diff --git a/.postcssrc b/.postcssrc deleted file mode 100644 index cf78de025..000000000 --- a/.postcssrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": { - "autoprefixer": {}, - } -} diff --git a/.stylelintrc.json b/.stylelintrc.json index bec84659f..571d67567 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -42,13 +42,22 @@ "selector-pseudo-class-no-unknown": [ true, { - "ignorePseudoClasses": ["global"] + "ignorePseudoClasses": ["global", "local"] } ], "plugin/selector-tag-no-without-class": ["/^(?!body|html)([^_-]*)$/", { "severity": "warning" }], "plugin/use-baseline": [true, { "severity": "warning", - "ignoreSelectors": ["nesting", "dir", "selection", "view-transition-group"], + "ignoreSelectors": [ + "nesting", + "dir", + "selection", + "view-transition", + "view-transition-group", + "view-transition-image-pair", + "view-transition-old", + "view-transition-new" + ], "ignoreProperties": [ "text-wrap", "outline", diff --git a/CLAUDE.md b/CLAUDE.md index 9b6e52111..2bb63851e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe - Early returns. - Prefix boolean variables with primary or modal auxiliaries (e.q. `isOpen`, `willUpdate`, `shouldRender`). - Functions should start with a verb (e.q. `openModal`, `closeDialog`, `handleClick`). - - Prefer checking required parameter before calling a function, avoid making it optinal and checking at the beginning of the function. + - Prefer checking required parameter before calling a function, avoid making it optional and checking at the beginning of the function. - Only leave comments for complex logic. - Do not use `null`. There's linter rule to enforce it. - **IMPORTANT: Avoid conditional spread operators** - TypeScript doesn't check if spread fields match the target type. @@ -203,8 +203,10 @@ const NewComp = (props: OwnProps & StateProps) => { … } ### Example ```ts -import React, { useFlag } from '../../lib/teact/teact'; +import { memo, useState, useRef } from '../../lib/teact/teact'; +import { withGlobal, getActions } from '../../global'; +import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -236,10 +238,10 @@ const Component = ({ id, className, stateValue, onClick }: OwnProps & StateProps const handleClick = useLastCallback(() => { if (!ref.current) return; const el = ref.current; - setColor(el.value); + setColor(el.dataset.value); close(); onClick?.(); - someAction(el.value); + someAction(el.dataset.value); }); return ( @@ -251,12 +253,11 @@ const Component = ({ id, className, stateValue, onClick }: OwnProps & StateProps } export default memo(withGlobal((global, { id }): Complete => { - const stateValue = selectValue(global, id); return { stateValue, }; - })(Component); + })(Component) ) ``` diff --git a/dev/postcss-remove-global.ts b/dev/postcss-remove-global.ts new file mode 100644 index 000000000..021398035 --- /dev/null +++ b/dev/postcss-remove-global.ts @@ -0,0 +1,46 @@ +import type { Plugin } from 'postcss'; + +const pluginName = 'postcss-remove-global'; + +function removeGlobalPlugin(): Plugin { + return { + postcssPlugin: pluginName, + Once(root, helpers) { + const file = helpers.result.opts.from || ''; + const isModule = /\.module\.(css|scss|sass)$/i.test(file); + if (isModule) { + return; + } + + // :global in rules + root.walkRules((rule) => { + // :global as nested selector + const globalReg = /:global(\s+)/; + // :global(.selector) as nested selector + const globalWithSelectorReg = /:global\(\s*((?:[a-zA-Z0-9.#:[\]_\-\s>+~]+))\s*\)/; + if (rule.selector === ':global') { + const parent = rule.parent || root; + parent.append(...rule.nodes); + rule.remove(); + } else if (rule.selector.match(globalReg)) { + rule.selector = rule.selector.replace(globalReg, ''); + } else if (rule.selector.match(globalWithSelectorReg)) { + rule.selector = rule.selector.replace(globalWithSelectorReg, '$1'); + } + }); + // :global in AtRules + root.walkAtRules((atRule) => { + const name = atRule.name; + const params = atRule.params; + const globalReg = /:global\((\w+)\)/; + if (name === 'keyframes' && params.match(globalReg)) { + atRule.params = params.replace(globalReg, '$1'); + } + }); + }, + }; +} + +removeGlobalPlugin.postcss = true; + +export default removeGlobalPlugin; diff --git a/eslint.config.mjs b/eslint.config.js similarity index 100% rename from eslint.config.mjs rename to eslint.config.js diff --git a/jest.config.js b/jest.config.js index c4e60411b..ee95888b2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,8 @@ export default { moduleNameMapper: { '\\.(css|scss|wasm|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|tgs)$': '/tests/staticFileMock.js', + '^@teact$': '/src/lib/teact/teact.ts', + '^@teact/(.*)$': '/src/lib/teact/$1', }, testPathIgnorePatterns: [ '/tests/playwright/', diff --git a/package-lock.json b/package-lock.json index 895d20c63..762f65792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,8 @@ "lint-staged": "^16.1.2", "mini-css-extract-plugin": "^2.9.2", "minimatch": "^10.0.3", + "postcss": "^8.5.6", + "postcss-load-config": "^6.0.1", "postcss-loader": "^8.1.1", "postcss-modules": "^6.0.1", "react": "^19.1.0", @@ -16084,6 +16086,49 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/postcss-loader": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", diff --git a/package.json b/package.json index f2215d3a5..36e01e7a1 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,8 @@ "lint-staged": "^16.1.2", "mini-css-extract-plugin": "^2.9.2", "minimatch": "^10.0.3", + "postcss": "^8.5.6", + "postcss-load-config": "^6.0.1", "postcss-loader": "^8.1.1", "postcss-modules": "^6.0.1", "react": "^19.1.0", diff --git a/postcss.config.ts b/postcss.config.ts new file mode 100644 index 000000000..aff4bcf54 --- /dev/null +++ b/postcss.config.ts @@ -0,0 +1,13 @@ +import autoprefixer from 'autoprefixer'; +import type { Config } from 'postcss-load-config'; + +import removeGlobalPlugin from './dev/postcss-remove-global.ts'; + +const config: Config = { + plugins: [ + removeGlobalPlugin(), + autoprefixer(), + ], +}; + +export default config; diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 45f4fd89d..4c74bd0a9 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -83,6 +83,7 @@ function buildApiChatFieldsFromPeerEntity( const botVerificationIconId = userOrChannel?.botVerificationIcon?.toString(); const storiesUnavailable = userOrChannel?.storiesUnavailable; const color = userOrChannel?.color ? buildApiPeerColor(userOrChannel.color) : undefined; + const profileColor = userOrChannel?.profileColor ? buildApiPeerColor(userOrChannel.profileColor) : undefined; const emojiStatus = userOrChannel?.emojiStatus ? buildApiEmojiStatus(userOrChannel.emojiStatus) : undefined; const paidMessagesStars = userOrChannel?.sendPaidMessagesStars; const isVerified = userOrChannel?.verified; @@ -107,6 +108,7 @@ function buildApiChatFieldsFromPeerEntity( isCreator, fakeType: isScam ? 'scam' : (isFake ? 'fake' : undefined), color, + profileColor, isJoinToSend: channel?.joinToSend, isJoinRequest: channel?.joinRequest, isForum: channel?.forum, diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 36443b0c7..29137a84b 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -9,6 +9,7 @@ import type { ApiOldLangString, ApiPeerColors, ApiPeerNotifySettings, + ApiPeerProfileColorSet, ApiPrivacyKey, ApiRestrictionReason, ApiSession, @@ -293,11 +294,16 @@ export function buildApiLanguage(lang: GramJs.TypeLangPackLanguage): ApiLanguage }; } -function buildApiPeerColorSet(colorSet: GramJs.help.TypePeerColorSet) { - if (colorSet instanceof GramJs.help.PeerColorSet) { - return colorSet.colors.map((color) => numberToHexColor(color)); - } - return undefined; +function buildApiPeerColorSet(colorSet: GramJs.help.PeerColorSet) { + return colorSet.colors.map((color) => numberToHexColor(color)); +} + +function buildApiPeerProfileColorSet(colorSet: GramJs.help.PeerColorProfileSet): ApiPeerProfileColorSet { + return { + paletteColors: colorSet.paletteColors.map((color) => numberToHexColor(color)), + bgColors: colorSet.bgColors.map((color) => numberToHexColor(color)), + storyColors: colorSet.storyColors.map((color) => numberToHexColor(color)), + }; } export function buildApiPeerColors(wrapper: GramJs.help.TypePeerColors): ApiPeerColors['general'] | undefined { @@ -306,8 +312,24 @@ export function buildApiPeerColors(wrapper: GramJs.help.TypePeerColors): ApiPeer return buildCollectionByCallback(wrapper.colors, (color) => { return [color.colorId, { isHidden: color.hidden, - colors: color.colors && buildApiPeerColorSet(color.colors), - darkColors: color.darkColors && buildApiPeerColorSet(color.darkColors), + colors: color.colors instanceof GramJs.help.PeerColorSet + ? buildApiPeerColorSet(color.colors) : undefined, + darkColors: color.darkColors instanceof GramJs.help.PeerColorSet + ? buildApiPeerColorSet(color.darkColors) : undefined, + }]; + }); +} + +export function buildApiPeerProfileColors(wrapper: GramJs.help.TypePeerColors): ApiPeerColors['profile'] | undefined { + if (!(wrapper instanceof GramJs.help.PeerColors)) return undefined; + + return buildCollectionByCallback(wrapper.colors, (color) => { + return [color.colorId, { + isHidden: color.hidden, + colors: color.colors instanceof GramJs.help.PeerColorProfileSet + ? buildApiPeerProfileColorSet(color.colors) : undefined, + darkColors: color.darkColors instanceof GramJs.help.PeerColorProfileSet + ? buildApiPeerProfileColorSet(color.darkColors) : undefined, }]; }); } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 375b59493..1089be2e3 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -101,7 +101,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { const { id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId, bot, botActiveUsers, botVerificationIcon, botInlinePlaceholder, botAttachMenu, botCanEdit, - sendPaidMessagesStars, username, + sendPaidMessagesStars, username, profileColor, } = mtpUser; const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined; const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo); @@ -141,6 +141,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { botActiveUsers, botVerificationIconId: botVerificationIcon?.toString(), color: mtpUser.color && buildApiPeerColor(mtpUser.color), + profileColor: profileColor && buildApiPeerColor(profileColor), paidMessagesStars: sendPaidMessagesStars?.toJSNumber(), }; } diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index bd7c8d631..f02da81c7 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -33,6 +33,7 @@ import { buildApiLanguage, buildApiPeerColors, buildApiPeerNotifySettings, + buildApiPeerProfileColors, buildApiSession, buildApiTimezone, buildApiWallpaper, @@ -631,6 +632,23 @@ export async function fetchPeerColors(hash?: number) { }; } +export async function fetchPeerProfileColors(hash?: number) { + const result = await invokeRequest(new GramJs.help.GetPeerProfileColors({ + hash: hash ?? DEFAULT_PRIMITIVES.INT, + })); + if (!result) return undefined; + + const colors = buildApiPeerProfileColors(result); + if (!colors) return undefined; + + const newHash = result instanceof GramJs.help.PeerColors ? result.hash : undefined; + + return { + colors, + hash: newHash, + }; +} + export async function fetchTimezones(hash?: number) { const result = await invokeRequest(new GramJs.help.GetTimezonesList({ hash: hash ?? DEFAULT_PRIMITIVES.INT, diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 915e78388..246ca1f99 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -44,6 +44,7 @@ export interface ApiChat { isProtected?: boolean; fakeType?: ApiFakeType; color?: ApiPeerColor; + profileColor?: ApiPeerColor; emojiStatus?: ApiEmojiStatusType; isForum?: boolean; isForumAsMessages?: true; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index a2b4d5709..81d2d320c 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -288,14 +288,23 @@ export interface ApiConfig { } export type ApiPeerColorSet = string[]; +export type ApiPeerProfileColorSet = { + paletteColors: string[]; + bgColors: string[]; + storyColors: string[]; +}; + +export type ApiPeerColorOption = { + isHidden?: true; + colors?: T; + darkColors?: T; +}; export interface ApiPeerColors { - general: Record; + general: Record>; generalHash?: number; + profile: Record>; + profileHash?: number; } export interface ApiTimezone { diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 5e2092421..885b736a5 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -35,6 +35,7 @@ export interface ApiUser { hasUnreadStories?: boolean; maxStoryId?: number; color?: ApiPeerColor; + profileColor?: ApiPeerColor; canEditBot?: boolean; hasMainMiniApp?: boolean; botActiveUsers?: number; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index a69b90632..6629d44d3 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -66,6 +66,7 @@ cn.icon = cn('icon'); type OwnProps = { className?: string; + style?: string; size?: AvatarSize; peer?: ApiPeer | CustomPeer; photo?: ApiPhoto; @@ -79,6 +80,7 @@ type OwnProps = { forPremiumPromo?: boolean; withStoryGap?: boolean; withStorySolid?: boolean; + storyColors?: string[]; forceFriendStorySolid?: boolean; forceUnreadStorySolid?: boolean; storyViewerOrigin?: StoryViewerOrigin; @@ -94,6 +96,7 @@ type OwnProps = { const Avatar: FC = ({ className, + style, size = 'large', peer, photo, @@ -107,6 +110,7 @@ const Avatar: FC = ({ forPremiumPromo, withStoryGap, withStorySolid, + storyColors, forceFriendStorySolid, forceUnreadStorySolid, storyViewerOrigin, @@ -130,6 +134,9 @@ const Avatar: FC = ({ const isReplies = realPeer && isChatWithRepliesBot(realPeer.id); const isAnonymousForwards = realPeer && isAnonymousForwardsChat(realPeer.id); const isForum = chat?.isForum; + + const isStoryClickable = withStory && storyViewerMode !== 'disabled' && realPeer?.hasStories; + let imageHash: string | undefined; let videoHash: string | undefined; @@ -271,14 +278,14 @@ const Avatar: FC = ({ withStorySolid && realPeer?.hasStories && 'with-story-solid', withStorySolid && forceFriendStorySolid && 'close-friend', withStorySolid && (realPeer?.hasUnreadStories || forceUnreadStorySolid) && 'has-unread-story', - onClick && 'interactive', + (onClick || isStoryClickable) && 'interactive', (!isSavedMessages && !imgUrl) && 'no-photo', ); const hasMedia = Boolean(isSavedMessages || imgUrl); const { handleClick, handleMouseDown } = useFastClick((e: ReactMouseEvent) => { - if (withStory && storyViewerMode !== 'disabled' && realPeer?.hasStories) { + if (isStoryClickable) { e.stopPropagation(); openStoryViewer({ @@ -302,7 +309,7 @@ const Avatar: FC = ({ data-peer-id={realPeer?.id} data-test-sender-id={IS_TEST ? realPeer?.id : undefined} aria-label={typeof content === 'string' ? author : undefined} - style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`)} + style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`, style)} onClick={handleClick} onContextMenu={onContextMenu} onMouseDown={handleMouseDown} @@ -312,7 +319,12 @@ const Avatar: FC = ({ {typeof content === 'string' ? renderText(content, [isBig ? 'hq_emoji' : 'emoji']) : content} {withStory && realPeer?.hasStories && ( - + )} ); diff --git a/src/components/common/AvatarStoryCircle.tsx b/src/components/common/AvatarStoryCircle.tsx index de92a5993..c6b636b4c 100644 --- a/src/components/common/AvatarStoryCircle.tsx +++ b/src/components/common/AvatarStoryCircle.tsx @@ -13,11 +13,11 @@ import { REM } from './helpers/mediaDimensions'; import useDevicePixelRatio from '../../hooks/window/useDevicePixelRatio'; interface OwnProps { - peerId: string; className?: string; size: number; withExtraGap?: boolean; + colors?: string[]; } interface StateProps { @@ -29,14 +29,13 @@ interface StateProps { const BLUE = ['#34C578', '#3CA3F3']; const GREEN = ['#C9EB38', '#09C167']; -const PURPLE = ['#A667FF', '#55A5FF']; const GRAY = '#C4C9CC'; const DARK_GRAY = '#737373'; const STROKE_WIDTH = 0.125 * REM; -const STROKE_WIDTH_READ = 0.0625 * REM; +const STROKE_WIDTH_LARGE = 0.25 * REM; const GAP_PERCENT = 2; const SEGMENTS_MAX = 45; // More than this breaks rendering in Safari and Chrome -const LARGE_AVATAR_SIZE = 3.5 * REM; +const LARGE_SIZE = 4 * REM; const GAP_PERCENT_EXTRA = 10; const EXTRA_GAP_ANGLE = Math.PI / 4; @@ -52,12 +51,15 @@ function AvatarStoryCircle({ lastReadId, withExtraGap, appTheme, + colors, }: OwnProps & StateProps) { const ref = useRef(); const dpr = useDevicePixelRatio(); - const adaptedSize = size + STROKE_WIDTH; + const isLarge = size > LARGE_SIZE; + const strokeWidth = isLarge ? STROKE_WIDTH_LARGE : STROKE_WIDTH; + const adaptedSize = size + strokeWidth + (isLarge ? 0.25 * REM : 0); // Add extra gap space for large avatars const values = useMemo(() => { return (storyIds || []).reduce((acc, id) => { @@ -92,15 +94,16 @@ function AvatarStoryCircle({ drawGradientCircle({ canvas: ref.current, - size: adaptedSize * dpr, + size: adaptedSize, + strokeWidth, segmentsCount: values.total, - color: isCloseFriend ? 'green' : 'blue', + colorStops: colors || (isCloseFriend ? GREEN : BLUE), readSegmentsCount: values.read, withExtraGap, readSegmentColor: appTheme === 'dark' ? DARK_GRAY : GRAY, dpr, }); - }, [appTheme, isCloseFriend, adaptedSize, values.read, values.total, withExtraGap, dpr]); + }, [appTheme, isCloseFriend, adaptedSize, values.read, values.total, withExtraGap, dpr, colors, size, strokeWidth]); if (!values.total) { return undefined; @@ -130,7 +133,8 @@ export default memo(withGlobal((global, { peerId }): Complete { - gradient.addColorStop(index / (colorStops.length - 1), colorStop); - }); + if (colorStops.length === 1) { + gradient.addColorStop(0, colorStops[0]); + gradient.addColorStop(1, colorStops[0]); + } else { + colorStops.forEach((colorStop, index) => { + gradient.addColorStop(index / (colorStops.length - 1), colorStop); + }); + } ctx.lineCap = 'round'; - ctx.clearRect(0, 0, size, size); + ctx.clearRect(0, 0, canvasSize, canvasSize); Array.from({ length: segmentsCount }).forEach((_, i) => { const isRead = i < readSegmentsCount; @@ -186,7 +199,7 @@ export function drawGradientCircle({ let endAngle = startAngle + segmentAngle - (segmentsCount > 1 ? gapSize : 0); ctx.strokeStyle = isRead ? readSegmentColor : gradient; - ctx.lineWidth = (isRead ? STROKE_WIDTH_READ : STROKE_WIDTH) * strokeModifier; + ctx.lineWidth = strokeWidth * (isRead ? 0.5 : 1); if (withExtraGap) { if (startAngle >= EXTRA_GAP_START && endAngle <= EXTRA_GAP_END) { // Segment is inside extra gap diff --git a/src/components/common/CustomEmoji.module.scss b/src/components/common/CustomEmoji.module.scss index fec46eefe..a0f3fdaf2 100644 --- a/src/components/common/CustomEmoji.module.scss +++ b/src/components/common/CustomEmoji.module.scss @@ -16,15 +16,6 @@ } } -.withSparkles { - position: relative; -} - -.sparkles { - position: absolute; - inset: -0.25rem; -} - .placeholder { width: 85%; height: 85%; diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index 49ab91f93..e6b484456 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -13,7 +13,6 @@ import useDynamicColorListener from '../../hooks/stickers/useDynamicColorListene import useLastCallback from '../../hooks/useLastCallback'; import useCustomEmoji from './hooks/useCustomEmoji'; -import Sparkles from './Sparkles'; import StickerView from './StickerView'; import styles from './CustomEmoji.module.scss'; @@ -42,9 +41,6 @@ type OwnProps = { observeIntersectionForPlaying?: ObserveFn; onClick?: NoneToVoidFunction; onAnimationEnd?: NoneToVoidFunction; - withSparkles?: boolean; - sparklesClassName?: string; - sparklesStyle?: string; }; const STICKER_SIZE = 20; @@ -71,9 +67,6 @@ const CustomEmoji: FC = ({ observeIntersectionForPlaying, onClick, onAnimationEnd, - withSparkles, - sparklesStyle, - sparklesClassName, }) => { let containerRef = useRef(); if (ref) { @@ -120,7 +113,6 @@ const CustomEmoji: FC = ({ ref={containerRef} className={buildClassName( styles.root, - withSparkles && styles.withSparkles, className, 'custom-emoji', 'emoji', @@ -132,16 +124,6 @@ const CustomEmoji: FC = ({ data-alt={customEmoji?.emoji} style={style} > - {withSparkles && ( - - )} {isSelectable && ( = ({ className, + style, peer, noVerified, noFake, @@ -67,9 +69,9 @@ const FullNameTitle: FC = ({ noLoopLimit, canCopyTitle, iconElement, - statusSparklesColor, isMonoforum, monoforumBadgeClassName, + withStatusTextColor, onEmojiStatusClick, observeIntersection, }) => { @@ -123,7 +125,7 @@ const FullNameTitle: FC = ({ const botVerificationIconId = realPeer?.botVerificationIconId; return ( -
+
{botVerificationIconId && ( = ({ direction={-1} shouldCleanup > - + sparklesColor={emojiStatus.type === 'collectible' && !withStatusTextColor + ? emojiStatus.textColor : undefined} + > + + )} {canShowEmojiStatus && !emojiStatus && isPremium && } diff --git a/src/components/common/ProfileInfo.module.scss b/src/components/common/ProfileInfo.module.scss deleted file mode 100644 index 6a5be5096..000000000 --- a/src/components/common/ProfileInfo.module.scss +++ /dev/null @@ -1,317 +0,0 @@ -.fallbackPhoto { - pointer-events: none; - - position: absolute; - z-index: 1; - - display: flex; - justify-content: center; - - width: 100%; - padding-top: 1rem; - padding-bottom: 0.5rem; - - opacity: 0; - background: linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); - - transition: 0.25s ease-in-out opacity; -} - -.fallbackPhotoContents { - pointer-events: none; - cursor: var(--custom-cursor, pointer); - user-select: none; - - display: flex; - align-items: center; - - height: 1.5rem; - - font-size: 0.75rem; - color: var(--color-white); - - opacity: 0.5; - - transition: 0.25s ease-in-out opacity; - - &:hover { - opacity: 1; - } -} - -.fallbackPhotoVisible { - opacity: 1; - - .fallbackPhotoContents { - pointer-events: all; - } -} - -.fallbackPhotoAvatar { - margin-right: 0.5rem; -} - -.photoWrapper { - position: absolute; - top: 0; - bottom: 0; - left: 0; - - overflow: hidden; - - width: 100%; - - > :global(.Transition) { - width: 100%; - height: 100%; - } -} - -.photoDashes { - position: absolute; - z-index: 2; - top: 0.5rem; - left: 0; - - display: flex; - - width: 100%; - height: 0.125rem; - padding: 0 0.375rem; -} - -.photoDash { - flex: 1 1 auto; - - margin: 0 0.125rem; - border-radius: 0.125rem; - - opacity: 0.25; - background-color: var(--color-white); - - transition: opacity 300ms ease; - - &_current { - opacity: 0.75; - } -} - -.navigation { - cursor: var(--custom-cursor, pointer); - - position: absolute; - z-index: 1; - top: 0; - bottom: 0; - - width: 25%; - margin: 0; - padding: 0; - border: none; - - appearance: none; - opacity: 0.25; - background: transparent no-repeat; - background-size: 1.25rem; - outline: none !important; - - transition: opacity 0.15s; - - &:global(:hover), - :global(.is-touch-env) & { - opacity: 1; - } - - &_prev { - left: 0; - background-image: url("../../assets/media_navigation_previous.svg"); - background-position: 1.25rem 50%; - - &[dir="rtl"] { - right: 0; - left: auto; - transform: scaleX(-1); - } - } - - &_next { - right: 0; - background-image: url("../../assets/media_navigation_next.svg"); - background-position: calc(100% - 1.25rem) 50%; - - &[dir="rtl"] { - right: auto; - left: 0; - transform: scaleX(-1); - } - } -} - -.info { - pointer-events: none; - - position: absolute; - bottom: 0; - left: 0; - - display: flex; - flex-direction: column; - justify-content: flex-end; - - width: 100%; - min-height: 100px; - padding: 0 1.5rem 0.5rem; - - color: var(--color-white); - - background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); - - :global(.statusSparkles) { - color: var(--color-white) !important; - } - - &:dir(rtl) { - .status { - unicode-bidi: plaintext; - text-align: right; - } - } - - &[dir="rtl"] { - .status { - unicode-bidi: plaintext; - text-align: right; - } - } -} - -.status { - display: flex; - align-items: center; - font-size: 0.875rem; -} - -.userRatingNegativeWrapper, -.userRatingWrapper { - pointer-events: all; - cursor: pointer; - - position: relative; - z-index: 1; - - display: inline-flex; - align-items: center; - justify-content: center; - - margin-right: 0.25rem; - - font-size: 1rem; - color: #000000; -} - -.userRatingWrapper { - width: 1rem; - font-size: 1.5rem; -} - -.ratingNegativeIcon { - pointer-events: none; - font-size: 1rem; - color: var(--color-white); - - -webkit-text-stroke: 1px #000000; -} - -.ratingIcon { - pointer-events: none; - color: var(--color-white); - - -webkit-text-stroke: 1px #000000; -} - -.ratingLevel { - pointer-events: none; - - position: absolute; - z-index: 1; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - font-size: 0.625rem; - font-weight: var(--font-weight-bold); - line-height: 1; - color: #000000; -} - -.userStatus { - opacity: 0.5; -} - -.getStatus { - --blured-background-color: #c4c9cc42; - - pointer-events: all; - cursor: var(--custom-cursor, pointer); - - margin-inline-start: 0.375rem; - padding: 0.1875rem 0.375rem; - border-radius: 1rem; - - font-size: 0.75rem; - font-weight: var(--font-weight-medium); - - opacity: 0.8; - background: var(--blured-background-color); - backdrop-filter: blur(100px); - - transition: 150ms filter ease-in; - - &:hover { - filter: brightness(1.1); - } -} - -.topicContainer { - --custom-emoji-size: 7.5rem; - - padding: 1rem 1rem 0.75rem; -} - -.topicTitle { - margin: 0.5rem 0 0; - font-size: 1.25rem; - line-height: 1.5rem; - text-align: center; -} - -.topicIcon { - display: flex !important; - width: 7.5rem !important; - height: 7.5rem !important; - margin: auto; - - &:global(.general-forum-icon) { - font-size: 7.5rem; - color: var(--color-text-secondary); - } -} - -.topicIconTitle { - font-size: 3rem !important; - font-weight: var(--font-weight-normal); - - :global(.emoji-small) { - width: 3rem; - height: 3rem; - } -} - -.topicMessagesCounter { - margin: 0; - - font-size: 0.875rem; - line-height: 1.25rem; - color: var(--color-text-secondary); - text-align: center; -} diff --git a/src/components/common/Sparkles.module.scss b/src/components/common/Sparkles.module.scss index 04e525d88..a7dfe409b 100644 --- a/src/components/common/Sparkles.module.scss +++ b/src/components/common/Sparkles.module.scss @@ -32,6 +32,11 @@ animation-delay: var(--_duration-shift); } +.noAnimation .symbol { + opacity: 0.5; + animation: none; +} + @keyframes sparkle { 0% { transform: translate(0, 0); diff --git a/src/components/common/Sparkles.tsx b/src/components/common/Sparkles.tsx index e7c3a2e43..d9e2dbf44 100644 --- a/src/components/common/Sparkles.tsx +++ b/src/components/common/Sparkles.tsx @@ -18,6 +18,7 @@ type PresetParameters = ButtonParameters | ProgressParameters; type OwnProps = { className?: string; style?: string; + noAnimation?: boolean; } & PresetParameters; const SYMBOL = '✦'; @@ -85,11 +86,15 @@ const PROGRESS_POSITIONS = generateRandomProgressPositions(100); const Sparkles = ({ className, style, + noAnimation, ...presetSettings }: OwnProps) => { if (presetSettings.preset === 'button') { return ( -
+
{BUTTON_POSITIONS.map((position) => { const shiftX = Math.cos(Math.atan2(-50 + position.y, -50 + position.x)) * 100; const shiftY = Math.sin(Math.atan2(-50 + position.y, -50 + position.x)) * 100; @@ -104,6 +109,7 @@ const Sparkles = ({ `--_shift-y: ${shiftY}%`, `scale: ${position.size}%`, )} + aria-hidden="true" > {SYMBOL}
@@ -128,6 +134,7 @@ const Sparkles = ({ `scale: ${position.scale}%`, `--_duration-shift: ${(-position.durationShift / 100) * ANIMATION_DURATION}s`, )} + aria-hidden="true" > {SYMBOL}
diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 4824e5f06..1bfe79398 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -25,7 +25,6 @@ import Button from '../ui/Button'; import Menu from '../ui/Menu'; import MenuItem from '../ui/MenuItem'; import Icon from './icons/Icon'; -import Sparkles from './Sparkles'; import StickerView from './StickerView'; import './StickerButton.scss'; @@ -58,7 +57,6 @@ type OwnProps = { onContextMenuClose?: NoneToVoidFunction; onContextMenuClick?: NoneToVoidFunction; isEffectEmoji?: boolean; - withSparkles?: boolean; }; const contentForStatusMenuContext = [ @@ -97,7 +95,6 @@ const StickerButton = ) => { const { openStickerSet, openPremiumModal, setEmojiStatus } = getActions(); const ref = useRef(); @@ -292,7 +289,6 @@ const StickerButton = - {withSparkles && } {isIntesectingForShowing && ( = ({ const reactionId = sticker.isCustomEmoji ? sticker.id : sticker.emoji; const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; - const withSparkles = sticker.id === COLLECTIBLE_STATUS_SET_ID + const withSparkles = stickerSet.id === COLLECTIBLE_STATUS_SET_ID || collectibleEmojiIdsSet?.has(sticker.id); - return ( + const component = ( = ({ isEffectEmoji={stickerSet.id === EFFECT_EMOJIS_SET_ID} noShowPremium={isCurrentUserPremium && (stickerSet.id === EFFECT_STICKERS_SET_ID || stickerSet.id === EFFECT_EMOJIS_SET_ID)} - withSparkles={withSparkles} /> ); + + if (withSparkles) { + return ( + + {component} + + ); + } + + return component; })} {isCut && totalItemsCount > itemsBeforeCutout && (
+ {!isFirst && ( +
+ )} + {!isExpanded && ( + + )} -
+
{(user || chat) && ( = ({ export default memo(withGlobal( (global, { peerId }): Complete => { + const peer = selectPeer(global, peerId); const user = selectUser(global, peerId); const userFullInfo = user ? selectUserFullInfo(global, peerId) : undefined; const userStatus = selectUserStatus(global, peerId); @@ -462,10 +598,16 @@ export default memo(withGlobal( const topic = isForum && currentTopicId ? selectTopic(global, peerId, currentTopicId) : undefined; const { animationLevel } = selectSharedSettings(global); - const emojiStatus = (user || chat)?.emojiStatus; + const emojiStatus = peer?.emojiStatus; const emojiStatusSticker = emojiStatus ? selectCustomEmoji(global, emojiStatus.documentId) : undefined; const emojiStatusSlug = emojiStatus?.type === 'collectible' ? emojiStatus.slug : undefined; + const profileColor = peer && selectPeerProfileColor(global, peer); + const theme = selectTheme(global); + + const hasBackground = selectPeerHasProfileBackground(global, peerId); + const savedGifts = selectPeerSavedGifts(global, peerId); + return { user, userFullInfo, @@ -476,9 +618,14 @@ export default memo(withGlobal( animationLevel, emojiStatusSticker, emojiStatusSlug, + emojiStatus, profilePhotos, topic, messagesCount: topic ? selectThreadMessagesCount(global, peerId, currentTopicId!) : undefined, + profileColorOption: profileColor, + theme, + isPlain: !hasBackground, + savedGifts, }; }, )(ProfileInfo)); diff --git a/src/components/common/ProfilePhoto.scss b/src/components/common/profile/ProfilePhoto.scss similarity index 100% rename from src/components/common/ProfilePhoto.scss rename to src/components/common/profile/ProfilePhoto.scss diff --git a/src/components/common/ProfilePhoto.tsx b/src/components/common/profile/ProfilePhoto.tsx similarity index 79% rename from src/components/common/ProfilePhoto.tsx rename to src/components/common/profile/ProfilePhoto.tsx index e1d576732..f6500dd63 100644 --- a/src/components/common/ProfilePhoto.tsx +++ b/src/components/common/profile/ProfilePhoto.tsx @@ -1,9 +1,9 @@ -import type { FC, TeactNode } from '../../lib/teact/teact'; +import type { FC, TeactNode } from '../../../lib/teact/teact'; import { memo, useEffect, useMemo, useRef, -} from '../../lib/teact/teact'; +} from '../../../lib/teact/teact'; -import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; +import type { ApiChat, ApiPhoto, ApiUser } from '../../../api/types'; import { getChatAvatarHash, @@ -15,24 +15,24 @@ import { isAnonymousForwardsChat, isChatWithRepliesBot, isDeletedUser, -} from '../../global/helpers'; -import { IS_CANVAS_FILTER_SUPPORTED } from '../../util/browser/windowEnvironment'; -import buildClassName from '../../util/buildClassName'; -import { isUserId } from '../../util/entities/ids'; -import { getFirstLetters } from '../../util/textFormat'; -import { getPeerColorClass } from './helpers/peerColor'; -import renderText from './helpers/renderText'; +} from '../../../global/helpers'; +import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment'; +import buildClassName from '../../../util/buildClassName'; +import { isUserId } from '../../../util/entities/ids'; +import { getFirstLetters } from '../../../util/textFormat'; +import { getPeerColorClass } from '../helpers/peerColor'; +import renderText from '../helpers/renderText'; -import useAppLayout from '../../hooks/useAppLayout'; -import useCanvasBlur from '../../hooks/useCanvasBlur'; -import useFlag from '../../hooks/useFlag'; -import useMedia from '../../hooks/useMedia'; -import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; -import useOldLang from '../../hooks/useOldLang'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useCanvasBlur from '../../../hooks/useCanvasBlur'; +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useMedia from '../../../hooks/useMedia'; +import useMediaTransitionDeprecated from '../../../hooks/useMediaTransitionDeprecated'; -import OptimizedVideo from '../ui/OptimizedVideo'; -import Spinner from '../ui/Spinner'; -import Icon from './icons/Icon'; +import OptimizedVideo from '../../ui/OptimizedVideo'; +import Spinner from '../../ui/Spinner'; +import Icon from '../icons/Icon'; import './ProfilePhoto.scss'; @@ -43,6 +43,8 @@ type OwnProps = { isSavedDialog?: boolean; photo?: ApiPhoto; canPlayVideo: boolean; + className?: string; + style?: string; onClick: NoneToVoidFunction; }; @@ -53,11 +55,13 @@ const ProfilePhoto: FC = ({ isSavedMessages, isSavedDialog, canPlayVideo, + className, + style, onClick, }) => { const videoRef = useRef(); - const lang = useOldLang(); + const lang = useLang(); const { isMobile } = useAppLayout(); const isDeleted = user && isDeletedUser(user); @@ -142,6 +146,7 @@ const ProfilePhoto: FC = ({ ) : ( @@ -171,10 +176,11 @@ const ProfilePhoto: FC = ({ isDeleted && 'deleted-account', isRepliesChat && 'replies-bot-account', (!isSavedMessages && !hasMedia) && 'no-photo', + className, ); return ( -
+
{typeof content === 'string' ? renderText(content, ['hq_emoji']) : content}
); diff --git a/src/components/common/profile/ProfilePinnedGifts.module.scss b/src/components/common/profile/ProfilePinnedGifts.module.scss new file mode 100644 index 000000000..5bbad1450 --- /dev/null +++ b/src/components/common/profile/ProfilePinnedGifts.module.scss @@ -0,0 +1,57 @@ +@use "../../../styles/mixins"; + +.root { + position: relative; + overflow: hidden; +} + +.gift { + cursor: var(--custom-cursor, pointer); + + position: absolute !important; + transform: translate(-50%, -50%); + + width: 2rem; + height: 2rem; + + @include mixins.with-vt-type('profileAvatar'); +} + +@include mixins.on-active-vt('profileExpand') { + &::view-transition-new(.profilePinnedGift) { + :local { + animation-name: fadeOut; + animation-timing-function: ease-out; + } + + @keyframes fadeOut { + to { + opacity: 0; + } + } + } + + &::view-transition-old(.profilePinnedGift) { + display: none; + } +} + +@include mixins.on-active-vt('profileCollapse') { + &::view-transition-old(.profilePinnedGift) { + display: none; + } + + &::view-transition-new(.profilePinnedGift) { + :local { + opacity: 0; + animation-name: fadeIn; + animation-timing-function: ease-in; + } + + @keyframes fadeIn { + to { + opacity: 1; + } + } + } +} diff --git a/src/components/common/profile/ProfilePinnedGifts.tsx b/src/components/common/profile/ProfilePinnedGifts.tsx new file mode 100644 index 000000000..189c2042c --- /dev/null +++ b/src/components/common/profile/ProfilePinnedGifts.tsx @@ -0,0 +1,125 @@ +import { memo, useMemo, useRef } from '@teact'; +import { getActions } from '../../../global'; + +import type { ApiSavedStarGift } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; +import buildStyle from '../../../util/buildStyle'; +import { clamp } from '../../../util/math'; +import { getGiftAttributes } from '../helpers/gifts'; +import { REM } from '../helpers/mediaDimensions'; + +import { useVtn } from '../../../hooks/animations/useVtn'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import GiftEffectWrapper from '../gift/GiftEffectWrapper'; +import StickerView from '../StickerView'; + +import styles from './ProfilePinnedGifts.module.scss'; + +type OwnProps = { + peerId: string; + className?: string; + gifts?: ApiSavedStarGift[]; + isExpanded?: boolean; + withGlow?: boolean; +}; + +const GIFT_STICKER_SIZE = 2 * REM; +const POSITIONS = [ + { x: -0.2, y: -0.3 }, + { x: 0.3, y: 0.1 }, + { x: -0.4, y: -0.1 }, + { x: 0.4, y: -0.1 }, + { x: -0.25, y: 0.1 }, + { x: 0.25, y: -0.25 }, +]; + +const CENTER = { x: 0.5, y: 0.5 }; + +const ProfilePinnedGifts = ({ + peerId, + gifts, + isExpanded, + className, + withGlow, +}: OwnProps) => { + const { createVtnStyle } = useVtn(); + + if (!gifts) return undefined; + + return ( +
+ {gifts.slice(0, POSITIONS.length).map((gift, index) => { + const position = !isExpanded ? POSITIONS[index] : getExpandedPosition(POSITIONS[index]); + const style = buildStyle( + `top: ${(CENTER.y + position.y) * 100}%`, + `left: ${(CENTER.x + position.x) * 100}%`, + ); + return ( + + ); + })} +
+ ); +}; + +function getExpandedPosition(position: { x: number; y: number }) { + return { + x: clamp(position.x * 1.5, -0.45, 0.45), + y: clamp(position.y * 1.5, -0.45, 0.45), + }; +} + +const PinnedGift = ({ + gift, style, className, withGlow, peerId, +}: { + gift: ApiSavedStarGift; + style?: string; + className?: string; + withGlow?: boolean; + peerId: string; +}) => { + const { openGiftInfoModal } = getActions(); + + const stickerRef = useRef(); + + const giftAttributes = useMemo(() => { + return getGiftAttributes(gift.gift); + }, [gift]); + + const handleClick = useLastCallback(() => { + openGiftInfoModal({ peerId, gift }); + }); + + if (!giftAttributes?.model || !giftAttributes.backdrop) return undefined; + + return ( + + + + ); +}; + +export default memo(ProfilePinnedGifts); diff --git a/src/components/common/profile/RadialPatternBackground.module.scss b/src/components/common/profile/RadialPatternBackground.module.scss index b4c084443..95ecf3870 100644 --- a/src/components/common/profile/RadialPatternBackground.module.scss +++ b/src/components/common/profile/RadialPatternBackground.module.scss @@ -3,11 +3,14 @@ border-radius: inherit; &::before { + pointer-events: none; content: ''; + position: absolute; inset: 0; + background-image: - radial-gradient(circle closest-side, #ffffff32, #ffffff00), + radial-gradient(circle closest-side, #ffffff32 3rem, #ffffff00 7rem), radial-gradient(closest-side, var(--_bg-1), var(--_bg-2)); } } diff --git a/src/components/common/profile/RadialPatternBackground.tsx b/src/components/common/profile/RadialPatternBackground.tsx index 651e7eaeb..ad148bdb1 100644 --- a/src/components/common/profile/RadialPatternBackground.tsx +++ b/src/components/common/profile/RadialPatternBackground.tsx @@ -23,6 +23,7 @@ type OwnProps = { patternIcon?: ApiSticker; className?: string; clearBottomSector?: boolean; + patternSize?: number; }; const RINGS = 3; @@ -40,6 +41,7 @@ const RadialPatternBackground = ({ patternIcon, clearBottomSector, className, + patternSize = 1, }: OwnProps) => { const containerRef = useRef(); const canvasRef = useRef(); @@ -78,13 +80,12 @@ const RadialPatternBackground = ({ const xOffset = ringRadius * 1.71 * Math.cos(angle); const yOffset = ringRadius * Math.sin(angle); - const x = 0.5 + xOffset; - const y = 0.5 + yOffset; - const sizeFactor = 1.4 - ringProgress * Math.random(); coordinates.push({ - x, y, sizeFactor, + x: xOffset, + y: yOffset, + sizeFactor, }); } } @@ -115,15 +116,16 @@ const RadialPatternBackground = ({ const { width, height } = canvas; if (!width || !height) return; + ctx.clearRect(0, 0, width, height); + ctx.save(); patternPositions.forEach(({ x, y, sizeFactor, }) => { - const centerShift = (width - Math.max(width, MIN_SIZE * dpr)) / 2; // Shift coords if canvas is smaller than `MIN_SIZE` - const renderX = x * Math.max(width, MIN_SIZE * dpr) + centerShift; - const renderY = y * Math.max(height, MIN_SIZE * dpr) + centerShift; + const renderX = x * patternSize * Math.max(width, MIN_SIZE * dpr) + width / 2; + const renderY = y * patternSize * Math.max(height, MIN_SIZE * dpr) + height / 2; - const size = BASE_ICON_SIZE * dpr * sizeFactor * (centerShift ? 0.8 : 1); + const size = BASE_ICON_SIZE * dpr * patternSize * sizeFactor; ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size); }); diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 47e77374e..837bbf42b 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -13,12 +13,12 @@ import { type AnimationLevel, LeftColumnContent, SettingsScreens } from '../../t import { selectCurrentChat, selectIsCurrentUserFrozen, selectIsForumPanelOpen, selectTabState, } from '../../global/selectors'; -import { selectSharedSettings } from '../../global/selectors/sharedState.ts'; +import { selectSharedSettings } from '../../global/selectors/sharedState'; import { IS_APP, IS_FIREFOX, IS_MAC_OS, IS_TOUCH_ENV, } from '../../util/browser/windowEnvironment'; import captureEscKeyListener from '../../util/captureEscKeyListener'; -import { resolveTransitionName } from '../../util/resolveTransitionName.ts'; +import { resolveTransitionName } from '../../util/resolveTransitionName'; import { captureControlledSwipe } from '../../util/swipeController'; import useFoldersReducer from '../../hooks/reducers/useFoldersReducer'; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 165959d2c..55149786d 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -430,6 +430,7 @@ const Chat: FC = ({ isSavedMessages={chatId === user?.id && user?.isSelf} isSavedDialog={isSavedDialog} observeIntersection={observeIntersection} + withStatusTextColor={isSelected} /> {isMuted && !isSavedDialog && }
diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index 33b5cefd2..79fb4a93d 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -13,13 +13,13 @@ import { SettingsScreens } from '../../../types'; import { ALL_FOLDER_ID } from '../../../config'; import { selectCanShareFolder, selectIsCurrentUserFrozen, selectTabState } from '../../../global/selectors'; import { selectCurrentLimit } from '../../../global/selectors/limits'; -import { selectSharedSettings } from '../../../global/selectors/sharedState.ts'; +import { selectSharedSettings } from '../../../global/selectors/sharedState'; import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; -import { resolveTransitionName } from '../../../util/resolveTransitionName.ts'; +import { resolveTransitionName } from '../../../util/resolveTransitionName'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; import useDerivedState from '../../../hooks/useDerivedState'; diff --git a/src/components/left/main/StatusButton.tsx b/src/components/left/main/StatusButton.tsx index f518dd619..96fef0513 100644 --- a/src/components/left/main/StatusButton.tsx +++ b/src/components/left/main/StatusButton.tsx @@ -14,6 +14,7 @@ import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import CustomEmoji from '../../common/CustomEmoji'; +import GiftEffectWrapper from '../../common/gift/GiftEffectWrapper'; import StarIcon from '../../common/icons/StarIcon'; import CustomEmojiEffect from '../../common/reactions/CustomEmojiEffect'; import Button from '../../ui/Button'; @@ -37,6 +38,8 @@ const StatusButton: FC = ({ emojiStatus, collectibleStatuses, isAcco const [isStatusPickerOpen, openStatusPicker, closeStatusPicker] = useFlag(false); const { isMobile } = useAppLayout(); + const collectibleEmojiStatus = emojiStatus?.type === 'collectible' ? emojiStatus : undefined; + const delay = emojiStatus?.until ? (emojiStatus.until - getServerTime()) * 1000 : undefined; useTimeout(loadCurrentUser, delay); @@ -86,13 +89,18 @@ const StatusButton: FC = ({ emojiStatus, collectibleStatuses, isAcco onClick={handleEmojiStatusClick} > {emojiStatus ? ( - + + + ) : } = ({ )} {currentUserId && ( diff --git a/src/components/left/settings/SettingsPrivacy.tsx b/src/components/left/settings/SettingsPrivacy.tsx index 72ae8d9d4..77c95dac1 100644 --- a/src/components/left/settings/SettingsPrivacy.tsx +++ b/src/components/left/settings/SettingsPrivacy.tsx @@ -6,17 +6,17 @@ import type { ApiPrivacySettings } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; import { SettingsScreens } from '../../../types'; -import { ACCOUNT_TTL_OPTIONS } from '../../../config.ts'; +import { ACCOUNT_TTL_OPTIONS } from '../../../config'; import { selectCanSetPasscode, selectIsCurrentUserFrozen, selectIsCurrentUserPremium, } from '../../../global/selectors'; import { selectSharedSettings } from '../../../global/selectors/sharedState'; -import { getClosestEntry } from '../../../util/getClosestEntry.ts'; +import { getClosestEntry } from '../../../util/getClosestEntry'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useLang from '../../../hooks/useLang'; -import useLastCallback from '../../../hooks/useLastCallback.ts'; +import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import StarIcon from '../../common/icons/StarIcon'; diff --git a/src/components/main/premium/previews/PremiumFeaturePreviewStories.tsx b/src/components/main/premium/previews/PremiumFeaturePreviewStories.tsx index a64a1f316..1f9ff60d8 100644 --- a/src/components/main/premium/previews/PremiumFeaturePreviewStories.tsx +++ b/src/components/main/premium/previews/PremiumFeaturePreviewStories.tsx @@ -22,6 +22,8 @@ type StateProps = { currentUser: ApiUser; }; +const STORY_COLORS = ['#A667FF', '#55A5FF']; + const STORY_FEATURE_TITLES = { stories_order: 'PremiumStoriesPriority', stories_stealth: 'PremiumStoriesStealth', @@ -54,7 +56,8 @@ const STORY_FEATURE_ICONS: Record = { const STORY_FEATURE_ORDER = Object.keys(STORY_FEATURE_TITLES) as (keyof typeof STORY_FEATURE_TITLES)[]; -const CIRCLE_SIZE = AVATAR_SIZES.giant + 0.25 * REM; +const CIRCLE_STROKE_WIDTH = 0.25 * REM; +const CIRCLE_SIZE = AVATAR_SIZES.giant + CIRCLE_STROKE_WIDTH; const CIRCLE_SEGMENTS = 8; const CIRCLE_READ_SEGMENTS = 0; @@ -74,9 +77,10 @@ const PremiumFeaturePreviewVideo = ({ drawGradientCircle({ canvas: circleRef.current, - size: CIRCLE_SIZE * dpr, + size: CIRCLE_SIZE, + strokeWidth: CIRCLE_STROKE_WIDTH, segmentsCount: CIRCLE_SEGMENTS, - color: 'purple', + colorStops: STORY_COLORS, readSegmentsCount: CIRCLE_READ_SEGMENTS, readSegmentColor: 'transparent', dpr, diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 7c2e01c11..195206404 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -1,6 +1,6 @@ import type { FC } from '@teact'; import { beginHeavyAnimation, memo, useEffect, useMemo, useRef } from '@teact'; -import { addExtraClass, removeExtraClass } from '@teact/teact-dom.ts'; +import { addExtraClass, removeExtraClass } from '@teact/teact-dom'; import { getActions, getGlobal, withGlobal } from '../../global'; import type { ApiChatFullInfo, ApiMessage, ApiRestrictionReason, ApiTopic } from '../../api/types'; diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 663a06612..ac21d553f 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -54,7 +54,7 @@ import { selectTopics, selectUserFullInfo, } from '../../global/selectors'; -import { selectSharedSettings } from '../../global/selectors/sharedState.ts'; +import { selectSharedSettings } from '../../global/selectors/sharedState'; import { IS_TAURI } from '../../util/browser/globalEnvironment'; import { IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, @@ -63,7 +63,7 @@ import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import { isUserId } from '../../util/entities/ids'; -import { resolveTransitionName } from '../../util/resolveTransitionName.ts'; +import { resolveTransitionName } from '../../util/resolveTransitionName'; import calculateMiddleFooterTransforms from './helpers/calculateMiddleFooterTransforms'; import useAppLayout from '../../hooks/useAppLayout'; @@ -345,7 +345,7 @@ function MiddleColumn({ return () => { visualViewport.removeEventListener('resize', handleResize); }; - }); + }, []); useEffect(() => { if (isPrivate) { @@ -508,7 +508,7 @@ function MiddleColumn({ `--composer-hidden-scale: ${composerHiddenScale}`, `--toolbar-hidden-scale: ${toolbarHiddenScale}`, `--unpin-hidden-scale: ${unpinHiddenScale}`, - `--toolbar-unpin-hidden-scale: ${toolbarForUnpinHiddenScale},`, + `--toolbar-unpin-hidden-scale: ${toolbarForUnpinHiddenScale}`, `--composer-translate-x: ${composerTranslateX}px`, `--toolbar-translate-x: ${toolbarTranslateX}px`, `--pattern-color: ${patternColor}`, diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index 41c9ea337..c99f97ee0 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -295,6 +295,7 @@ .symbol-set-container { display: grid !important; + grid-auto-rows: var(--emoji-size, 4.5rem); grid-template-columns: repeat(auto-fill, var(--emoji-size, 4.5rem)); row-gap: 0.25rem; column-gap: var(--symbol-set-gap-size, 0.625rem); @@ -311,7 +312,8 @@ } > .EmojiButton, - > .StickerButton { + > .StickerButton, + > .gift-effect-wrapper > .StickerButton { margin: 0; } diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 4daa20887..27ad3e09a 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -11,10 +11,10 @@ import type { MenuPositionOptions } from '../../ui/Menu'; import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { selectIsContextMenuTranslucent, selectTabState } from '../../../global/selectors'; -import { selectSharedSettings } from '../../../global/selectors/sharedState.ts'; +import { selectSharedSettings } from '../../../global/selectors/sharedState'; import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; -import { resolveTransitionName } from '../../../util/resolveTransitionName.ts'; +import { resolveTransitionName } from '../../../util/resolveTransitionName'; import useAppLayout from '../../../hooks/useAppLayout'; import useLastCallback from '../../../hooks/useLastCallback'; @@ -36,7 +36,6 @@ import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './Symb import './SymbolMenu.scss'; const ANIMATION_DURATION = 350; -const STICKERS_TAB_INDEX = 2; export type OwnProps = { chatId: string; @@ -123,7 +122,7 @@ const SymbolMenu: FC = ({ // If we can't send plain text, we should always show the stickers tab useEffect(() => { if (canSendPlainText) return; - setActiveTab(STICKERS_TAB_INDEX); + setActiveTab(SymbolMenuTabs.Stickers); }, [canSendPlainText]); useLayoutEffect(() => { @@ -178,7 +177,7 @@ const SymbolMenu: FC = ({ }); }); - setRecentEmojis([]); + setRecentCustomEmojis([]); }, [isOpen, addRecentCustomEmoji]); const handleCustomEmojiSelect = useLastCallback((emoji: ApiSticker) => { diff --git a/src/components/modals/gift/status/GiftStatusInfoModal.tsx b/src/components/modals/gift/status/GiftStatusInfoModal.tsx index 5d3751fb5..8f79b6e13 100644 --- a/src/components/modals/gift/status/GiftStatusInfoModal.tsx +++ b/src/components/modals/gift/status/GiftStatusInfoModal.tsx @@ -102,7 +102,6 @@ const GiftStatusInfoModal = ({ withEmojiStatus noFake noVerified - statusSparklesColor={subtitleColor} />

{lang('Online')} diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index ecfd2ec95..5857a1b22 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -21,6 +21,7 @@ @include mixins.adapt-padding-to-scrollbar(0.5rem); @include mixins.side-panel-section; + @include mixins.with-vt-type('rightColumn'); .narrow { margin-bottom: 0; @@ -84,6 +85,8 @@ flex: 1; flex-direction: column-reverse; + @include mixins.with-vt-type('rightColumn'); + .TabList { z-index: 1; background: var(--color-background); @@ -241,4 +244,8 @@ transition: none; } } + + .saved-gift { + @include mixins.with-vt-type('profileGifts'); + } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index adbc48bfe..015b21c1c 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -25,6 +25,7 @@ import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSI import { selectActiveGiftsCollectionId } from '../../global/selectors/payments'; const CONTENT_PANEL_SHOW_DELAY = 300; +import { forceMutation } from '../../lib/fasterdom/fasterdom.ts'; import { getHasAdminRight, getIsDownloading, @@ -60,18 +61,26 @@ import { selectPremiumLimit } from '../../global/selectors/limits'; import { selectMessageDownloadableMedia } from '../../global/selectors/media'; import { selectSharedSettings } from '../../global/selectors/sharedState'; import { selectActiveStoriesCollectionId } from '../../global/selectors/stories'; +import { + VTT_PROFILE_GIFTS, + VTT_RIGHT_PROFILE_COLLAPSE, + VTT_RIGHT_PROFILE_EXPAND, +} from '../../util/animations/viewTransitionTypes.ts'; import { areDeepEqual } from '../../util/areDeepEqual'; import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { isUserId } from '../../util/entities/ids'; +import { stopScrollInertia } from '../../util/resetScroll.ts'; import { resolveTransitionName } from '../../util/resolveTransitionName.ts'; import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets'; import renderText from '../common/helpers/renderText'; import { getSenderName } from '../left/search/helpers/getSenderName'; import { useViewTransition } from '../../hooks/animations/useViewTransition'; +import { useVtn } from '../../hooks/animations/useVtn.ts'; import usePeerStoriesPolling from '../../hooks/polling/usePeerStoriesPolling'; +import useTopOverscroll from '../../hooks/scroll/useTopOverscroll.tsx'; import useCacheBuster from '../../hooks/useCacheBuster'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useFlag from '../../hooks/useFlag'; @@ -96,7 +105,7 @@ import NothingFound from '../common/NothingFound'; import PreviewMedia from '../common/PreviewMedia'; import PrivateChatInfo from '../common/PrivateChatInfo'; import ChatExtra from '../common/profile/ChatExtra'; -import ProfileInfo from '../common/ProfileInfo'; +import ProfileInfo from '../common/profile/ProfileInfo.tsx'; import WebLink from '../common/WebLink'; import ChatList from '../left/main/ChatList'; import MediaStory from '../story/MediaStory'; @@ -172,6 +181,7 @@ type StateProps = { isSavedDialog?: boolean; forceScrollProfileTab?: boolean; isSynced?: boolean; + hasAvatar?: boolean; }; type TabProps = { @@ -242,6 +252,7 @@ const Profile: FC = ({ isSavedDialog, forceScrollProfileTab, isSynced, + hasAvatar, onProfileStateChange, }) => { const { @@ -274,10 +285,11 @@ const Profile: FC = ({ const lang = useLang(); const [deletingUserId, setDeletingUserId] = useState(); - const [isViewTransitionEnabled, enableViewTransition, disableViewTransition] = useFlag(); + const [isGiftTransitionEnabled, enableGiftTransition, disableGiftTransition] = useFlag(); const profileId = isSavedDialog ? String(threadId) : chatId; const isSavedMessages = profileId === currentUserId && !isSavedDialog; + const [isProfileExpanded, expandProfile, collapseProfile] = useFlag(); const [restoreContentHeightKey, setRestoreContentHeightKey] = useState(0); @@ -384,11 +396,11 @@ const Profile: FC = ({ }, [chatId]); useSyncEffect(() => { - enableViewTransition(); + enableGiftTransition(); }, [giftsFilter]); useSyncEffect(() => { - disableViewTransition(); + disableGiftTransition(); }, [gifts]); useEffect(() => { @@ -399,7 +411,8 @@ const Profile: FC = ({ }, [chatId, hasGiftsTab, isSynced]); const [renderingGifts, setRenderingGifts] = useState(gifts); - const { startViewTransition, shouldApplyVtn } = useViewTransition(); + const { startViewTransition } = useViewTransition(); + const { createVtnStyle } = useVtn(); const giftIds = useMemo(() => renderingGifts?.map((gift) => getSavedGiftKey(gift)), [renderingGifts]); @@ -427,7 +440,7 @@ const Profile: FC = ({ return; } - if (!gifts || !prevGifts || !isViewTransitionEnabled) { + if (!gifts || !prevGifts || !isGiftTransitionEnabled) { setRenderingGifts(gifts); return; } @@ -436,14 +449,14 @@ const Profile: FC = ({ const newGiftIds = gifts.map((gift) => getSavedGiftKey(gift)); const hasOrderChanged = prevGiftIds.some((id, index) => id !== newGiftIds[index]); - if (hasOrderChanged && animationLevel > 0) { - startViewTransition(() => { + if (hasOrderChanged) { + startViewTransition(VTT_PROFILE_GIFTS, () => { setRenderingGifts(gifts); }); } else { setRenderingGifts(gifts); } - }, [gifts, startViewTransition, animationLevel, isViewTransitionEnabled]); + }, [gifts, startViewTransition, isGiftTransitionEnabled]); const [resultType, viewportIds, getMore, noProfileInfo] = useProfileViewportIds({ loadMoreMembers: handleLoadMoreMembers, @@ -509,15 +522,34 @@ const Profile: FC = ({ stopAutoScrollToTabs(); }); - const { handleScroll } = useProfileState( + const handleExpandProfile = useLastCallback(() => { + if (isProfileExpanded) return; + startViewTransition(VTT_RIGHT_PROFILE_EXPAND, () => { + expandProfile(); + }); + }); + + const handleCollapseProfile = useLastCallback(() => { + if (!isProfileExpanded) return; + const scrollContainer = containerRef.current; + startViewTransition(VTT_RIGHT_PROFILE_COLLAPSE, () => { + if (!scrollContainer) return; + forceMutation(() => { + stopScrollInertia(scrollContainer); + }, scrollContainer); + collapseProfile(); + }); + }); + + const { handleScroll } = useProfileState({ containerRef, - resultType, + tabType: resultType, profileState, - onProfileStateChange, forceScrollProfileTab, allowAutoScrollToTabs, + onProfileStateChange, handleStopAutoScrollToTabs, - ); + }); const { applyTransitionFix, releaseTransitionFix } = useTransitionFixes(containerRef); @@ -590,6 +622,10 @@ const Profile: FC = ({ resetGiftProfileFilter({ peerId: chatId }); }); + const renderedOverflowTrigger = useTopOverscroll( + containerRef, handleExpandProfile, handleCollapseProfile, !hasAvatar, + ); + useEffect(() => { if (!transitionRef.current || !IS_TOUCH_ENV) { return undefined; @@ -977,7 +1013,8 @@ const Profile: FC = ({ @@ -1003,6 +1040,24 @@ const Profile: FC = ({ setRestoreContentHeightKey(restoreContentHeightKey + 1); }); + function renderProfileInfo(peerId: string, isReady: boolean) { + return ( +

+ + +
+ ); + } + function renderSpinnerOrContent(noContent: boolean, noSpinner: boolean) { const baseContent = renderSpinnerOrContentBase(noContent, noSpinner); @@ -1059,6 +1114,7 @@ const Profile: FC = ({ itemSelector={itemSelector} items={canRenderContent ? viewportIds : undefined} cacheBuster={cacheBuster} + beforeChildren={renderedOverflowTrigger} sensitiveArea={PROFILE_SENSITIVE_AREA} preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0} // To prevent scroll jumps caused by reordering member list @@ -1071,13 +1127,12 @@ const Profile: FC = ({ renderProfileInfo( monoforumChannel?.id || profileId, isRightColumnShown && canRenderContent, - isSavedDialog, - Boolean(monoforumChannel), ) )} {!isRestricted && (
= ({ ); }; -function renderProfileInfo(profileId: string, isReady: boolean, isSavedDialog?: boolean, isForMonoforum?: boolean) { - return ( -
- - -
- ); -} - export default memo(withGlobal( (global, { chatId, threadId, isMobile, @@ -1201,6 +1247,7 @@ export default memo(withGlobal( const monoforumChannel = selectMonoforumChannel(global, chatId); const isRestricted = chat && selectIsChatRestricted(global, chat.id); + const hasAvatar = Boolean(peer?.avatarPhotoId); return { theme: selectTheme(global), @@ -1251,6 +1298,7 @@ export default memo(withGlobal( adminMembersById: hasMembersTab ? adminMembersById : undefined, commonChatIds: commonChats?.ids, monoforumChannel, + hasAvatar, }; }, )(Profile)); diff --git a/src/components/right/RightHeader.scss b/src/components/right/RightHeader.scss index 5f59209ec..4678385e3 100644 --- a/src/components/right/RightHeader.scss +++ b/src/components/right/RightHeader.scss @@ -1,3 +1,5 @@ +@use "../../styles/mixins"; + .RightHeader { pointer-events: auto; @@ -7,6 +9,10 @@ height: var(--header-height); padding: 0.5rem 0.8125rem; + background: var(--color-background); + + @include mixins.with-vt-type('rightColumn'); + .close-button { flex-shrink: 0; } @@ -69,3 +75,9 @@ padding: 0.5rem; } } + +@include mixins.on-active-vt('rightColumn') { + &::view-transition-group(.rightHeader) { + z-index: 10; + } +} diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 7bd1c01c6..cbb543a5a 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -31,6 +31,7 @@ import { IS_MAC_OS } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import { isUserId } from '../../util/entities/ids'; +import { useVtn } from '../../hooks/animations/useVtn'; import useAppLayout from '../../hooks/useAppLayout'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useFlag from '../../hooks/useFlag'; @@ -195,6 +196,7 @@ const RightHeader: FC = ({ const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const { isMobile } = useAppLayout(); + const { createVtnStyle } = useVtn(); const { sortType: giftsSortType, @@ -697,7 +699,11 @@ const RightHeader: FC = ({ ); return ( -
+