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 000000000..521978d6d Binary files /dev/null and b/src/assets/tgs/settings/DuckCake.tgs differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 44b7d661c..6f5470f5c 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/calls/group/GroupCallTopPane.scss b/src/components/calls/group/GroupCallTopPane.scss index 8e1e2c4b4..46aa629c1 100644 --- a/src/components/calls/group/GroupCallTopPane.scss +++ b/src/components/calls/group/GroupCallTopPane.scss @@ -1,7 +1,7 @@ @use "../../../styles/mixins"; .GroupCallTopPane { - @include mixins.header-pane; + @include mixins.middle-header-pane; cursor: var(--custom-cursor, pointer); diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 1a81e8c42..b49914915 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -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, }; diff --git a/src/components/common/helpers/renderText.tsx b/src/components/common/helpers/renderText.tsx index 216e7605f..30faac8ed 100644 --- a/src/components/common/helpers/renderText.tsx +++ b/src/components/common/helpers/renderText.tsx @@ -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((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 = ({