From f0df7a01e9aa7888bd77dea1710e9aa8ee35c9bc Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:53:43 +0100 Subject: [PATCH] Chat List: Show suggestions (#6521) --- src/api/gramjs/apiBuilders/misc.ts | 27 ++ src/api/gramjs/methods/index.ts | 2 + src/api/gramjs/methods/misc.ts | 37 +++ src/api/gramjs/methods/settings.ts | 32 +-- src/api/types/misc.ts | 16 +- src/assets/localization/fallback.strings | 19 ++ src/assets/tgs/settings/DuckCake.tgs | Bin 0 -> 11491 bytes src/bundles/extra.ts | 1 + .../calls/group/GroupCallTopPane.scss | 2 +- .../common/helpers/animatedAssets.ts | 2 + src/components/common/helpers/renderText.tsx | 77 ++++-- src/components/common/profile/ChatExtra.tsx | 1 + .../common/profile/UserBirthday.tsx | 12 +- src/components/left/main/Archive.module.scss | 8 +- src/components/left/main/Archive.tsx | 60 ++++- src/components/left/main/Chat.scss | 2 +- src/components/left/main/Chat.tsx | 3 + src/components/left/main/ChatFolders.tsx | 16 +- src/components/left/main/ChatList.tsx | 65 ++--- .../left/main/FrozenAccountNotification.tsx | 25 -- .../left/main/UnconfirmedSession.tsx | 71 ------ src/components/left/main/forum/ForumPanel.tsx | 3 +- src/components/left/main/forum/Topic.tsx | 8 +- .../left/main/hooks/useChatAnimationType.ts | 20 +- .../left/main/hooks/useChatListEntry.tsx | 25 +- .../left/main/hooks/useOrderDiff.ts | 26 +- .../left/main/panes/ChatListPanes.module.scss | 5 + .../left/main/panes/ChatListPanes.tsx | 140 ++++++++++ .../FrozenAccountPane.module.scss} | 6 +- .../left/main/panes/FrozenAccountPane.tsx | 44 ++++ .../main/panes/SuggestionPane.module.scss | 56 ++++ .../left/main/panes/SuggestionPane.tsx | 104 ++++++++ .../UnconfirmedSessionPane.module.scss} | 9 +- .../main/panes/UnconfirmedSessionPane.tsx | 92 +++++++ src/components/left/settings/Settings.scss | 13 +- .../left/settings/SettingsEditProfile.tsx | 123 ++++++--- .../left/settings/SettingsPrivacy.tsx | 4 +- src/components/main/Dialogs.tsx | 2 +- src/components/main/Main.tsx | 2 + src/components/middle/MiddleHeaderPanes.tsx | 2 +- src/components/middle/hooks/useHeaderPane.tsx | 18 +- src/components/middle/panes/AudioPlayer.scss | 2 +- .../middle/panes/BotAdPane.module.scss | 4 +- .../panes/BotVerificationPane.module.scss | 2 +- .../middle/panes/ChatReportPane.scss | 2 +- .../panes/HeaderPinnedMessage.module.scss | 2 +- .../panes/PaidMessageChargePane.module.scss | 2 +- src/components/modals/ModalContainer.tsx | 5 +- .../birthday/BirthdaySetupModal.async.tsx | 14 + .../birthday/BirthdaySetupModal.module.scss | 61 +++++ .../modals/birthday/BirthdaySetupModal.tsx | 240 ++++++++++++++++++ .../modals/webApp/WebAppModalTabContent.tsx | 2 +- src/components/ui/InputText.tsx | 9 +- src/global/actions/api/settings.ts | 47 +++- src/global/actions/ui/misc.ts | 12 + src/global/actions/ui/settings.ts | 2 + src/global/selectors/sharedState.ts | 6 + src/global/types/actions.ts | 16 +- src/global/types/globalState.ts | 2 + src/global/types/tabState.ts | 5 + src/lib/gramjs/tl/apiTl.ts | 3 + src/lib/gramjs/tl/static/api.json | 7 +- src/styles/_mixins.scss | 30 ++- src/styles/_variables.scss | 1 + src/styles/index.scss | 4 + src/types/language.d.ts | 32 +++ src/util/localization/index.ts | 28 +- src/util/replaceWithTeact.ts | 10 +- 68 files changed, 1400 insertions(+), 330 deletions(-) create mode 100644 src/api/gramjs/methods/misc.ts create mode 100644 src/assets/tgs/settings/DuckCake.tgs delete mode 100644 src/components/left/main/FrozenAccountNotification.tsx delete mode 100644 src/components/left/main/UnconfirmedSession.tsx create mode 100644 src/components/left/main/panes/ChatListPanes.module.scss create mode 100644 src/components/left/main/panes/ChatListPanes.tsx rename src/components/left/main/{FrozenAccountNotification.module.scss => panes/FrozenAccountPane.module.scss} (78%) create mode 100644 src/components/left/main/panes/FrozenAccountPane.tsx create mode 100644 src/components/left/main/panes/SuggestionPane.module.scss create mode 100644 src/components/left/main/panes/SuggestionPane.tsx rename src/components/left/main/{UnconfirmedSession.module.scss => panes/UnconfirmedSessionPane.module.scss} (71%) create mode 100644 src/components/left/main/panes/UnconfirmedSessionPane.tsx create mode 100644 src/components/modals/birthday/BirthdaySetupModal.async.tsx create mode 100644 src/components/modals/birthday/BirthdaySetupModal.module.scss create mode 100644 src/components/modals/birthday/BirthdaySetupModal.tsx diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 751952f15..57b198d3d 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -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>((acc, mtpString) => { acc[mtpString.key] = oldBuildLangPackString(mtpString); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 5c1412807..510275ceb 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -46,3 +46,5 @@ export * from './fragment'; export * from './stars'; export * from './forum'; + +export * from './misc'; diff --git a/src/api/gramjs/methods/misc.ts b/src/api/gramjs/methods/misc.ts new file mode 100644 index 000000000..e306bfe31 --- /dev/null +++ b/src/api/gramjs/methods/misc.ts @@ -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 { + 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 { + const result = await invokeRequest(new GramJs.help.GetConfig()); + if (!result) return undefined; + + return buildApiConfig(result); +} + +export async function fetchPromoData(): Promise { + 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 { + await invokeRequest(new GramJs.help.DismissSuggestion({ + peer: new GramJs.InputPeerEmpty(), + suggestion, + })); +} diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 482a8fff4..79d0c0b9c 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -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 { - 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 { - 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, diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 3f408acf3..a75c692b7 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -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; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index b89002795..1bf376b05 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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, it’s not me!"; +"UnconfirmedAuthLocationRegion" = "{deviceModel}, {region}, {country}"; +"UnconfirmedAuthLocationCountry" = "{deviceModel}, {country}"; +"SuggestionBirthdaySetupTitle" = "Add your birthday! 🎂"; +"SuggestionBirthdaySetupMessage" = "Let your contacts know when you’re 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"; diff --git a/src/assets/tgs/settings/DuckCake.tgs b/src/assets/tgs/settings/DuckCake.tgs new file mode 100644 index 0000000000000000000000000000000000000000..521978d6d9bc5393748ac1ae373f1f5bbf887fb1 GIT binary patch literal 11491 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 { 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((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( - {part.substring(queryPosition, queryPosition + highlight.length)} + {postProcess(part.substring(queryPosition, queryPosition + highlight.length))} , ); - 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((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( - {nextLink} + {postProcess(nextLink)} , ); } else { @@ -250,13 +271,13 @@ function addLinks(textParts: TextPart[], allowOnlyTgLinks?: boolean): TextPart[] , ); } 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]; }, []); diff --git a/src/components/common/profile/ChatExtra.tsx b/src/components/common/profile/ChatExtra.tsx index 2b677f563..0c76a49a3 100644 --- a/src/components/common/profile/ChatExtra.tsx +++ b/src/components/common/profile/ChatExtra.tsx @@ -401,6 +401,7 @@ const ChatExtra = ({ { 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(); diff --git a/src/components/left/main/Archive.module.scss b/src/components/left/main/Archive.module.scss index 7c93da824..4a6628c08 100644 --- a/src/components/left/main/Archive.module.scss +++ b/src/components/left/main/Archive.module.scss @@ -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; } diff --git a/src/components/left/main/Archive.tsx b/src/components/left/main/Archive.tsx index f29f372ff..05b9c00c7 100644 --- a/src/components/left/main/Archive.tsx +++ b/src/components/left/main/Archive.tsx @@ -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 = ({ +const ANIMATION_RESET_DELAY = 200; + +const Archive = ({ archiveSettings, + isFoldersSidebarShown, + offsetTop, + animationType, onDragEnter, onClick, - isFoldersSidebarShown, -}) => { +}: OwnProps) => { const { updateArchiveSettings } = getActions(); + + const ref = useRef(); + + 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 = ({ return ( = ({ chatId, folderId, orderDiff, + shiftDiff, animationType, isPinned, listedTopicIds, @@ -239,6 +241,7 @@ const Chat: FC = ({ animationType, withInterfaceAnimations, orderDiff, + shiftDiff, isSavedDialog, isPreview, onReorderAnimationEnd, diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index df1a266d3..4677e6875 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -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; - isAccountFrozen?: boolean; }; const SAVED_MESSAGES_HOTKEY = '0'; @@ -76,8 +74,6 @@ const ChatFolders: FC = ({ hasArchivedStories, archiveSettings, isStoryRibbonShown, - sessions, - isAccountFrozen, isFoldersSidebarShown, }) => { const { @@ -232,8 +228,6 @@ const ChatFolders: FC = ({ 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( 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( maxChatLists: selectCurrentLimit(global, 'chatlistJoined'), archiveSettings, isStoryRibbonShown, - sessions, - isAccountFrozen, }; }, )(ChatFolders)); diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 94bce4e07..d27247886 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -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; - 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 = ({ +const ChatList = ({ className, folderType, folderId, @@ -67,24 +61,21 @@ const ChatList: FC = ({ isForumPanelOpen, canDisplayArchive, archiveSettings, - sessions, - isAccountFrozen, isMainList, withTags, isFoldersSidebarShown, isStoryRibbonShown, foldersDispatch, -}) => { +}: OwnProps) => { const { openChat, openNextChat, closeForumPanel, toggleStoryRibbon, - openFrozenAccountModal, openLeftColumnContent, } = getActions(); const containerRef = useRef(); - 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 = ({ ); const shouldDisplayArchive = isAllFolder && canDisplayArchive && archiveSettings; - const shouldShowFrozenAccountNotification = isAccountFrozen && isAllFolder; const orderedIds = useFolderManagerForOrderedIds(resolvedFolderId); usePeerStoriesPolling(orderedIds); @@ -102,24 +92,13 @@ const ChatList: FC = ({ 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 + to navigate between chats useHotkeys(useMemo(() => (isActive && orderedIds?.length ? { 'Alt+ArrowUp': (e: KeyboardEvent) => { @@ -178,10 +157,6 @@ const ChatList: FC = ({ closeForumPanel(); }); - const handleFrozenAccountNotificationClick = useLastCallback(() => { - openFrozenAccountModal(); - }); - const handleShowStoryRibbon = useLastCallback(() => { toggleStoryRibbon({ isShown: true, isArchived }); }); @@ -217,8 +192,7 @@ const ChatList: FC = ({ 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 ( = ({ isSavedDialog={isSaved} animationType={getAnimationType(id)} orderDiff={orderDiffById[id]} + shiftDiff={shiftDiff} onReorderAnimationEnd={onReorderAnimationEnd} offsetTop={offsetTop} observeIntersection={observe} @@ -250,28 +225,18 @@ const ChatList: FC = ({ itemSelector=".ListItem:not(.chat-item-archive)" preloadBackwards={CHAT_LIST_SLICE} withAbsolutePositioning - maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight} + maxHeight={chatsHeight + archiveHeight + panesHeight} onLoadMore={getMore} > - {shouldShowUnconfirmedSessions && ( - - )} - {shouldShowFrozenAccountNotification && ( - - )} + {isAllFolder && } {shouldDisplayArchive && ( )} diff --git a/src/components/left/main/FrozenAccountNotification.tsx b/src/components/left/main/FrozenAccountNotification.tsx deleted file mode 100644 index d98be9f04..000000000 --- a/src/components/left/main/FrozenAccountNotification.tsx +++ /dev/null @@ -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 ( -
-
{lang('TitleFrozenAccount')}
-
{lang('SubtitleFrozenAccount')}
-
- ); -}; - -export default memo(FrozenAccountNotification); diff --git a/src/components/left/main/UnconfirmedSession.tsx b/src/components/left/main/UnconfirmedSession.tsx deleted file mode 100644 index 8daf288d1..000000000 --- a/src/components/left/main/UnconfirmedSession.tsx +++ /dev/null @@ -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; - onHeightChange: (height: number) => void; -}; - -const UnconfirmedSession = ({ sessions, onHeightChange }: OwnProps) => { - const { changeSessionSettings, terminateAuthorization, showNotification } = getActions(); - const ref = useRef(); - 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 ( -
-

{lang('UnconfirmedAuthTitle')}

-

- {lang('UnconfirmedAuthSingle', locationString)} -

-
- - -
-
- ); -}; - -export default memo(UnconfirmedSession); diff --git a/src/components/left/main/forum/ForumPanel.tsx b/src/components/left/main/forum/ForumPanel.tsx index 49341f76b..9609a66cb 100644 --- a/src/components/left/main/forum/ForumPanel.tsx +++ b/src/components/left/main/forum/ForumPanel.tsx @@ -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} /> ); diff --git a/src/components/left/main/forum/Topic.tsx b/src/components/left/main/forum/Topic.tsx index 0d51030d5..40956a8b5 100644 --- a/src/components/left/main/forum/Topic.tsx +++ b/src/components/left/main/forum/Topic.tsx @@ -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; }; -const Topic: FC = ({ +const Topic = ({ topic, isSelected, chatId, @@ -93,12 +93,13 @@ const Topic: FC = ({ animationType, withInterfaceAnimations, orderDiff, + shiftDiff, typingStatus, draft, wasTopicOpened, topics, onReorderAnimationEnd, -}) => { +}: OwnProps & StateProps) => { const { openThread, deleteTopic, @@ -154,6 +155,7 @@ const Topic: FC = ({ animationType, withInterfaceAnimations, orderDiff, + shiftDiff, onReorderAnimationEnd, }); diff --git a/src/components/left/main/hooks/useChatAnimationType.ts b/src/components/left/main/hooks/useChatAnimationType.ts index 5b98f9a92..0540ecffb 100644 --- a/src/components/left/main/hooks/useChatAnimationType.ts +++ b/src/components/left/main/hooks/useChatAnimationType.ts @@ -1,20 +1,34 @@ import { useMemo } from '../../../../lib/teact/teact'; export enum ChatAnimationTypes { + Shift, Move, Opacity, None, } -export function useChatAnimationType(orderDiffById: Record) { +export const ARCHIVE_ANIMATION_ID = 'archive'; + +export function useChatAnimationType( + orderDiffById: Record, + isInitialRender: boolean, + isShifted?: boolean, +) { return useMemo(() => { + if (isInitialRender) { + return () => ChatAnimationTypes.None; + } + const orderDiffs = Object.values(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(orderDiffById: R return ChatAnimationTypes.Move; }; - }, [orderDiffById]); + }, [orderDiffById, isShifted, isInitialRender]); } diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index 9b8a0c48d..c852e639f 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -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, diff --git a/src/components/left/main/hooks/useOrderDiff.ts b/src/components/left/main/hooks/useOrderDiff.ts index c96b8c306..481745a3d 100644 --- a/src/components/left/main/hooks/useOrderDiff.ts +++ b/src/components/left/main/hooks/useOrderDiff.ts @@ -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>(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, }; diff --git a/src/components/left/main/panes/ChatListPanes.module.scss b/src/components/left/main/panes/ChatListPanes.module.scss new file mode 100644 index 000000000..7a8659dec --- /dev/null +++ b/src/components/left/main/panes/ChatListPanes.module.scss @@ -0,0 +1,5 @@ +.root { + position: absolute; + z-index: var(--z-left-header); + width: 100%; +} diff --git a/src/components/left/main/panes/ChatListPanes.tsx b/src/components/left/main/panes/ChatListPanes.tsx new file mode 100644 index 000000000..f9ccfad07 --- /dev/null +++ b/src/components/left/main/panes/ChatListPanes.tsx @@ -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; + 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(FALLBACK_PANE_STATE); + const [getFrozenAccountState, setFrozenAccountState] = useSignal(FALLBACK_PANE_STATE); + const [getSuggestionState, setSuggestionState] = useSignal(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 ( +
+ + + +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + sessions: global.activeSessions.byHash, + promoData: global.promoData, + isAccountFrozen: selectIsCurrentUserFrozen(global), + }; + }, +)(ChatListPanes)); diff --git a/src/components/left/main/FrozenAccountNotification.module.scss b/src/components/left/main/panes/FrozenAccountPane.module.scss similarity index 78% rename from src/components/left/main/FrozenAccountNotification.module.scss rename to src/components/left/main/panes/FrozenAccountPane.module.scss index 0acdfb862..2eaf3a29e 100644 --- a/src/components/left/main/FrozenAccountNotification.module.scss +++ b/src/components/left/main/panes/FrozenAccountPane.module.scss @@ -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); diff --git a/src/components/left/main/panes/FrozenAccountPane.tsx b/src/components/left/main/panes/FrozenAccountPane.tsx new file mode 100644 index 000000000..4c46e992a --- /dev/null +++ b/src/components/left/main/panes/FrozenAccountPane.tsx @@ -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 ( +
+
{lang('TitleFrozenAccount')}
+
{lang('SubtitleFrozenAccount')}
+
+ ); +}; + +export default memo(FrozenAccountPane); diff --git a/src/components/left/main/panes/SuggestionPane.module.scss b/src/components/left/main/panes/SuggestionPane.module.scss new file mode 100644 index 000000000..1d17e2a6a --- /dev/null +++ b/src/components/left/main/panes/SuggestionPane.module.scss @@ -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); + } +} diff --git a/src/components/left/main/panes/SuggestionPane.tsx b/src/components/left/main/panes/SuggestionPane.tsx new file mode 100644 index 000000000..c163dcfd8 --- /dev/null +++ b/src/components/left/main/panes/SuggestionPane.tsx @@ -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(SUPPORTED_SUGGESTIONS); + +const AUTOCLOSABLE_SUGGESTIONS = new Set([BIRTHDAY_SETUP]); + +const SUGGESTION_LANG_KEYS: Record = { + 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) => { + 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 ( +
+
{title}
+
{message}
+ +
+ ); +}; + +export default memo(SuggestionPane); diff --git a/src/components/left/main/UnconfirmedSession.module.scss b/src/components/left/main/panes/UnconfirmedSessionPane.module.scss similarity index 71% rename from src/components/left/main/UnconfirmedSession.module.scss rename to src/components/left/main/panes/UnconfirmedSessionPane.module.scss index 54c62e4c2..d7d139952 100644 --- a/src/components/left/main/UnconfirmedSession.module.scss +++ b/src/components/left/main/panes/UnconfirmedSessionPane.module.scss @@ -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 { diff --git a/src/components/left/main/panes/UnconfirmedSessionPane.tsx b/src/components/left/main/panes/UnconfirmedSessionPane.tsx new file mode 100644 index 000000000..e8ba960aa --- /dev/null +++ b/src/components/left/main/panes/UnconfirmedSessionPane.tsx @@ -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 ( +
+

{lang('UnconfirmedAuthTitle')}

+

+ {lang('UnconfirmedAuthSingle', { location: locationString })} +

+
+ + +
+
+ ); +}; + +export default memo(UnconfirmedSessionPane); diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index c1b1d4f2c..6456a08c5 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -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); +} diff --git a/src/components/left/settings/SettingsEditProfile.tsx b/src/components/left/settings/SettingsEditProfile.tsx index 04415f356..5c9954fdf 100644 --- a/src/components/left/settings/SettingsEditProfile.tsx +++ b/src/components/left/settings/SettingsEditProfile.tsx @@ -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 = ({ +const SettingsEditProfile = ({ isActive, currentAvatarHash, currentFirstName, currentLastName, + currentBirthday, currentBio, progress, checkedUsername, @@ -65,13 +69,16 @@ const SettingsEditProfile: FC = ({ 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 = ({ } }, [progress]); - const handlePhotoChange = useCallback((newPhoto: File) => { - setPhoto(newPhoto); - }, []); + const formattedBirthday = useMemo(() => { + if (!currentBirthday) return undefined; - const handleFirstNameChange = useCallback((e: ChangeEvent) => { + 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) => { setFirstName(e.target.value); setIsProfileFieldsTouched(true); - }, []); + }); - const handleLastNameChange = useCallback((e: ChangeEvent) => { + const handleLastNameChange = useLastCallback((e: React.ChangeEvent) => { setLastName(e.target.value); setIsProfileFieldsTouched(true); - }, []); + }); - const handleBioChange = useCallback((e: ChangeEvent) => { + const handleBioChange = useLastCallback((e: React.ChangeEvent) => { 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 = ({ username: editableUsername, }), }); - }, [ - photo, - firstName, lastName, bio, isProfileFieldsTouched, - editableUsername, isUsernameTouched, - updateProfile, - ]); + }); function renderPurchaseLink() { const purchaseInfoLink = `${TME_LINK_PREFIX}${PURCHASE_USERNAME}`; return ( -

- {(lang('lng_username_purchase_available')) +

+ {(oldLang('lng_username_purchase_available')) .replace('{link}', '%PURCHASE_LINK%') .split('%') .map((s) => { @@ -214,39 +236,57 @@ const SettingsEditProfile: FC = ({