Profile: Display user background (#6205)

This commit is contained in:
zubiden 2025-09-30 16:52:04 +02:00 committed by Alexander Zinchuk
parent 38b9836e68
commit 7c6f646153
78 changed files with 1820 additions and 749 deletions

View File

@ -1,5 +0,0 @@
{
"plugins": {
"autoprefixer": {},
}
}

View File

@ -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",

View File

@ -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<OwnProps>((global, { id }): Complete<StateProps> => {
const stateValue = selectValue(global, id);
return {
stateValue,
};
})(Component);
})(Component)
)
```

View File

@ -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;

View File

@ -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)$':
'<rootDir>/tests/staticFileMock.js',
'^@teact$': '<rootDir>/src/lib/teact/teact.ts',
'^@teact/(.*)$': '<rootDir>/src/lib/teact/$1',
},
testPathIgnorePatterns: [
'<rootDir>/tests/playwright/',

45
package-lock.json generated
View File

@ -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",

View File

@ -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",

13
postcss.config.ts Normal file
View File

@ -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;

View File

@ -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,

View File

@ -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) {
function buildApiPeerColorSet(colorSet: GramJs.help.PeerColorSet) {
return colorSet.colors.map((color) => numberToHexColor(color));
}
return undefined;
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,
}];
});
}

View File

@ -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(),
};
}

View File

@ -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,

View File

@ -44,6 +44,7 @@ export interface ApiChat {
isProtected?: boolean;
fakeType?: ApiFakeType;
color?: ApiPeerColor;
profileColor?: ApiPeerColor;
emojiStatus?: ApiEmojiStatusType;
isForum?: boolean;
isForumAsMessages?: true;

View File

@ -288,14 +288,23 @@ export interface ApiConfig {
}
export type ApiPeerColorSet = string[];
export type ApiPeerProfileColorSet = {
paletteColors: string[];
bgColors: string[];
storyColors: string[];
};
export type ApiPeerColorOption<T extends ApiPeerColorSet | ApiPeerProfileColorSet> = {
isHidden?: true;
colors?: T;
darkColors?: T;
};
export interface ApiPeerColors {
general: Record<number, {
isHidden?: true;
colors?: ApiPeerColorSet;
darkColors?: ApiPeerColorSet;
}>;
general: Record<number, ApiPeerColorOption<ApiPeerColorSet>>;
generalHash?: number;
profile: Record<number, ApiPeerColorOption<ApiPeerProfileColorSet>>;
profileHash?: number;
}
export interface ApiTimezone {

View File

@ -35,6 +35,7 @@ export interface ApiUser {
hasUnreadStories?: boolean;
maxStoryId?: number;
color?: ApiPeerColor;
profileColor?: ApiPeerColor;
canEditBot?: boolean;
hasMainMiniApp?: boolean;
botActiveUsers?: number;

View File

@ -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<OwnProps> = ({
className,
style,
size = 'large',
peer,
photo,
@ -107,6 +110,7 @@ const Avatar: FC<OwnProps> = ({
forPremiumPromo,
withStoryGap,
withStorySolid,
storyColors,
forceFriendStorySolid,
forceUnreadStorySolid,
storyViewerOrigin,
@ -130,6 +134,9 @@ const Avatar: FC<OwnProps> = ({
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<OwnProps> = ({
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<HTMLDivElement, MouseEvent>) => {
if (withStory && storyViewerMode !== 'disabled' && realPeer?.hasStories) {
if (isStoryClickable) {
e.stopPropagation();
openStoryViewer({
@ -302,7 +309,7 @@ const Avatar: FC<OwnProps> = ({
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<OwnProps> = ({
{typeof content === 'string' ? renderText(content, [isBig ? 'hq_emoji' : 'emoji']) : content}
</div>
{withStory && realPeer?.hasStories && (
<AvatarStoryCircle peerId={realPeer.id} size={pxSize} withExtraGap={withStoryGap} />
<AvatarStoryCircle
peerId={realPeer.id}
size={pxSize}
withExtraGap={withStoryGap}
colors={storyColors}
/>
)}
</div>
);

View File

@ -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<HTMLCanvasElement>();
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<OwnProps>((global, { peerId }): Complete<StatePro
export function drawGradientCircle({
canvas,
size,
color,
strokeWidth: strokeWidthPx = STROKE_WIDTH,
colorStops,
segmentsCount,
readSegmentsCount = 0,
withExtraGap = false,
@ -138,8 +142,9 @@ export function drawGradientCircle({
dpr,
}: {
canvas: HTMLCanvasElement;
strokeWidth?: number;
size: number;
color: string;
colorStops: string[];
segmentsCount: number;
readSegmentsCount?: number;
withExtraGap?: boolean;
@ -152,33 +157,41 @@ export function drawGradientCircle({
segmentsCount = SEGMENTS_MAX;
}
const strokeModifier = Math.max(Math.max(size - LARGE_AVATAR_SIZE * dpr, 0) / dpr / REM / 1.5, 1) * dpr;
const sizeModifier = dpr;
const strokeModifier = dpr;
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
canvas.width = size;
canvas.height = size;
const centerCoordinate = size / 2;
const radius = (size - STROKE_WIDTH * strokeModifier) / 2;
const canvasSize = size * sizeModifier;
const strokeWidth = strokeWidthPx * strokeModifier;
canvas.width = canvasSize;
canvas.height = canvasSize;
const centerCoordinate = canvasSize / 2;
const radius = (canvasSize - strokeWidth) / 2;
const segmentAngle = (2 * Math.PI) / segmentsCount;
const gapSize = (GAP_PERCENT / 100) * (2 * Math.PI);
const gradient = ctx.createLinearGradient(
0,
0,
Math.ceil(size * Math.cos(Math.PI / 2)),
Math.ceil(size * Math.sin(Math.PI / 2)),
Math.ceil(canvasSize * Math.cos(Math.PI / 2)),
Math.ceil(canvasSize * Math.sin(Math.PI / 2)),
);
const colorStops = color === 'purple' ? PURPLE : color === 'green' ? GREEN : BLUE;
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

View File

@ -16,15 +16,6 @@
}
}
.withSparkles {
position: relative;
}
.sparkles {
position: absolute;
inset: -0.25rem;
}
.placeholder {
width: 85%;
height: 85%;

View File

@ -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<OwnProps> = ({
observeIntersectionForPlaying,
onClick,
onAnimationEnd,
withSparkles,
sparklesStyle,
sparklesClassName,
}) => {
let containerRef = useRef<HTMLDivElement>();
if (ref) {
@ -120,7 +113,6 @@ const CustomEmoji: FC<OwnProps> = ({
ref={containerRef}
className={buildClassName(
styles.root,
withSparkles && styles.withSparkles,
className,
'custom-emoji',
'emoji',
@ -132,16 +124,6 @@ const CustomEmoji: FC<OwnProps> = ({
data-alt={customEmoji?.emoji}
style={style}
>
{withSparkles && (
<Sparkles
className={buildClassName(
styles.sparkles,
sparklesClassName,
)}
style={sparklesStyle}
preset="button"
/>
)}
{isSelectable && (
<img
className={styles.highlightCatch}

View File

@ -3,17 +3,6 @@
gap: 0.25rem;
align-items: center;
:global(.custom-emoji) {
color: var(--color-primary);
}
:global(.statusSparkles) {
color: var(--accent-color);
:global(.selected) & {
color: white;
}
}
.statusTransition {
width: 1.5em !important;
height: 1.5em !important;
@ -50,3 +39,14 @@
background-color: var(--color-gray);
}
.statusPrimaryColor {
color: var(--color-primary);
:global(.statusSparkles) {
color: var(--accent-color);
:global(.selected) & {
color: white;
}
}
}

View File

@ -19,7 +19,6 @@ import {
} from '../../global/helpers';
import { isApiPeerUser } from '../../global/helpers/peers';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import { copyTextToClipboard } from '../../util/clipboard';
import stopEvent from '../../util/stopEvent';
import renderText from './helpers/renderText';
@ -31,6 +30,7 @@ import useOldLang from '../../hooks/useOldLang';
import Transition from '../ui/Transition';
import CustomEmoji from './CustomEmoji';
import FakeIcon from './FakeIcon';
import GiftEffectWrapper from './gift/GiftEffectWrapper';
import StarIcon from './icons/StarIcon';
import VerifiedIcon from './VerifiedIcon';
@ -39,6 +39,7 @@ import styles from './FullNameTitle.module.scss';
type OwnProps = {
peer: ApiPeer | CustomPeer;
className?: string;
style?: string;
noVerified?: boolean;
noFake?: boolean;
withEmojiStatus?: boolean;
@ -50,13 +51,14 @@ type OwnProps = {
noLoopLimit?: boolean;
canCopyTitle?: boolean;
iconElement?: React.ReactNode;
statusSparklesColor?: string;
withStatusTextColor?: boolean;
onEmojiStatusClick?: NoneToVoidFunction;
observeIntersection?: ObserveFn;
};
const FullNameTitle: FC<OwnProps> = ({
className,
style,
peer,
noVerified,
noFake,
@ -67,9 +69,9 @@ const FullNameTitle: FC<OwnProps> = ({
noLoopLimit,
canCopyTitle,
iconElement,
statusSparklesColor,
isMonoforum,
monoforumBadgeClassName,
withStatusTextColor,
onEmojiStatusClick,
observeIntersection,
}) => {
@ -123,7 +125,7 @@ const FullNameTitle: FC<OwnProps> = ({
const botVerificationIconId = realPeer?.botVerificationIconId;
return (
<div className={buildClassName('title', styles.root, className)}>
<div className={buildClassName('title', styles.root, className)} style={style}>
{botVerificationIconId && (
<CustomEmoji
documentId={botVerificationIconId}
@ -157,18 +159,22 @@ const FullNameTitle: FC<OwnProps> = ({
direction={-1}
shouldCleanup
>
<CustomEmoji
forceAlways
className="no-selection"
<GiftEffectWrapper
withSparkles={emojiStatus.type === 'collectible'}
sparklesClassName="statusSparkles"
sparklesStyle={buildStyle(statusSparklesColor && `color: ${statusSparklesColor}`)}
sparklesColor={emojiStatus.type === 'collectible' && !withStatusTextColor
? emojiStatus.textColor : undefined}
>
<CustomEmoji
forceAlways
className={buildClassName('no-selection', !withStatusTextColor && styles.statusPrimaryColor)}
documentId={emojiStatus.documentId}
size={emojiStatusSize}
loopLimit={!noLoopLimit ? EMOJI_STATUS_LOOP_LIMIT : undefined}
observeIntersectionForLoading={observeIntersection}
onClick={onEmojiStatusClick}
/>
</GiftEffectWrapper>
</Transition>
)}
{canShowEmojiStatus && !emojiStatus && isPremium && <StarIcon />}

View File

@ -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;
}

View File

@ -32,6 +32,11 @@
animation-delay: var(--_duration-shift);
}
.noAnimation .symbol {
opacity: 0.5;
animation: none;
}
@keyframes sparkle {
0% {
transform: translate(0, 0);

View File

@ -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 (
<div className={buildClassName(styles.root, styles.button, className)} style={style}>
<div
className={buildClassName(styles.root, styles.button, className, noAnimation && styles.noAnimation)}
style={style}
>
{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}
</div>
@ -128,6 +134,7 @@ const Sparkles = ({
`scale: ${position.scale}%`,
`--_duration-shift: ${(-position.durationShift / 100) * ANIMATION_DURATION}s`,
)}
aria-hidden="true"
>
{SYMBOL}
</div>

View File

@ -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<T> = {
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
isEffectEmoji?: boolean;
withSparkles?: boolean;
};
const contentForStatusMenuContext = [
@ -97,7 +95,6 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
onContextMenuClose,
onContextMenuClick,
isEffectEmoji,
withSparkles,
}: OwnProps<T>) => {
const { openStickerSet, openPremiumModal, setEmojiStatus } = getActions();
const ref = useRef<HTMLDivElement>();
@ -292,7 +289,6 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
onClick={handleClick}
onContextMenu={handleContextMenu}
>
{withSparkles && <Sparkles preset="button" />}
{isIntesectingForShowing && (
<StickerView
containerRef={ref}

View File

@ -37,6 +37,7 @@ import useWindowSize from '../../hooks/window/useWindowSize';
import Button from '../ui/Button';
import ConfirmDialog from '../ui/ConfirmDialog';
import GiftEffectWrapper from './gift/GiftEffectWrapper';
import Icon from './icons/Icon';
import ReactionEmoji from './reactions/ReactionEmoji';
import StickerButton from './StickerButton';
@ -380,10 +381,10 @@ const StickerSet: FC<OwnProps & StateProps> = ({
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 = (
<StickerButton
key={sticker.id}
sticker={sticker}
@ -412,9 +413,18 @@ const StickerSet: FC<OwnProps & StateProps> = ({
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 (
<GiftEffectWrapper className="gift-effect-wrapper" withSparkles>
{component}
</GiftEffectWrapper>
);
}
return component;
})}
{isCut && totalItemsCount > itemsBeforeCutout && (
<Button

View File

@ -0,0 +1,21 @@
.root {
--glow-color: transparent;
position: relative;
&::before {
pointer-events: none;
content: '';
position: absolute;
z-index: -1;
inset: -25%;
background-image: radial-gradient(circle closest-side, var(--glow-color) 0%, transparent 100%);
}
}
.sparkles {
position: absolute;
inset: -0.25rem;
}

View File

@ -0,0 +1,58 @@
import type { ElementRef } from '@teact';
import { memo, type TeactNode, useRef } from '@teact';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import Sparkles from '../Sparkles';
import styles from './GiftEffectWrapper.module.scss';
type OwnProps = {
children: TeactNode;
ref?: ElementRef<HTMLDivElement>;
className?: string;
style?: string;
withSparkles?: boolean;
sparklesClassName?: string;
sparklesColor?: string;
glowColor?: string;
onClick?: NoneToVoidFunction;
};
const GiftEffectWrapper = ({
children,
ref,
className,
style,
withSparkles,
sparklesClassName,
sparklesColor,
glowColor,
onClick,
}: OwnProps) => {
let containerRef = useRef<HTMLDivElement>();
if (ref) {
containerRef = ref;
}
return (
<div
ref={containerRef}
className={buildClassName(styles.root, className)}
style={buildStyle(glowColor && `--glow-color: ${glowColor}`, style)}
onClick={onClick}
>
{withSparkles && (
<Sparkles
preset="button"
className={buildClassName(styles.sparkles, sparklesClassName)}
style={buildStyle(sparklesColor && `color: ${sparklesColor}`)}
/>
)}
{children}
</div>
);
};
export default memo(GiftEffectWrapper);

View File

@ -6,7 +6,7 @@ import type { ApiEmojiStatusType, ApiPeer, ApiSavedStarGift } from '../../../api
import { STARS_CURRENCY_CODE, TON_CURRENCY_CODE } from '../../../config';
import { getHasAdminRight } from '../../../global/helpers';
import { selectChat, selectPeer, selectUser } from '../../../global/selectors';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment.ts';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../util/objects/customPeer';
@ -14,7 +14,7 @@ import { formatIntegerCompact } from '../../../util/textFormat';
import { getGiftAttributes, getStickerFromGift, getTotalGiftAvailability } from '../helpers/gifts';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useFlag from '../../../hooks/useFlag.ts';
import useFlag from '../../../hooks/useFlag';
import { type ObserveFn } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
@ -34,6 +34,7 @@ type OwnProps = {
peerId: string;
gift: ApiSavedStarGift;
style?: string;
className?: string;
observeIntersection?: ObserveFn;
};
@ -56,6 +57,7 @@ const SavedGift = ({
hasAdminRights,
collectibleEmojiStatuses,
currentUserEmojiStatus,
className,
observeIntersection,
}: OwnProps & StateProps) => {
const { openGiftInfoModal } = getActions();
@ -147,7 +149,7 @@ const SavedGift = ({
return (
<div
ref={ref}
className={buildClassName('interactive-gift scroll-item', styles.root)}
className={buildClassName('interactive-gift scroll-item', styles.root, className)}
style={style}
onClick={handleClick}
onContextMenu={handleContextMenu}
@ -186,7 +188,8 @@ const SavedGift = ({
className={styles.priceBadge}
nonInteractive
size="tiny"
withSparkleEffect={true}
withSparkleEffect
noSparkleAnimation
pill
fluid
>

View File

@ -38,6 +38,7 @@ import {
selectUser,
selectUserFullInfo,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
import stopEvent from '../../../util/stopEvent';
@ -69,6 +70,8 @@ type OwnProps = {
chatOrUserId: string;
isSavedDialog?: boolean;
isInSettings?: boolean;
className?: string;
style?: string;
};
type StateProps = {
@ -117,6 +120,8 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
isBotCanManageEmojiStatus,
botAppPermissions,
botVerification,
className,
style,
}) => {
const {
showNotification,
@ -338,7 +343,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
}
return (
<div className="ChatExtra">
<div className={buildClassName('ChatExtra', className)} style={style}>
{personalChannel && (
<div className={styles.personalChannel}>
<h3 className={styles.personalChannelTitle}>{oldLang('ProfileChannel')}</h3>

View File

@ -0,0 +1,549 @@
@use "../../../styles/mixins";
.root {
--rating-outline-color: #000000;
--rating-text-color: #000000;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
aspect-ratio: 1 / 1;
color: var(--color-white);
@include mixins.with-vt-type('profileAvatar');
:global(.VerifiedIcon),
:global(.StarIcon) {
--color-fill: var(--color-white);
--color-checkmark: var(--color-primary);
z-index: 2;
opacity: 0.8;
}
}
.plain.minimized {
color: var(--color-text);
.userRatingNegativeWrapper,
.userRatingWrapper {
color: var(--color-white);
}
:global(.VerifiedIcon),
:global(.StarIcon) {
--color-fill: var(--color-primary);
--color-checkmark: var(--color-white);
opacity: 1;
}
}
.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;
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%;
@include mixins.with-vt-type('profileAvatar');
&::before {
content: '';
position: absolute;
z-index: 1;
top: 0;
right: 0;
left: 0;
height: 2rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0) 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;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%);
@include mixins.with-vt-type('profileAvatar');
&: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;
@include mixins.with-vt-type('profileAvatar');
}
.status,
.info,
.title {
align-self: flex-start;
}
.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;
}
.userRatingWrapper {
width: 1rem;
font-size: 1.5rem;
}
.ratingIcon, .ratingNegativeIcon {
pointer-events: none;
paint-order: stroke fill;
-webkit-text-stroke: 2px var(--rating-outline-color);
}
.ratingNegativeIcon {
font-size: 1rem;
}
.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: var(--rating-text-color);
}
.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;
}
.radialPatternBackground {
pointer-events: none;
// Hacky way to keep background stable during resizes
position: absolute;
top: 8rem;
aspect-ratio: 1 / 1;
width: 140%;
margin-top: -70%;
}
.standaloneAvatar {
grid-area: 1 / -1;
@include mixins.with-vt-type('profileAvatar');
}
.activeProfilePhoto {
@include mixins.with-vt-type('profileAvatar');
}
.minimized {
aspect-ratio: unset;
padding-block: 4.25rem 1rem;
.status, .title, .info {
align-self: center;
}
.info {
position: relative;
justify-content: unset;
min-height: unset;
margin-top: 1rem;
background: none;
}
}
.title {
@include mixins.with-vt-type('profileAvatar');
}
.pinnedGifts {
position: absolute;
inset: 0;
}
@include mixins.on-active-vt('profileAvatar') {
&::view-transition-group(.info),
&::view-transition-group(.title),
&::view-transition-group(.status) {
z-index: 1;
}
}
@include mixins.on-active-vt('profileAvatar') {
&::view-transition-old(.profileInfo),
&::view-transition-new(.profileInfo) {
animation-name: none;
}
}
// Delay before showing buttons & overlay
@include mixins.on-active-vt('profileExpand') {
&::view-transition-new(.photoWrapper),
&::view-transition-new(.info) {
animation-delay: 0.25s;
}
}
// Hide buttons & overlay immediately
@include mixins.on-active-vt('profileCollapse') {
&::view-transition-old(.photoWrapper),
&::view-transition-old(.info) {
animation-duration: 0.1s;
}
}
@include mixins.on-active-vt('profileExpand') {
&::view-transition-old(.avatar) {
:local {
animation-name: fadeOut;
animation-duration: 0.1s;
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
}
&::view-transition-new(.avatar) {
:local {
animation-name: profileExpandAvatar;
animation-duration: inherit;
animation-timing-function: inherit;
animation-fill-mode: forwards;
}
@keyframes profileExpandAvatar {
from {
opacity: 1;
clip-path: inset(0 0 0 0 round 50%);
}
25% {
clip-path: inset(0 0 0 0 round 50%);
}
to {
clip-path: inset(0 0 0 0 round 0%);
}
}
}
}
@include mixins.on-active-vt('profileCollapse') {
&::view-transition-new(.avatar) {
:local {
animation-name: fadeIn;
animation-duration: 0.125s;
animation-delay: 0.125s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
&::view-transition-old(.avatar) {
:local {
animation-name: profileCollapseAvatar;
animation-duration: inherit;
animation-timing-function: inherit;
animation-fill-mode: forwards;
}
@keyframes profileCollapseAvatar {
from {
clip-path: inset(0 0 0 0 round 0%);
}
25% {
clip-path: inset(0 0 0 0 round 50%);
}
50% {
opacity: 1;
}
to {
opacity: 0;
clip-path: inset(0 0 0 0 round 50%);
}
}
}
}

View File

@ -1,22 +1,5 @@
// This class is used in `ghostAnimation`, so we need to keep it global
.ProfileInfo {
position: relative;
aspect-ratio: 1 / 1;
@supports not (aspect-ratio: 1 / 1) {
&::before {
content: "";
float: left;
padding-top: 100%;
}
&::after {
content: "";
clear: both;
display: block;
}
}
.fullName {
margin-bottom: 0;
@ -27,15 +10,6 @@
white-space: pre-wrap;
}
.VerifiedIcon,
.StarIcon {
--color-fill: var(--color-white);
--color-checkmark: var(--color-primary);
z-index: 2;
opacity: 0.8;
}
.emoji:not(.custom-emoji) {
width: 1.5rem;
height: 1.5rem;
@ -47,6 +21,5 @@
pointer-events: auto;
cursor: var(--custom-cursor, pointer);
color: var(--color-white) !important;
}
}

View File

@ -1,50 +1,69 @@
import type { FC } from '../../lib/teact/teact';
import { memo, useEffect, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import { memo, useEffect, useMemo, useState } from '../../../lib/teact/teact';
import type {
ApiChat, ApiPeerPhotos, ApiSticker, ApiTopic, ApiUser, ApiUserFullInfo, ApiUserStatus,
} from '../../api/types';
import type { AnimationLevel } from '../../types';
import type { IconName } from '../../types/icons';
import { MediaViewerOrigin } from '../../types';
ApiChat,
ApiEmojiStatusType,
ApiPeerColorOption,
ApiPeerPhotos,
ApiPeerProfileColorSet,
ApiSavedGifts,
ApiSticker,
ApiTopic,
ApiUser,
ApiUserFullInfo,
ApiUserStatus,
} from '../../../api/types/index';
import type { IconName } from '../../../types/icons/index';
import type { AnimationLevel, ThemeKey } from '../../../types/index';
import { MediaViewerOrigin } from '../../../types/index';
import {
getUserStatus, isAnonymousForwardsChat, isChatChannel, isSystemBot, isUserOnline,
} from '../../global/helpers';
} from '../../../global/helpers/index';
import { getActions, withGlobal } from '../../../global/index';
import {
selectChat,
selectCurrentMessageList,
selectCustomEmoji,
selectPeer,
selectPeerHasProfileBackground,
selectPeerPhotos,
selectPeerProfileColor,
selectPeerSavedGifts,
selectTabState,
selectTheme,
selectThreadMessagesCount,
selectTopic,
selectUser,
selectUserFullInfo,
selectUserStatus,
} from '../../global/selectors';
import { selectSharedSettings } from '../../global/selectors/sharedState.ts';
import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { resolveTransitionName } from '../../util/resolveTransitionName.ts';
import renderText from './helpers/renderText';
} from '../../../global/selectors/index';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import renderText from '../helpers/renderText.tsx';
import useIntervalForceUpdate from '../../hooks/schedulers/useIntervalForceUpdate';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import usePhotosPreload from './hooks/usePhotosPreload';
import { useVtn } from '../../../hooks/animations/useVtn';
import useIntervalForceUpdate from '../../../hooks/schedulers/useIntervalForceUpdate';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
import useCustomEmoji from '../hooks/useCustomEmoji';
import usePhotosPreload from '../hooks/usePhotosPreload';
import Transition from '../ui/Transition';
import Avatar from './Avatar';
import FullNameTitle from './FullNameTitle';
import Icon from './icons/Icon';
import Transition from '../../ui/Transition.tsx';
import Avatar from '../Avatar.tsx';
import FullNameTitle from '../FullNameTitle.tsx';
import Icon from '../icons/Icon.tsx';
import TopicIcon from '../TopicIcon.tsx';
import ProfilePhoto from './ProfilePhoto';
import TopicIcon from './TopicIcon';
import ProfilePinnedGifts from './ProfilePinnedGifts.tsx';
import RadialPatternBackground from './RadialPatternBackground.tsx';
import './ProfileInfo.scss';
import styles from './ProfileInfo.module.scss';
@ -52,14 +71,14 @@ import styles from './ProfileInfo.module.scss';
const MAX_LEVEL_ICON = 90;
type OwnProps = {
isExpanded?: boolean;
peerId: string;
forceShowSelf?: boolean;
isForSettings?: boolean;
canPlayVideo: boolean;
isForMonoforum?: boolean;
};
type StateProps =
{
type StateProps = {
user?: ApiUser;
userFullInfo?: ApiUserFullInfo;
userStatus?: ApiUserStatus;
@ -69,9 +88,14 @@ type StateProps =
topic?: ApiTopic;
messagesCount?: number;
animationLevel: AnimationLevel;
emojiStatus?: ApiEmojiStatusType;
emojiStatusSticker?: ApiSticker;
emojiStatusSlug?: string;
profilePhotos?: ApiPeerPhotos;
profileColorOption?: ApiPeerColorOption<ApiPeerProfileColorSet>;
theme: ThemeKey;
isPlain?: boolean;
savedGifts?: ApiSavedGifts;
};
const EMOJI_STATUS_SIZE = 24;
@ -79,9 +103,11 @@ const EMOJI_TOPIC_SIZE = 120;
const LOAD_MORE_THRESHOLD = 3;
const MAX_PHOTO_DASH_COUNT = 30;
const STATUS_UPDATE_INTERVAL = 1000 * 60; // 1 min
const PATTERN_COLOR = '#000000';
const ProfileInfo: FC<OwnProps & StateProps> = ({
forceShowSelf,
const ProfileInfo = ({
isExpanded,
isForSettings,
canPlayVideo,
user,
userFullInfo,
@ -92,12 +118,17 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
topic,
messagesCount,
animationLevel,
emojiStatus,
emojiStatusSticker,
emojiStatusSlug,
profilePhotos,
peerId,
isForMonoforum,
}) => {
profileColorOption,
theme,
isPlain,
savedGifts,
}: OwnProps & StateProps) => {
const {
openMediaViewer,
openPremiumModal,
@ -106,6 +137,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
loadMoreProfilePhotos,
openUniqueGiftBySlug,
openProfileRatingModal,
loadPeerSavedGifts,
} = getActions();
const oldLang = useOldLang();
@ -113,6 +145,8 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
useIntervalForceUpdate(user ? STATUS_UPDATE_INTERVAL : undefined);
const { createVtnStyle } = useVtn();
const photos = profilePhotos?.photos || MEMO_EMPTY_ARRAY;
const prevMediaIndex = usePreviousDeprecated(mediaIndex);
const prevAvatarOwnerId = usePreviousDeprecated(avatarOwnerId);
@ -122,12 +156,46 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const isFirst = photos.length <= 1 || currentPhotoIndex === 0;
const isLast = photos.length <= 1 || currentPhotoIndex === photos.length - 1;
const collectibleEmojiStatus = emojiStatus?.type === 'collectible' ? emojiStatus : undefined;
const peer = user || chat;
const { customEmoji: backgroundEmoji } = useCustomEmoji(
collectibleEmojiStatus?.patternDocumentId || peer?.profileColor?.backgroundEmojiId,
);
const profileColorSet = useMemo(() => {
if (collectibleEmojiStatus) {
return {
bgColors: [collectibleEmojiStatus.centerColor, collectibleEmojiStatus.edgeColor],
storyColors: [collectibleEmojiStatus.centerColor, collectibleEmojiStatus.edgeColor],
};
}
const colors = profileColorOption
&& (theme === 'dark' ? profileColorOption.darkColors : profileColorOption.colors);
if (!colors) return undefined;
// Why are they reversed on the server?
return {
bgColors: [...colors.bgColors].reverse(),
storyColors: [...colors.storyColors].reverse(),
};
}, [profileColorOption, theme, collectibleEmojiStatus]);
const pinnedGifts = useMemo(() => {
return savedGifts?.gifts.filter((gift) => gift.isPinned);
}, [savedGifts]);
useEffect(() => {
if (photos.length - currentPhotoIndex <= LOAD_MORE_THRESHOLD) {
loadMoreProfilePhotos({ peerId });
}
}, [currentPhotoIndex, peerId, photos.length]);
useEffect(() => {
loadPeerSavedGifts({ peerId });
}, [peerId]);
// Set the current avatar photo to the last selected photo in Media Viewer after it is closed
useEffect(() => {
if (prevAvatarOwnerId && prevMediaIndex !== undefined && mediaIndex === undefined) {
@ -144,6 +212,13 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
}, [currentPhotoIndex, photos.length]);
// Reset photo index when collapsing
useEffect(() => {
if (!isExpanded) {
setCurrentPhotoIndex(0);
}
}, [isExpanded]);
usePhotosPreload(photos, currentPhotoIndex);
const handleProfilePhotoClick = useLastCallback(() => {
@ -151,7 +226,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
isAvatarView: true,
chatId: peerId,
mediaIndex: currentPhotoIndex,
origin: forceShowSelf ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
origin: isForSettings ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
});
});
@ -239,7 +314,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
letterClassName={styles.topicIconTitle}
noLoopLimit
/>
<h3 className={styles.topicTitle} dir={oldLang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3>
<h3 className={styles.topicTitle} dir={lang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3>
<p className={styles.topicMessagesCounter}>
{messagesCount ? oldLang('Chat.Title.Topic', messagesCount, 'i') : oldLang('lng_forum_no_messages')}
</p>
@ -277,6 +352,8 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
chat={chat}
photo={photo}
canPlayVideo={Boolean(isActive && canPlayVideo)}
className={buildClassName(isActive && styles.activeProfilePhoto)}
style={isActive ? createVtnStyle('avatar', true) : undefined}
onClick={handleProfilePhotoClick}
/>
);
@ -292,7 +369,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
if (isNegative) {
return (
<span className={styles.userRatingNegativeWrapper} onClick={onRatingClick}>
<span role="button" tabIndex={0} className={styles.userRatingNegativeWrapper} onClick={onRatingClick}>
<Icon
name="rating-icons-negative"
className={styles.ratingNegativeIcon}
@ -309,7 +386,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
: `rating-icons-level${Math.floor(iconLevel / 10) * 10}`) as IconName;
return (
<span className={styles.userRatingWrapper} onClick={onRatingClick}>
<span role="button" tabIndex={0} className={styles.userRatingWrapper} onClick={onRatingClick}>
<Icon
name={iconName}
className={styles.ratingIcon}
@ -326,7 +403,11 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
if (isForMonoforum) {
return (
<span className={buildClassName(styles.status, 'status')} dir="auto">
<span
className={buildClassName(styles.status, 'status')}
dir="auto"
style={createVtnStyle('status', true)}
>
{lang('MonoforumStatus')}
</span>
);
@ -340,6 +421,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
'status',
isUserOnline(user, userStatus) && 'online',
)}
style={createVtnStyle('status', true)}
>
{renderUserRating()}
<span className={styles.userStatus} dir="auto">
@ -355,7 +437,11 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
}
return (
<span className={buildClassName(styles.status, 'status')} dir="auto">
<span
className={buildClassName(styles.status, 'status')}
dir="auto"
style={createVtnStyle('status', true)}
>
{
isChatChannel(chat!)
? oldLang('Subscribers', chat!.membersCount ?? 0, 'i')
@ -371,12 +457,41 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
return (
<div
className={buildClassName('ProfileInfo')}
dir={oldLang.isRtl ? 'rtl' : undefined}
className={buildClassName(
'ProfileInfo',
styles.root,
!isExpanded && styles.minimized,
isPlain && styles.plain,
)}
style={buildStyle(
profileColorSet && `--rating-outline-color: ${isExpanded ? 'transparent' : profileColorSet?.bgColors[1]}`,
profileColorSet && !isExpanded && `--rating-text-color: ${profileColorSet?.bgColors[1]}`,
createVtnStyle('profileInfo', true),
)}
dir={lang.isRtl ? 'rtl' : undefined}
>
<div className={styles.photoWrapper}>
{profileColorSet?.bgColors && (
<RadialPatternBackground
backgroundColors={profileColorSet.bgColors}
patternIcon={backgroundEmoji}
patternColor={collectibleEmojiStatus?.patternColor || PATTERN_COLOR}
className={styles.radialPatternBackground}
patternSize={0.75}
/>
)}
{pinnedGifts && (
<ProfilePinnedGifts
peerId={peerId}
gifts={pinnedGifts}
isExpanded={isExpanded}
className={styles.pinnedGifts}
withGlow={!isPlain}
/>
)}
{isExpanded && (
<div className={styles.photoWrapper} style={createVtnStyle('photoWrapper', true)}>
{renderPhotoTabs()}
{!forceShowSelf && profilePhotos?.personalPhoto && (
{!isForSettings && profilePhotos?.personalPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
isFirst && styles.fallbackPhotoVisible,
@ -387,7 +502,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
</div>
</div>
)}
{forceShowSelf && profilePhotos?.fallbackPhoto && (
{isForSettings && profilePhotos?.fallbackPhoto && (
<div className={buildClassName(
styles.fallbackPhoto,
(isFirst || isLast) && styles.fallbackPhotoVisible,
@ -407,7 +522,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
)}
<Transition
activeKey={currentPhotoIndex}
name={resolveTransitionName('slide', animationLevel, !hasSlideAnimation, oldLang.isRtl)}
name={resolveTransitionName('slide', animationLevel, !hasSlideAnimation, lang.isRtl)}
>
{renderPhoto}
</Transition>
@ -415,7 +530,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
{!isFirst && (
<button
type="button"
dir={oldLang.isRtl ? 'rtl' : undefined}
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_prev)}
aria-label={oldLang('AccDescrPrevious')}
onClick={selectPreviousMedia}
@ -424,19 +539,39 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
{!isLast && (
<button
type="button"
dir={oldLang.isRtl ? 'rtl' : undefined}
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.navigation, styles.navigation_next)}
aria-label={oldLang('Next')}
onClick={selectNextMedia}
/>
)}
</div>
)}
{!isExpanded && (
<Avatar
withStory
storyColors={profileColorSet?.storyColors}
className={styles.standaloneAvatar}
key={peer?.id}
size="jumbo"
peer={peer}
style={createVtnStyle('avatar', true)}
onClick={isForSettings ? handleProfilePhotoClick : undefined}
/>
)}
<div className={styles.info} dir={oldLang.isRtl ? 'rtl' : 'auto'}>
<div
className={styles.info}
dir={lang.isRtl ? 'rtl' : 'auto'}
style={createVtnStyle('info', true)}
>
{(user || chat) && (
<FullNameTitle
className={styles.title}
style={createVtnStyle('title', true)}
peer={(user || chat)!}
withEmojiStatus
withStatusTextColor
emojiStatusSize={EMOJI_STATUS_SIZE}
onEmojiStatusClick={handleStatusClick}
noLoopLimit
@ -451,6 +586,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { peerId }): Complete<StateProps> => {
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<OwnProps>(
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<OwnProps>(
animationLevel,
emojiStatusSticker,
emojiStatusSlug,
emojiStatus,
profilePhotos,
topic,
messagesCount: topic ? selectThreadMessagesCount(global, peerId, currentTopicId!) : undefined,
profileColorOption: profileColor,
theme,
isPlain: !hasBackground,
savedGifts,
};
},
)(ProfileInfo));

View File

@ -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<OwnProps> = ({
isSavedMessages,
isSavedDialog,
canPlayVideo,
className,
style,
onClick,
}) => {
const videoRef = useRef<HTMLVideoElement>();
const lang = useOldLang();
const lang = useLang();
const { isMobile } = useAppLayout();
const isDeleted = user && isDeletedUser(user);
@ -142,6 +146,7 @@ const ProfilePhoto: FC<OwnProps> = ({
) : (
<img
src={fullMediaData}
draggable={false}
className={buildClassName('avatar-media', transitionClassNames)}
alt=""
/>
@ -171,10 +176,11 @@ const ProfilePhoto: FC<OwnProps> = ({
isDeleted && 'deleted-account',
isRepliesChat && 'replies-bot-account',
(!isSavedMessages && !hasMedia) && 'no-photo',
className,
);
return (
<div className={fullClassName} onClick={hasMedia ? onClick : undefined}>
<div className={fullClassName} onClick={hasMedia ? onClick : undefined} style={style}>
{typeof content === 'string' ? renderText(content, ['hq_emoji']) : content}
</div>
);

View File

@ -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;
}
}
}
}

View File

@ -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 (
<div className={buildClassName(styles.root, className)}>
{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 (
<PinnedGift
peerId={peerId}
className={styles.gift}
key={gift.gift.id}
gift={gift}
style={buildStyle(style, createVtnStyle(`profilePinnedGift${index}`, 'profilePinnedGift'))}
withGlow={withGlow}
/>
);
})}
</div>
);
};
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<HTMLDivElement>();
const giftAttributes = useMemo(() => {
return getGiftAttributes(gift.gift);
}, [gift]);
const handleClick = useLastCallback(() => {
openGiftInfoModal({ peerId, gift });
});
if (!giftAttributes?.model || !giftAttributes.backdrop) return undefined;
return (
<GiftEffectWrapper
withSparkles
sparklesColor={giftAttributes.backdrop.textColor}
glowColor={withGlow ? giftAttributes.backdrop.edgeColor : undefined}
ref={stickerRef}
className={className}
style={style}
onClick={handleClick}
>
<StickerView
containerRef={stickerRef}
sticker={giftAttributes.model.sticker}
size={GIFT_STICKER_SIZE}
withTranslucentThumb
noPlay
/>
</GiftEffectWrapper>
);
};
export default memo(ProfilePinnedGifts);

View File

@ -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));
}
}

View File

@ -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<HTMLDivElement>();
const canvasRef = useRef<HTMLCanvasElement>();
@ -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);
});

View File

@ -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';

View File

@ -430,6 +430,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedMessages={chatId === user?.id && user?.isSelf}
isSavedDialog={isSavedDialog}
observeIntersection={observeIntersection}
withStatusTextColor={isSelected}
/>
{isMuted && !isSavedDialog && <Icon name="muted" />}
<div className="separator" />

View File

@ -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';

View File

@ -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<StateProps> = ({ 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<StateProps> = ({ emojiStatus, collectibleStatuses, isAcco
onClick={handleEmojiStatusClick}
>
{emojiStatus ? (
<GiftEffectWrapper
withSparkles={Boolean(collectibleEmojiStatus)}
sparklesClassName="statusSparkles"
sparklesColor={collectibleEmojiStatus?.textColor}
>
<CustomEmoji
key={emojiStatus.documentId}
documentId={emojiStatus.documentId}
size={EMOJI_STATUS_SIZE}
loopLimit={EMOJI_STATUS_LOOP_LIMIT}
withSparkles={emojiStatus?.type === 'collectible'}
/>
</GiftEffectWrapper>
) : <StarIcon />}
</Button>
<StatusPickerMenu

View File

@ -4,9 +4,9 @@ import { getActions } from '../../../global';
import { type AnimationLevel, LeftColumnContent } from '../../../types';
import { resolveTransitionName } from '../../../util/resolveTransitionName.ts';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import useLastCallback from '../../../hooks/useLastCallback.ts';
import useLastCallback from '../../../hooks/useLastCallback';
import Transition from '../../ui/Transition';
import NewChatStep1 from './NewChatStep1';

View File

@ -12,9 +12,9 @@ import type { RegularLangKey } from '../../../types/language';
import { type AnimationLevel, GlobalSearchContent } from '../../../types';
import { selectTabState } from '../../../global/selectors';
import { selectSharedSettings } from '../../../global/selectors/sharedState.ts';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { parseDateString } from '../../../util/dates/dateFormat';
import { resolveTransitionName } from '../../../util/resolveTransitionName.ts';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation';

View File

@ -7,10 +7,10 @@ import type { AnimationLevel } from '../../../types';
import { LoadMoreDirection } from '../../../types';
import { selectTabState } from '../../../global/selectors';
import { selectSharedSettings } from '../../../global/selectors/sharedState.ts';
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import { parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { resolveTransitionName } from '../../../util/resolveTransitionName.ts';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import { throttle } from '../../../util/schedulers';
import { renderMessageSummary } from '../../common/helpers/renderMessageText';

View File

@ -7,11 +7,11 @@ import type { AnimationLevel } from '../../../types';
import { SettingsScreens } from '../../../types';
import { selectTabState } from '../../../global/selectors';
import { resolveTransitionName } from '../../../util/resolveTransitionName.ts';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import useTwoFaReducer from '../../../hooks/reducers/useTwoFaReducer';
import useLastCallback from '../../../hooks/useLastCallback';
import useScrollNotch from '../../../hooks/useScrollNotch.ts';
import useScrollNotch from '../../../hooks/useScrollNotch';
import Transition from '../../ui/Transition';
import SettingsFolders from './folders/SettingsFolders';

View File

@ -21,7 +21,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/icons/Icon';
import StarIcon from '../../common/icons/StarIcon';
import ChatExtra from '../../common/profile/ChatExtra';
import ProfileInfo from '../../common/ProfileInfo';
import ProfileInfo from '../../common/profile/ProfileInfo';
import ConfirmDialog from '../../ui/ConfirmDialog';
import ListItem from '../../ui/ListItem';
@ -86,7 +86,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<ProfileInfo
peerId={currentUserId}
canPlayVideo={Boolean(isActive)}
forceShowSelf
isForSettings
/>
)}
{currentUserId && (

View File

@ -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';

View File

@ -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<string, IconName> = {
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,

View File

@ -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';

View File

@ -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}`,

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
// 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<OwnProps & StateProps> = ({
});
});
setRecentEmojis([]);
setRecentCustomEmojis([]);
}, [isOpen, addRecentCustomEmoji]);
const handleCustomEmojiSelect = useLastCallback((emoji: ApiSticker) => {

View File

@ -102,7 +102,6 @@ const GiftStatusInfoModal = ({
withEmojiStatus
noFake
noVerified
statusSparklesColor={subtitleColor}
/>
<p className={styles.status} style={buildStyle(subtitleColor && `color: ${subtitleColor}`)}>
{lang('Online')}

View File

@ -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');
}
}

View File

@ -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<OwnProps & StateProps> = ({
isSavedDialog,
forceScrollProfileTab,
isSynced,
hasAvatar,
onProfileStateChange,
}) => {
const {
@ -274,10 +285,11 @@ const Profile: FC<OwnProps & StateProps> = ({
const lang = useLang();
const [deletingUserId, setDeletingUserId] = useState<string | undefined>();
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<OwnProps & StateProps> = ({
}, [chatId]);
useSyncEffect(() => {
enableViewTransition();
enableGiftTransition();
}, [giftsFilter]);
useSyncEffect(() => {
disableViewTransition();
disableGiftTransition();
}, [gifts]);
useEffect(() => {
@ -399,7 +411,8 @@ const Profile: FC<OwnProps & StateProps> = ({
}, [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<OwnProps & StateProps> = ({
return;
}
if (!gifts || !prevGifts || !isViewTransitionEnabled) {
if (!gifts || !prevGifts || !isGiftTransitionEnabled) {
setRenderingGifts(gifts);
return;
}
@ -436,14 +449,14 @@ const Profile: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
<SavedGift
peerId={chatId}
key={getSavedGiftKey(gift)}
style={shouldApplyVtn ? `view-transition-name: vt${getSavedGiftKey(gift)}` : undefined}
className="saved-gift"
style={createVtnStyle(getSavedGiftKey(gift))}
gift={gift}
observeIntersection={observeIntersectionForMedia}
/>
@ -1003,6 +1040,24 @@ const Profile: FC<OwnProps & StateProps> = ({
setRestoreContentHeightKey(restoreContentHeightKey + 1);
});
function renderProfileInfo(peerId: string, isReady: boolean) {
return (
<div className="profile-info">
<ProfileInfo
isExpanded={isProfileExpanded}
peerId={peerId}
canPlayVideo={isReady}
isForMonoforum={Boolean(monoforumChannel)}
/>
<ChatExtra
chatOrUserId={profileId}
isSavedDialog={isSavedDialog}
style={createVtnStyle('chatExtra')}
/>
</div>
);
}
function renderSpinnerOrContent(noContent: boolean, noSpinner: boolean) {
const baseContent = renderSpinnerOrContentBase(noContent, noSpinner);
@ -1059,6 +1114,7 @@ const Profile: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
renderProfileInfo(
monoforumChannel?.id || profileId,
isRightColumnShown && canRenderContent,
isSavedDialog,
Boolean(monoforumChannel),
)
)}
{!isRestricted && (
<div
className="shared-media"
style={createVtnStyle('sharedMedia')}
>
<Transition
ref={transitionRef}
@ -1120,15 +1175,6 @@ const Profile: FC<OwnProps & StateProps> = ({
);
};
function renderProfileInfo(profileId: string, isReady: boolean, isSavedDialog?: boolean, isForMonoforum?: boolean) {
return (
<div className="profile-info">
<ProfileInfo peerId={profileId} canPlayVideo={isReady} isForMonoforum={isForMonoforum} />
<ChatExtra chatOrUserId={profileId} isSavedDialog={isSavedDialog} />
</div>
);
}
export default memo(withGlobal<OwnProps>(
(global, {
chatId, threadId, isMobile,
@ -1201,6 +1247,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
adminMembersById: hasMembersTab ? adminMembersById : undefined,
commonChatIds: commonChats?.ids,
monoforumChannel,
hasAvatar,
};
},
)(Profile));

View File

@ -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;
}
}

View File

@ -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<OwnProps & StateProps> = ({
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
const { isMobile } = useAppLayout();
const { createVtnStyle } = useVtn();
const {
sortType: giftsSortType,
@ -697,7 +699,11 @@ const RightHeader: FC<OwnProps & StateProps> = ({
);
return (
<div className="RightHeader" data-tauri-drag-region={IS_TAURI && IS_MAC_OS ? true : undefined}>
<div
className="RightHeader"
data-tauri-drag-region={IS_TAURI && IS_MAC_OS ? true : undefined}
style={createVtnStyle('rightHeader', true)}
>
<Button
className="close-button"
round

View File

@ -16,15 +16,23 @@ const runThrottledForScroll = throttle((cb) => cb(), 250, false);
let isScrollingProgrammatically = false;
export default function useProfileState(
containerRef: ElementRef<HTMLDivElement>,
tabType: ProfileTabType,
profileState: ProfileState,
onProfileStateChange: (state: ProfileState) => void,
export default function useProfileState({
containerRef,
tabType,
profileState,
onProfileStateChange,
forceScrollProfileTab = false,
allowAutoScrollToTabs = false,
handleStopAutoScrollToTabs: () => void,
) {
handleStopAutoScrollToTabs,
}: {
containerRef: ElementRef<HTMLDivElement>;
tabType: ProfileTabType;
profileState: ProfileState;
forceScrollProfileTab?: boolean;
allowAutoScrollToTabs?: boolean;
onProfileStateChange: (state: ProfileState) => void;
handleStopAutoScrollToTabs: NoneToVoidFunction;
}) {
// Scroll to tabs if needed
useEffectWithPrevDeps(([prevTabType]) => {
if ((prevTabType && prevTabType !== tabType && allowAutoScrollToTabs) || (tabType && forceScrollProfileTab)) {
@ -96,7 +104,9 @@ export default function useProfileState(
state = getStateFromTabType(tabType);
}
if (state !== profileState) {
onProfileStateChange(state);
}
});
// Determine profile state when switching tabs

View File

@ -3,12 +3,12 @@ import type React from '../../../lib/teact/teact.ts';
import { useState } from '../../../lib/teact/teact.ts';
import { memo } from '../../../lib/teact/teact.ts';
import type { ApiChat } from '../../../api/types/index.ts';
import type { ManagementScreens } from '../../../types/index.ts';
import { ChatCreationProgress } from '../../../types/index.ts';
import type { ApiChat } from '../../../api/types/index';
import type { ManagementScreens } from '../../../types/index';
import { ChatCreationProgress } from '../../../types/index';
import { getActions, withGlobal } from '../../../global/index.ts';
import { selectChat, selectTabState } from '../../../global/selectors/index.ts';
import { getActions, withGlobal } from '../../../global/index';
import { selectChat, selectTabState } from '../../../global/selectors/index';
import useHistoryBack from '../../../hooks/useHistoryBack.ts';
import useLang from '../../../hooks/useLang.ts';

View File

@ -54,6 +54,7 @@ export type OwnProps = {
isRectangular?: boolean;
withPremiumGradient?: boolean;
withSparkleEffect?: boolean;
noSparkleAnimation?: boolean;
noPreventDefault?: boolean;
noForcedUpperCase?: boolean;
shouldStopPropagation?: boolean;
@ -78,13 +79,6 @@ const Button: FC<OwnProps> = ({
ref,
type = 'button',
id,
onClick,
onContextMenu,
onMouseDown,
onMouseUp,
onMouseEnter,
onMouseLeave,
onFocus,
children,
size = 'default',
color = 'primary',
@ -99,7 +93,7 @@ const Button: FC<OwnProps> = ({
isShiny,
withPremiumGradient,
withSparkleEffect,
onTransitionEnd,
noSparkleAnimation,
ariaLabel,
ariaControls,
hasPopup,
@ -121,6 +115,14 @@ const Button: FC<OwnProps> = ({
iconName,
iconAlignment = 'start',
iconClassName,
onClick,
onContextMenu,
onMouseDown,
onMouseUp,
onMouseEnter,
onMouseLeave,
onFocus,
onTransitionEnd,
}) => {
let elementRef = useRef<HTMLButtonElement | HTMLAnchorElement>();
if (ref) {
@ -214,7 +216,7 @@ const Button: FC<OwnProps> = ({
const content = (
<>
{withSparkleEffect && <Sparkles preset="button" />}
{withSparkleEffect && <Sparkles preset="button" noAnimation={noSparkleAnimation} />}
{renderContent()}
{!isNotInteractive && ripple && (
<RippleEffect />

View File

@ -160,7 +160,8 @@ export const EDITABLE_STORY_INPUT_CSS_SELECTOR = `#${EDITABLE_STORY_INPUT_ID}`;
export const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix';
export const MESSAGE_CONTENT_CLASS_NAME = 'message-content';
export const MESSAGE_CONTENT_SELECTOR = '.message-content';
export const VIEW_TRANSITION_CLASS_NAME = 'active-view-transition';
export const VT_CLASS_NAME = 'active-view-transition';
export const VT_TYPE_CLASS_PREFIX = 'active-vt-';
export const RESIZE_HANDLE_CLASS_NAME = 'resizeHandle';
export const RESIZE_HANDLE_SELECTOR = `.${RESIZE_HANDLE_CLASS_NAME}`;

View File

@ -678,17 +678,27 @@ addActionHandler('loadConfig', async (global): Promise<void> => {
});
addActionHandler('loadPeerColors', async (global): Promise<void> => {
const hash = global.peerColors?.generalHash;
const result = await callApi('fetchPeerColors', hash);
if (!result) return;
const generalHash = global.peerColors?.generalHash;
const profileHash = global.peerColors?.profileHash;
const [generalResult, profileResult] = await Promise.all([
callApi('fetchPeerColors', generalHash),
callApi('fetchPeerProfileColors', profileHash),
]);
if (!generalResult && !profileResult) return;
global = getGlobal();
const currentPeerColors = global.peerColors! || {};
global = {
...global,
peerColors: {
...global.peerColors,
general: result.colors,
generalHash: result.hash,
...currentPeerColors,
general: generalResult?.colors || currentPeerColors.general,
generalHash: generalResult?.hash || currentPeerColors.generalHash,
profile: profileResult?.colors || currentPeerColors.profile,
profileHash: profileResult?.hash || currentPeerColors.profileHash,
},
};
setGlobal(global);

View File

@ -28,6 +28,7 @@ import {
selectActiveGiftsCollectionId,
selectGiftProfileFilter,
selectPeer,
selectPeerCollectionSavedGifts,
selectPeerSavedGifts,
selectTabState,
} from '../../selectors';
@ -297,13 +298,13 @@ addActionHandler('loadPeerSavedGifts', async (global, actions, payload): Promise
global = getGlobal();
const currentGifts = selectPeerSavedGifts(global, peerId, tabId);
const fetchingCollectionId = selectActiveGiftsCollectionId(global, peerId, tabId);
const currentGifts = selectPeerCollectionSavedGifts(global, peerId, fetchingCollectionId, tabId);
const localNextOffset = currentGifts?.nextOffset;
if (!shouldRefresh && currentGifts && !localNextOffset) return; // Already loaded all
const fetchingFilter = selectGiftProfileFilter(global, peerId, tabId);
const fetchingCollectionId = selectActiveGiftsCollectionId(global, peerId, tabId);
const result = await callApi('fetchSavedStarGifts', {
peer,
@ -332,7 +333,8 @@ addActionHandler('reloadPeerSavedGifts', (global, actions, payload): ActionRetur
} = payload;
Object.values(global.byTabId).forEach((tabState) => {
if (selectPeerSavedGifts(global, peerId, tabState.id)) {
const activeCollectionId = selectActiveGiftsCollectionId(global, peerId, tabState.id);
if (selectPeerCollectionSavedGifts(global, peerId, activeCollectionId, tabState.id)) {
actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id });
}
});
@ -428,12 +430,7 @@ addActionHandler('changeGiftVisibility', async (global, actions, payload): Promi
return;
}
// Reload gift list to avoid issues with pagination
Object.values(global.byTabId).forEach((tabState) => {
if (selectPeerSavedGifts(global, peerId, tabId)) {
actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id });
}
});
actions.reloadPeerSavedGifts({ peerId });
});
addActionHandler('convertGiftToStars', async (global, actions, payload): Promise<void> => {
@ -451,11 +448,7 @@ addActionHandler('convertGiftToStars', async (global, actions, payload): Promise
}
const peerId = gift.type === 'user' ? global.currentUserId! : gift.chatId;
Object.values(global.byTabId).forEach((tabState) => {
if (selectPeerSavedGifts(global, peerId, tabState.id)) {
actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id });
}
});
actions.reloadPeerSavedGifts({ peerId });
actions.openStarsBalanceModal({ tabId });
});
@ -509,11 +502,7 @@ addActionHandler('toggleSavedGiftPinned', async (global, actions, payload): Prom
if (!result) return;
Object.values(global.byTabId).forEach((tabState) => {
if (selectPeerSavedGifts(global, peerId, tabState.id)) {
actions.loadPeerSavedGifts({ peerId, shouldRefresh: true, tabId: tabState.id });
}
});
actions.reloadPeerSavedGifts({ peerId });
});
addActionHandler('updateStarGiftPrice', async (global, actions, payload): Promise<void> => {

View File

@ -6,7 +6,7 @@ import { isUserId } from '../../util/entities/ids';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { isChatAdmin, isDeletedUser } from '../helpers';
import { selectChat, selectChatFullInfo } from './chats';
import { selectActiveGiftsCollectionId } from './payments';
import { type ProfileCollectionKey } from './payments';
import { selectTabState } from './tabs';
import { selectBot, selectUser, selectUserFullInfo } from './users';
@ -29,14 +29,22 @@ export function selectCanGift<T extends GlobalState>(global: T, peerId: string)
return selectChatFullInfo(global, peerId)?.areStarGiftsAvailable;
}
export function selectPeerCollectionSavedGifts<T extends GlobalState>(
global: T,
peerId: string,
collectionId: ProfileCollectionKey,
...[tabId = getCurrentTabId()]: TabArgs<T>
): ApiSavedGifts | undefined {
const tabState = selectTabState(global, tabId);
return tabState.savedGifts.collectionsByPeerId[peerId]?.[collectionId];
}
export function selectPeerSavedGifts<T extends GlobalState>(
global: T,
peerId: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
): ApiSavedGifts | undefined {
const tabState = selectTabState(global, tabId);
const activeCollectionId = selectActiveGiftsCollectionId(global, peerId, tabId);
return tabState.savedGifts.collectionsByPeerId[peerId]?.[activeCollectionId];
return selectPeerCollectionSavedGifts(global, peerId, 'all', tabId);
}
export function selectPeerStarGiftCollections<T extends GlobalState>(
@ -61,3 +69,8 @@ export function selectPeerPaidMessagesStars<T extends GlobalState>(
if (isChatAdmin(chat)) return undefined;
return chat.paidMessagesStars;
}
export function selectPeerHasProfileBackground<T extends GlobalState>(global: T, peerId: string) {
const peer = selectPeer(global, peerId);
return Boolean(peer?.profileColor || peer?.emojiStatus?.type === 'collectible');
}

View File

@ -1,5 +1,5 @@
import type { ApiMessage, ApiSponsoredMessage } from '../../api/types';
import type { PerformanceTypeKey, ThemeKey } from '../../types';
import type { ApiMessage, ApiPeer, ApiSponsoredMessage } from '../../api/types';
import type { CustomPeer, PerformanceTypeKey, ThemeKey } from '../../types';
import type { GlobalState, TabArgs } from '../types';
import { NewChatMembersProgress, RightColumnContent } from '../../types';
@ -186,3 +186,9 @@ export function selectSettingsScreen<T extends GlobalState>(
) {
return selectTabState(global, tabId).leftColumn.settingsScreen;
}
export function selectPeerProfileColor<T extends GlobalState>(global: T, peer: ApiPeer | CustomPeer) {
const key = 'isCustomPeer' in peer ? peer.peerColorId : peer.profileColor?.color;
if (!key) return undefined;
return global.peerColors?.profile[key];
}

View File

@ -1,11 +1,17 @@
import {
beginHeavyAnimation,
useEffect,
useRef,
useState,
} from '../../lib/teact/teact';
import { getGlobal } from '../../global';
import { VIEW_TRANSITION_CLASS_NAME } from '../../config';
import type { AnimationLevel } from '../../types';
import type { VTTypes } from '../../util/animations/viewTransitionTypes';
import { VT_CLASS_NAME, VT_TYPE_CLASS_PREFIX } from '../../config';
import { requestMutation, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import { selectSharedSettings } from '../../global/selectors/sharedState';
import { IS_VIEW_TRANSITION_SUPPORTED } from '../../util/browser/windowEnvironment';
import Deferred from '../../util/Deferred';
@ -14,25 +20,37 @@ type TransitionFunction = () => Promise<void> | void;
type TransitionState = 'idle' | 'capturing-old' | 'capturing-new' | 'animating' | 'skipped';
interface ViewTransitionController {
transitionState: TransitionState;
shouldApplyVtn?: boolean;
startViewTransition: (domUpdateCallback?: TransitionFunction) => PromiseLike<void> | void;
startViewTransition: (
types: VTTypes, domUpdateCallback?: TransitionFunction, minimumAnimationLevel?: AnimationLevel,
) => PromiseLike<void> | void;
}
type ViewTransitionParameters = {
domUpdateCallback?: TransitionFunction;
types?: VTTypes;
};
const SKIP_TIMEOUT = 1000;
let hasActiveTransition = false;
export function hasActiveViewTransition(): boolean {
return hasActiveTransition;
}
export function useViewTransition(): ViewTransitionController {
const domUpdaterFn = useRef<TransitionFunction>();
const parameters = useRef<ViewTransitionParameters>();
const [transitionState, setTransitionState] = useState<TransitionState>('idle');
useEffect(() => {
if (transitionState !== 'capturing-old') return;
const { domUpdateCallback, types } = parameters.current || {};
const onHeavyAnimationEnd = beginHeavyAnimation();
const transition = document.startViewTransition(async () => {
setTransitionState('capturing-new');
if (domUpdaterFn.current) await domUpdaterFn.current();
if (domUpdateCallback) {
await domUpdateCallback();
}
const deferred = new Deferred<void>();
requestNextMutation(() => {
deferred.resolve();
@ -40,46 +58,87 @@ export function useViewTransition(): ViewTransitionController {
return deferred.promise;
});
types?.getTypes().forEach((type) => {
transition.types?.add(type);
});
transition.finished.then(() => {
onHeavyAnimationEnd();
setTransitionState('idle');
requestMutation(() => {
document.body.classList.remove(VIEW_TRANSITION_CLASS_NAME);
cleanUp(types);
});
hasActiveTransition = false;
});
let isReady = false;
transition.ready.then(() => {
isReady = true;
setTransitionState('animating');
}).catch((e: unknown) => {
// eslint-disable-next-line no-console
console.error(e);
console.error('View transition error', e, types?.getTypes());
setTransitionState('skipped');
requestMutation(() => {
document.body.classList.remove(VIEW_TRANSITION_CLASS_NAME);
cleanUp(types);
});
hasActiveTransition = false;
});
setTimeout(() => {
if (!isReady) { // Skip transition if it's not prepared in time
transition.skipTransition();
}
}, SKIP_TIMEOUT);
}, [transitionState]);
function startViewTransition(updateCallback?: TransitionFunction): PromiseLike<void> | void {
function startViewTransition(
types: VTTypes,
updateCallback?: TransitionFunction,
minimumAnimationLevel: AnimationLevel = 1,
): PromiseLike<void> | void {
const global = getGlobal();
const { animationLevel } = selectSharedSettings(global);
// Fallback: simply run the callback immediately if view transitions aren't supported.
if (!IS_VIEW_TRANSITION_SUPPORTED) {
if (updateCallback) updateCallback();
if (!IS_VIEW_TRANSITION_SUPPORTED || animationLevel < minimumAnimationLevel) {
updateCallback?.();
return;
}
domUpdaterFn.current = updateCallback;
if (hasActiveTransition) {
// eslint-disable-next-line no-console
console.warn('VT skipped because another transition is already active', types.getTypes());
updateCallback?.();
return;
}
parameters.current = {
domUpdateCallback: updateCallback,
types,
};
setTransitionState('capturing-old');
requestMutation(() => {
document.body.classList.add(VIEW_TRANSITION_CLASS_NAME);
document.documentElement.classList.add(VT_CLASS_NAME);
types.getTypes().forEach((type) => {
document.documentElement.classList.add(`${VT_TYPE_CLASS_PREFIX}${type}`);
});
});
hasActiveTransition = true;
}
return {
shouldApplyVtn: transitionState === 'capturing-old'
|| transitionState === 'capturing-new' || transitionState === 'animating',
transitionState,
startViewTransition,
};
}
function cleanUp(types?: VTTypes) {
types?.getTypes().forEach((type) => {
document.documentElement.classList.remove(`${VT_TYPE_CLASS_PREFIX}${type}`);
});
document.documentElement.classList.remove(VT_CLASS_NAME);
}

View File

@ -0,0 +1,28 @@
import buildStyle from '../../util/buildStyle';
import useLastCallback from '../useLastCallback';
import useUniqueId from '../useUniqueId';
const VTN_PROPERTY_NAME = '--_vtn';
CSS.registerProperty?.({
name: VTN_PROPERTY_NAME,
syntax: '*',
inherits: false,
});
export function useVtn(uniqueId?: string) {
const fallbackId = useUniqueId();
// Pass `true` to use the same class name as the name parameter
const createVtnStyle = useLastCallback((name: string, vtClass?: string | boolean) => {
const vtClassString = vtClass === true ? name : (vtClass || undefined);
return buildStyle(
`${VTN_PROPERTY_NAME}: vtn-${name}-${uniqueId || fallbackId}`,
vtClassString && `view-transition-class: ${vtClassString}`,
);
});
return {
createVtnStyle,
};
}

View File

@ -26,6 +26,7 @@ export default function useTopOverscroll(
const lastIsOnTopRef = useRef(true);
const lastScrollAtRef = useRef(0);
const isReturningOverscrollRef = useRef(false);
const lastCalledStateRef = useRef<'overscroll' | 'reset' | undefined>(undefined);
const enableOverscrollTrigger = useLastCallback((noScrollInertiaStop = false) => {
if (isTriggerEnabledRef.current) return;
@ -77,14 +78,16 @@ export default function useTopOverscroll(
forceMutation(disableOverscrollTrigger, overscrollTriggerRef.current);
}
if (
isMovingUp && (
if (lastCalledStateRef.current !== 'overscroll' && isMovingUp
&& (
(lastIsOnTopRef.current && lastEventDelay > INERTIA_THRESHOLD)
|| (newScrollTop < 0 && isReturningOverscrollRef.current) // Overscroll repeated by the user
)) {
onOverscroll?.();
} else if (isMovingDown && newScrollTop > 0) {
lastCalledStateRef.current = 'overscroll';
} else if (lastCalledStateRef.current !== 'reset' && isMovingDown && newScrollTop > 0) {
onReset?.();
lastCalledStateRef.current = 'reset';
}
lastScrollTopRef.current = newScrollTop;
@ -101,10 +104,12 @@ export default function useTopOverscroll(
const isScrollable = container.scrollHeight > container.offsetHeight;
if (isScrollable || event.deltaY === 0) return;
if (event.deltaY < 0) {
if (lastCalledStateRef.current !== 'overscroll' && event.deltaY < 0) {
onOverscroll?.();
} else {
lastCalledStateRef.current = 'overscroll';
} else if (lastCalledStateRef.current !== 'reset') {
onReset?.();
lastCalledStateRef.current = 'reset';
}
}, [containerRef, onOverscroll, onReset], MOUSE_WHEEL_DEBOUNCE);

View File

@ -1708,6 +1708,7 @@ help.getAppConfig#61e3f854 hash:int = help.AppConfig;
help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList;
help.getPremiumPromo#b81b93d4 = help.PremiumPromo;
help.getPeerColors#da80f42f hash:int = help.PeerColors;
help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors;
help.getTimezonesList#49b30240 hash:int = help.TimezonesList;
channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool;
channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector<int> = messages.AffectedMessages;

View File

@ -248,6 +248,7 @@
"help.getCountriesList",
"help.getAppConfig",
"help.getPeerColors",
"help.getPeerProfileColors",
"help.getTimezonesList",
"help.getPremiumPromo",
"channels.readHistory",

View File

@ -178,3 +178,17 @@
box-shadow: none;
}
}
@mixin on-active-vt($type) {
:global {
.active-vt-#{$type} {
@content;
}
}
}
@mixin with-vt-type($type) {
:global(.active-vt-#{$type}) & {
view-transition-name: var(--_vtn);
}
}

View File

@ -484,13 +484,18 @@ body:not(.is-ios) {
--color-chat-username: rgb(233, 238, 244);
}
/* stylelint-disable-next-line scss/at-rule-no-unknown */
@view-transition {
/* stylelint-disable-next-line property-no-unknown */
navigation: auto;
types: page;
}
:root {
view-transition-name: none;
@include mixins.with-vt-type('page');
}
@include mixins.on-active-vt('page') {
&::view-transition-group(root) {
animation-duration: 400ms;
}

View File

@ -0,0 +1,23 @@
export class VTTypes {
private readonly hierarchy: string[] = [];
constructor(types: readonly string[]) {
this.hierarchy = [...(types || [])];
}
with(type: string): VTTypes {
return new VTTypes([...this.hierarchy, type]);
}
getTypes(): readonly string[] {
return this.hierarchy;
}
}
// View transition types
export const VTT_RIGHT_COLUMN = new VTTypes(['rightColumn']);
export const VTT_RIGHT_PROFILE_AVATAR = VTT_RIGHT_COLUMN.with('profileAvatar');
export const VTT_RIGHT_PROFILE_EXPAND = VTT_RIGHT_PROFILE_AVATAR.with('profileExpand');
export const VTT_RIGHT_PROFILE_COLLAPSE = VTT_RIGHT_PROFILE_AVATAR.with('profileCollapse');
export const VTT_PROFILE_GIFTS = VTT_RIGHT_COLUMN.with('profileGifts');

View File

@ -109,7 +109,7 @@ export const IS_BACKDROP_BLUR_SUPPORTED = CSS.supports('backdrop-filter: blur()'
export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window;
export const IS_OPEN_IN_NEW_TAB_SUPPORTED = !(IS_PWA && IS_MOBILE);
export const IS_TRANSLATION_SUPPORTED = !IS_TEST;
export const IS_VIEW_TRANSITION_SUPPORTED = 'ViewTransition' in window;
export const IS_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test');
export const MESSAGE_LIST_SENSITIVE_AREA = 750;

View File

@ -33,12 +33,9 @@
"tests",
"plugins",
"dev",
"webpack.config.ts",
"babel.config.js",
"eslint.config.mjs",
"*.config.ts",
"*.config.js",
".fantasticonrc.cjs",
"playwright.config.ts",
"jest.config.js",
".github/workflows/*.js",
"deploy/*.js"
]