Attachment Modal: Redesign (#6777)

This commit is contained in:
Alexander Zinchuk 2026-03-31 11:29:24 +02:00
parent a44ecb0113
commit 6770fff857
58 changed files with 2342 additions and 722 deletions

View File

@ -366,6 +366,7 @@ lang('SimpleKey');
// Plurals // Plurals
lang('PluralKey', undefined, { pluralValue: 3 }); lang('PluralKey', undefined, { pluralValue: 3 });
lang('PluralKey', { count: 3 }, { pluralValue: 3 }); // if key has variables
// String replacements // String replacements
lang('ReplKey', { name: 'Amy' }); lang('ReplKey', { name: 'Amy' });

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M25.468 9.259a1.82 1.82 0 0 0-2.572 0l-9.623 9.625-3.26-3.26a1.82 1.82 0 0 0-2.573 2.571l4.548 4.545c.71.71 1.861.71 2.571 0l10.909-10.909a1.82 1.82 0 0 0 0-2.572"/></svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M4.219 14.363s10.066-4.358 13.425-5.746c6.393-2.643 7.72-3.102 8.586-3.117.19-.003.616.044.892.266.233.188.297.442.327.62s.07.583.039.9c-.347 3.617-1.846 12.395-2.608 16.447-.323 1.714-.958 2.289-1.573 2.345-1.337.122-2.352-.878-3.647-1.721-2.026-1.32-3.17-2.142-5.138-3.43-2.272-1.488-.799-2.306.496-3.643.34-.35 6.23-5.674 6.343-6.157.015-.06.028-.285-.107-.404-.134-.119-.333-.078-.476-.046-.305.069-9.71 6.378-9.71 6.378s-1.379.94-2.497.916c-.822-.017-2.403-.462-3.578-.841-1.442-.466-2.588-.712-2.488-1.503.078-.618 1.713-1.264 1.713-1.264"/></svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@ -302,6 +302,7 @@
"ConversationPinMessagesFor" = "Pin for me and {user}"; "ConversationPinMessagesFor" = "Pin for me and {user}";
"ConversationPinMessageAlertPinAndNotifyMembers" = "Pin and notify all members"; "ConversationPinMessageAlertPinAndNotifyMembers" = "Pin and notify all members";
"SavedMessages" = "Saved Messages"; "SavedMessages" = "Saved Messages";
"SavedMessagesShort" = "Saved";
"AccDescrPrevious" = "Previous"; "AccDescrPrevious" = "Previous";
"ReportReasonSpam" = "Spam"; "ReportReasonSpam" = "Spam";
"ReportReasonViolence" = "Violence"; "ReportReasonViolence" = "Violence";
@ -584,6 +585,8 @@
"ContactShare" = "Share Contact"; "ContactShare" = "Share Contact";
"OK" = "OK"; "OK" = "OK";
"ForwardTo" = "Forward to..."; "ForwardTo" = "Forward to...";
"ShareWith" = "Share with";
"SelectChats" = "Select chats";
"AttachGame" = "Game"; "AttachGame" = "Game";
"JumpToDate" = "Jump to Date"; "JumpToDate" = "Jump to Date";
"FloodWait" = "Too many attempts, please try again later."; "FloodWait" = "Too many attempts, please try again later.";
@ -617,6 +620,7 @@
"WebAppAddToAttachmentAdd" = "Add"; "WebAppAddToAttachmentAdd" = "Add";
"AccActionDownload" = "Download"; "AccActionDownload" = "Download";
"Forward" = "Forward"; "Forward" = "Forward";
"ForwardForStars" = "Forward for {price}";
"MediaZoomOut" = "Zoom Out"; "MediaZoomOut" = "Zoom Out";
"MediaZoomIn" = "Zoom In"; "MediaZoomIn" = "Zoom In";
"PeerInfoReportProfileVideo" = "Report Profile Photo"; "PeerInfoReportProfileVideo" = "Report Profile Photo";
@ -2143,6 +2147,11 @@
"ConfirmationModalPaymentForMessage_other" = "{user} charges **{amount}** per incoming message. Would you like to pay **{totalAmount}** to send **{count} messages?**"; "ConfirmationModalPaymentForMessage_other" = "{user} charges **{amount}** per incoming message. Would you like to pay **{totalAmount}** to send **{count} messages?**";
"PayForMessage_one" = "Pay for {count} message"; "PayForMessage_one" = "Pay for {count} message";
"PayForMessage_other" = "Pay for {count} messages"; "PayForMessage_other" = "Pay for {count} messages";
"ForwardPaidChatsConfirmation" = "{chatsSelected} {payConfirmation}";
"ForwardPaidChatsSelected_one" = "You selected **{paidChatsCount}** chat that charges Stars for messages.";
"ForwardPaidChatsSelected_other" = "You selected **{paidChatsCount}** chats that charge Stars for messages.";
"ForwardPaidChatsPayConfirmation_one" = "Would you like to pay **{totalAmount}** to send **{count}** message?";
"ForwardPaidChatsPayConfirmation_other" = "Would you like to pay **{totalAmount}** to send **{count}** messages?";
"MessageSentPaidToastTitle_one" = "Message sent!"; "MessageSentPaidToastTitle_one" = "Message sent!";
"MessageSentPaidToastTitle_other" = "{count} messages sent!"; "MessageSentPaidToastTitle_other" = "{count} messages sent!";
"MessageSentPaidToastText" = "You paid {amount}"; "MessageSentPaidToastText" = "You paid {amount}";
@ -2690,8 +2699,6 @@
"AttachmentSendAudio_other" = "Send {count} Audios"; "AttachmentSendAudio_other" = "Send {count} Audios";
"AttachmentSendFile_one" = "Send File"; "AttachmentSendFile_one" = "Send File";
"AttachmentSendFile_other" = "Send {count} Files"; "AttachmentSendFile_other" = "Send {count} Files";
"AttachmentSendGif" = "Send GIF";
"AttachmentReplaceGif" = "Replace GIF";
"AttachmentDragAddItems" = "Add Items"; "AttachmentDragAddItems" = "Add Items";
"AttachmentCaptionPlaceholder" = "Add a caption..."; "AttachmentCaptionPlaceholder" = "Add a caption...";
"MessageSummaryTitle" = "AI Summary"; "MessageSummaryTitle" = "AI Summary";
@ -2772,6 +2779,8 @@
"RankEditTextOwn" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; "RankEditTextOwn" = "Share your role, title or how you're known in this group. Your tag is visible to all members.";
"RankEditText" = "Add a short tag next to {user}'s name."; "RankEditText" = "Add a short tag next to {user}'s name.";
"MenuAddCaption" = "Add Caption"; "MenuAddCaption" = "Add Caption";
"FwdMessagesToChats_one" = "Message forwarded to {count} chat";
"FwdMessagesToChats_other" = "Message forwarded to {count} chats";
"MenuCopyDate" = "Copy Date"; "MenuCopyDate" = "Copy Date";
"DateCopiedToast" = "Date copied to clipboard"; "DateCopiedToast" = "Date copied to clipboard";
"ReminderSetToast" = "You set up a reminder in **Saved Messages**"; "ReminderSetToast" = "You set up a reminder in **Saved Messages**";

View File

@ -896,7 +896,7 @@
} }
#caption-input-text .placeholder-text { #caption-input-text .placeholder-text {
bottom: 0.8125rem; bottom: 0.875rem;
} }
#story-input-text .placeholder-text { #story-input-text .placeholder-text {

View File

@ -31,7 +31,7 @@ import File from './File';
type OwnProps = { type OwnProps = {
document: ApiDocument; document: ApiDocument;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
smaller?: boolean; fileSize?: 'small' | 'medium' | 'large';
isSelected?: boolean; isSelected?: boolean;
isSelectable?: boolean; isSelectable?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
@ -59,7 +59,7 @@ const BYTES_PER_MB = 1024 * 1024;
const Document = ({ const Document = ({
document, document,
observeIntersection, observeIntersection,
smaller, fileSize,
canAutoLoad, canAutoLoad,
autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb,
uploadProgress, uploadProgress,
@ -201,7 +201,7 @@ const Document = ({
timestamp={datetime} timestamp={datetime}
thumbnailDataUri={thumbDataUri} thumbnailDataUri={thumbDataUri}
previewData={localBlobUrl || previewData} previewData={localBlobUrl || previewData}
smaller={smaller} previewSize={fileSize}
isTransferring={isTransferring} isTransferring={isTransferring}
isUploading={isUploading} isUploading={isUploading}
transferProgress={transferProgress} transferProgress={transferProgress}

View File

@ -185,7 +185,7 @@
padding-inline: 0.25rem; padding-inline: 0.25rem;
} }
&.smaller { &.size-small {
--background-color: var(--color-background); --background-color: var(--color-background);
--border-radius-messages-small: 0.3125rem; --border-radius-messages-small: 0.3125rem;
@ -218,6 +218,29 @@
} }
} }
&.size-large {
.action-icon,
.file-progress,
.file-icon,
.file-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: 0.75rem;
}
.file-info {
margin-top: 0;
}
.file-subtitle {
line-height: 1rem;
}
.file-icon-container {
margin-inline-end: 0.5rem;
}
}
&:dir(rtl), &:dir(rtl),
&[dir="rtl"] { &[dir="rtl"] {
.file-progress, .file-progress,

View File

@ -26,6 +26,8 @@ import Icon from './icons/Icon';
import './File.scss'; import './File.scss';
type FileSize = 'small' | 'medium' | 'large';
type OwnProps = { type OwnProps = {
ref?: ElementRef<HTMLDivElement>; ref?: ElementRef<HTMLDivElement>;
id?: string; id?: string;
@ -37,7 +39,7 @@ type OwnProps = {
thumbnailDataUri?: string; thumbnailDataUri?: string;
previewData?: string; previewData?: string;
className?: string; className?: string;
smaller?: boolean; previewSize?: FileSize;
isTransferring?: boolean; isTransferring?: boolean;
isUploading?: boolean; isUploading?: boolean;
isSelectable?: boolean; isSelectable?: boolean;
@ -59,7 +61,7 @@ const File = ({
thumbnailDataUri, thumbnailDataUri,
previewData, previewData,
className, className,
smaller, previewSize = 'medium',
isTransferring, isTransferring,
isUploading, isUploading,
isSelectable, isSelectable,
@ -89,12 +91,12 @@ const File = ({
const color = getColorFromExtension(extension); const color = getColorFromExtension(extension);
const { width, height } = getDocumentThumbnailDimensions(smaller); const { width, height } = getDocumentThumbnailDimensions(previewSize);
const fullClassName = buildClassName( const fullClassName = buildClassName(
'File', 'File',
className, className,
smaller && 'smaller', previewSize !== 'medium' && `size-${previewSize}`,
onClick && !isUploading && 'interactive', onClick && !isUploading && 'interactive',
isSelected && 'file-is-selected', isSelected && 'file-is-selected',
); );
@ -135,7 +137,7 @@ const File = ({
<div className={buildClassName('file-progress', color, spinnerClassNames)}> <div className={buildClassName('file-progress', color, spinnerClassNames)}>
<ProgressSpinner <ProgressSpinner
progress={transferProgress} progress={transferProgress}
size={smaller ? 's' : 'm'} size={previewSize === 'small' ? 's' : 'm'}
onClick={isUploading ? onClick : undefined} onClick={isUploading ? onClick : undefined}
/> />
</div> </div>

View File

@ -8,7 +8,7 @@
align-items: center; align-items: center;
min-width: 0; min-width: 0;
height: 2rem; height: var(--chip-size, 2rem);
margin-inline: 0.25rem; margin-inline: 0.25rem;
padding-right: 0.75rem; padding-right: 0.75rem;
border-radius: 1rem; border-radius: 1rem;
@ -47,12 +47,17 @@
cursor: default; cursor: default;
} }
&.small {
margin-inline: 0;
border-radius: 1.4375rem;
}
.avatar, .avatar,
.iconWrapper { .iconWrapper {
flex-shrink: 0; flex-shrink: 0;
width: 2rem; width: var(--chip-size, 2rem);
height: 2rem; height: var(--chip-size, 2rem);
opacity: 1; opacity: 1;
@ -102,8 +107,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: var(--chip-size, 2rem);
height: 2rem; height: var(--chip-size, 2rem);
border-radius: 50%; border-radius: 50%;
font-size: 1.5rem; font-size: 1.5rem;
@ -118,10 +123,9 @@
&.squareAvatar { &.squareAvatar {
--border-radius-forum-avatar: 0.625rem; --border-radius-forum-avatar: 0.625rem;
border-start-start-radius: 0.625rem; border-radius: 0.625rem;
border-end-start-radius: 0.625rem;
&.minimized, .remove { .remove {
border-radius: 0.625rem; border-radius: 0.625rem;
} }
} }

View File

@ -10,6 +10,7 @@ import { getPeerTitle, isApiPeerChat } from '../../global/helpers/peers';
import { selectPeer, selectTheme, selectUser } from '../../global/selectors'; import { selectPeer, selectTheme, selectUser } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle'; import buildStyle from '../../util/buildStyle';
import { REM } from './helpers/mediaDimensions';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import usePeerColor from '../../hooks/usePeerColor'; import usePeerColor from '../../hooks/usePeerColor';
@ -20,10 +21,14 @@ import Icon from './icons/Icon';
import styles from './PeerChip.module.scss'; import styles from './PeerChip.module.scss';
const CHIP_SIZE_SMALL = 1.875 * REM;
const CHIP_SIZE_MEDIUM = 2 * REM;
export type PeerChipSize = 'small' | 'medium';
type OwnProps<T = undefined> = { type OwnProps<T = undefined> = {
peerId?: string; peerId?: string;
size?: PeerChipSize;
forceShowSelf?: boolean; forceShowSelf?: boolean;
customPeer?: CustomPeer; customPeer?: CustomPeer;
mockPeer?: ApiPeer; mockPeer?: ApiPeer;
@ -33,11 +38,11 @@ type OwnProps<T = undefined> = {
canClose?: boolean; canClose?: boolean;
isCloseNonDestructive?: boolean; isCloseNonDestructive?: boolean;
className?: string; className?: string;
itemClassName?: string;
withPeerColors?: boolean; withPeerColors?: boolean;
withEmojiStatus?: boolean; withEmojiStatus?: boolean;
clickArg?: T; clickArg?: T;
onClick?: (arg: T) => void; onClick?: (arg: T) => void;
itemClassName?: string;
}; };
type StateProps = { type StateProps = {
@ -49,6 +54,7 @@ type StateProps = {
const PeerChip = <T,>({ const PeerChip = <T,>({
icon, icon,
title, title,
size = 'medium',
isMinimized, isMinimized,
canClose, canClose,
isCloseNonDestructive, isCloseNonDestructive,
@ -57,10 +63,10 @@ const PeerChip = <T,>({
mockPeer, mockPeer,
customPeer, customPeer,
className, className,
itemClassName,
isSavedMessages, isSavedMessages,
withPeerColors, withPeerColors,
withEmojiStatus, withEmojiStatus,
itemClassName,
theme, theme,
onClick, onClick,
}: OwnProps<T> & StateProps) => { }: OwnProps<T> & StateProps) => {
@ -106,6 +112,7 @@ const PeerChip = <T,>({
const fullClassName = buildClassName( const fullClassName = buildClassName(
styles.root, styles.root,
size === 'small' && styles.small,
(chat?.isForum || customPeer?.isAvatarSquare) && styles.squareAvatar, (chat?.isForum || customPeer?.isAvatarSquare) && styles.squareAvatar,
isMinimized && styles.minimized, isMinimized && styles.minimized,
canClose && styles.closeable, canClose && styles.closeable,
@ -115,7 +122,10 @@ const PeerChip = <T,>({
className, className,
); );
const chipSize = size === 'small' ? CHIP_SIZE_SMALL : CHIP_SIZE_MEDIUM;
const style = buildStyle( const style = buildStyle(
`--chip-size: ${chipSize}px`,
withPeerColors && peerColorStyle, withPeerColors && peerColorStyle,
); );

View File

@ -0,0 +1,7 @@
.recentContacts {
margin-block: 1rem;
&:last-child {
margin-bottom: 0;
}
}

View File

@ -1,4 +1,5 @@
import { memo, useMemo, useState } from '../../lib/teact/teact'; import type { TeactNode } from '../../lib/teact/teact';
import { memo, useCallback, useMemo, useState } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiChatFolder, ApiChatType } from '../../api/types'; import type { ApiChatFolder, ApiChatType } from '../../api/types';
@ -7,36 +8,51 @@ import type { ThreadId } from '../../types';
import { ALL_FOLDER_ID, API_CHAT_TYPES } from '../../config'; import { ALL_FOLDER_ID, API_CHAT_TYPES } from '../../config';
import { import {
getCanPostInChat, getCanPostInChat,
getChatTitle,
getHasAdminRight, getHasAdminRight,
isChatChannel, isChatChannel,
isDeletedUser, isDeletedUser,
isSystemBot,
} from '../../global/helpers'; } from '../../global/helpers';
import { filterPeersByQuery } from '../../global/helpers/peers'; import { filterPeersByQuery } from '../../global/helpers/peers';
import { import {
filterChatIdsByType, selectChat, selectChatFullInfo, selectIsMonoforumAdmin, selectUser, filterChatIdsByType, selectCanAnimateInterface, selectChat, selectChatFullInfo, selectIsMonoforumAdmin,
selectTopic, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import { selectCurrentLimit } from '../../global/selectors/limits'; import { selectCurrentLimit } from '../../global/selectors/limits';
import buildClassName from '../../util/buildClassName';
import { unique } from '../../util/iteratees'; import { unique } from '../../util/iteratees';
import sortChatIds from './helpers/sortChatIds'; import sortChatIds from './helpers/sortChatIds';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import { useFolderManagerForOrderedIds } from '../../hooks/useFolderManager'; import { useFolderManagerForOrderedIds } from '../../hooks/useFolderManager';
import useFolderTabs from '../../hooks/useFolderTabs'; import useFolderTabs from '../../hooks/useFolderTabs';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import { useStateRef } from '../../hooks/useStateRef';
import TabList from '../ui/TabList'; import TabList from '../ui/TabList';
import ChatOrUserPicker from './pickers/ChatOrUserPicker'; import PeerChip from './PeerChip';
import ChatOrUserPicker, { type SearchRowRenderProps } from './pickers/ChatOrUserPicker';
import PickerRecentContacts from './pickers/PickerRecentContacts';
import styles from './RecipientPicker.module.scss';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
title?: string;
searchPlaceholder: string; searchPlaceholder: string;
className?: string; className?: string;
filter?: readonly ApiChatType[]; filter?: readonly ApiChatType[];
isLowStackPriority?: boolean; isLowStackPriority?: boolean;
isForwarding?: boolean; isForwarding?: boolean;
isMultiSelect?: boolean;
withFolders?: boolean; withFolders?: boolean;
footer?: TeactNode;
viewportFooter?: TeactNode;
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; onSelectRecipient: (peerId: string, threadId?: ThreadId) => void;
onSelectedIdsChange?: (ids: string[]) => void;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction;
}; };
@ -52,6 +68,8 @@ type StateProps = {
maxFolders: number; maxFolders: number;
}; };
const RECENT_CONTACTS_LIMIT = 15;
const RecipientPicker = ({ const RecipientPicker = ({
isOpen, isOpen,
currentUserId, currentUserId,
@ -60,21 +78,34 @@ const RecipientPicker = ({
pinnedIds, pinnedIds,
contactIds, contactIds,
filter = API_CHAT_TYPES, filter = API_CHAT_TYPES,
title,
className, className,
searchPlaceholder, searchPlaceholder,
isLowStackPriority, isLowStackPriority,
chatFoldersById, chatFoldersById,
orderedFolderIds, orderedFolderIds,
isForwarding, isForwarding,
isMultiSelect,
maxFolders, maxFolders,
withFolders, withFolders,
footer,
viewportFooter,
loadMore, loadMore,
onSelectRecipient, onSelectRecipient,
onSelectedIdsChange,
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { openLimitReachedModal } = getActions(); const { openLimitReachedModal } = getActions();
const lang = useLang();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [removingIds, setRemovingIds] = useState<string[]>([]);
const [appearingIds, setAppearingIds] = useState<string[]>([]);
const selectedIdsRef = useStateRef(selectedIds);
const removingIdsRef = useStateRef(removingIds);
const appearingIdsRef = useStateRef(appearingIds);
const [activeFolderIndex, setActiveFolderIndex] = useState(0); const [activeFolderIndex, setActiveFolderIndex] = useState(0);
const { displayedFolders, folderTabs } = useFolderTabs({ const { displayedFolders, folderTabs } = useFolderTabs({
@ -85,7 +116,7 @@ const RecipientPicker = ({
isReadOnly: true, isReadOnly: true,
}); });
const shouldRenderFolders = withFolders && folderTabs?.length && !search; const shouldRenderFolders = withFolders && folderTabs && folderTabs.length > 1 && !search;
const displayedFolderId = displayedFolders?.[activeFolderIndex]?.id || ALL_FOLDER_ID; const displayedFolderId = displayedFolders?.[activeFolderIndex]?.id || ALL_FOLDER_ID;
const orderedChatIds = useFolderManagerForOrderedIds(displayedFolderId); const orderedChatIds = useFolderManagerForOrderedIds(displayedFolderId);
@ -103,6 +134,57 @@ const RecipientPicker = ({
setActiveFolderIndex(index); setActiveFolderIndex(index);
}); });
const updateSelectedIds = useLastCallback((newIds: string[], newlyAddedId?: string) => {
setSelectedIds(newIds);
onSelectedIdsChange?.(newIds);
if (newlyAddedId && selectCanAnimateInterface(getGlobal())) {
setAppearingIds([...appearingIdsRef.current, newlyAddedId]);
setTimeout(() => {
setAppearingIds(appearingIdsRef.current.filter((id) => id !== newlyAddedId));
}, 200);
}
});
const handleRemoveSelected = useLastCallback((selectionId: string) => {
if (removingIdsRef.current.includes(selectionId)) return;
const canAnimate = selectCanAnimateInterface(getGlobal());
if (!canAnimate) {
const newIds = selectedIdsRef.current.filter((id) => id !== selectionId);
setSelectedIds(newIds);
onSelectedIdsChange?.(newIds);
return;
}
setRemovingIds([...removingIdsRef.current, selectionId]);
setTimeout(() => {
setRemovingIds(removingIdsRef.current.filter((id) => id !== selectionId));
const newIds = selectedIdsRef.current.filter((id) => id !== selectionId);
setSelectedIds(newIds);
onSelectedIdsChange?.(newIds);
}, 300);
});
const handleToggleSelection = useLastCallback((peerId: string, threadId?: ThreadId) => {
const selectionId = threadId ? `${peerId}:${threadId}` : peerId;
if (selectedIds.includes(selectionId)) {
handleRemoveSelected(selectionId);
} else {
updateSelectedIds([...selectedIds, selectionId], selectionId);
}
});
const handleSelect = useLastCallback((peerId: string, threadId?: ThreadId) => {
if (isMultiSelect) {
handleToggleSelection(peerId, threadId);
} else {
onSelectRecipient(peerId, threadId);
}
});
const ids = useMemo(() => { const ids = useMemo(() => {
if (!isOpen) return []; if (!isOpen) return [];
@ -120,6 +202,8 @@ const RecipientPicker = ({
]; ];
const peerIds = allIds.filter((id) => { const peerIds = allIds.filter((id) => {
if (isSystemBot(id)) return false;
const chat = selectChat(global, id); const chat = selectChat(global, id);
const user = selectUser(global, id); const user = selectUser(global, id);
const hasAdminRights = chat && getHasAdminRight(chat, 'postMessages'); const hasAdminRights = chat && getHasAdminRight(chat, 'postMessages');
@ -171,16 +255,145 @@ const RecipientPicker = ({
const renderingIds = useCurrentOrPrev(ids, true); const renderingIds = useCurrentOrPrev(ids, true);
const chatFolders = useMemo(() => { const recentContactIds = useMemo(() => {
if (!shouldRenderFolders) return undefined; if (!contactIds) return [];
return contactIds.slice(0, RECENT_CONTACTS_LIMIT);
}, [contactIds]);
const hasSelectedChips = isMultiSelect && selectedIds.length > 0;
const parseSelectionId = useLastCallback((selectionId: string): { peerId: string; topicId?: number } => {
const colonIndex = selectionId.indexOf(':');
if (colonIndex === -1) {
return { peerId: selectionId };
}
return {
peerId: selectionId.substring(0, colonIndex),
topicId: Number(selectionId.substring(colonIndex + 1)),
};
});
const getChipTitle = useLastCallback((selectionId: string): string | undefined => {
const { peerId, topicId } = parseSelectionId(selectionId);
if (!topicId) return undefined;
const global = getGlobal();
const topic = selectTopic(global, peerId, topicId);
const chat = selectChat(global, peerId);
if (!topic || !chat) return undefined;
const chatTitle = getChatTitle(lang, chat);
return `${topic.title}${chatTitle}`;
});
const renderSearchRow = useCallback((props: SearchRowRenderProps) => {
if (!hasSelectedChips) {
return (
<div className="search-input-wrapper">
<i className="icon icon-search" />
<input
ref={props.inputRef}
className="search-input"
type="text"
dir="auto"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
/>
</div>
);
}
return ( return (
<TabList <div className="search-row-with-chips">
tabs={folderTabs} <div className="chips-and-search-scroll no-scrollbar">
activeTab={activeFolderIndex} {selectedIds.map((selectionId) => {
onSwitchTab={handleSwitchFolderIndex} const { peerId } = parseSelectionId(selectionId);
/> const chipTitle = getChipTitle(selectionId);
const isAppearing = appearingIds.includes(selectionId);
const isRemoving = removingIds.includes(selectionId);
return (
<div
key={selectionId}
className={buildClassName(
'picker-chip-wrapper',
isAppearing && 'picker-chip-appear',
isRemoving && 'picker-chip-disappear',
)}
>
<PeerChip
peerId={peerId}
title={chipTitle}
size="small"
forceShowSelf
canClose
className="picker-chip"
itemClassName="picker-chip-name"
clickArg={selectionId}
onClick={handleRemoveSelected}
/>
</div>
);
})}
<div className="inline-search">
<i className="icon icon-search" />
<input
ref={props.inputRef}
className="search-input"
type="text"
dir="auto"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
/>
</div>
</div>
</div>
); );
}, [folderTabs, activeFolderIndex, shouldRenderFolders]); }, [hasSelectedChips, selectedIds, appearingIds, removingIds]);
const subheaderContent = useMemo(() => {
const hasRecentContacts = recentContactIds.length > 0 && !search;
const hasFolderTabs = shouldRenderFolders;
if (!hasRecentContacts && !hasFolderTabs) return undefined;
return (
<>
{hasRecentContacts && (
<PickerRecentContacts
contactIds={recentContactIds}
currentUserId={currentUserId}
selectedIds={isMultiSelect ? selectedIds : undefined}
className={styles.recentContacts}
onSelect={handleSelect}
/>
)}
{Boolean(hasFolderTabs) && folderTabs && (
<TabList
tabs={folderTabs}
activeTab={activeFolderIndex}
onSwitchTab={handleSwitchFolderIndex}
/>
)}
</>
);
}, [
recentContactIds,
search,
shouldRenderFolders,
currentUserId,
handleSelect,
isMultiSelect,
selectedIds,
folderTabs,
activeFolderIndex,
handleSwitchFolderIndex,
]);
return ( return (
<ChatOrUserPicker <ChatOrUserPicker
@ -188,13 +401,18 @@ const RecipientPicker = ({
className={className} className={className}
chatOrUserIds={renderingIds} chatOrUserIds={renderingIds}
currentUserId={currentUserId} currentUserId={currentUserId}
title={title}
searchPlaceholder={searchPlaceholder} searchPlaceholder={searchPlaceholder}
search={search} search={search}
subheader={chatFolders} renderSearchRow={renderSearchRow}
subheader={subheaderContent}
footer={footer}
viewportFooter={viewportFooter}
listActiveKey={activeFolderIndex} listActiveKey={activeFolderIndex}
selectedIds={isMultiSelect ? selectedIds : undefined}
onSearchChange={setSearch} onSearchChange={setSearch}
loadMore={loadMore} loadMore={loadMore}
onSelectChatOrUser={onSelectRecipient} onSelectChatOrUser={handleSelect}
onClose={onClose} onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
isLowStackPriority={isLowStackPriority} isLowStackPriority={isLowStackPriority}

View File

@ -192,18 +192,26 @@ export function getPictogramDimensions(): ApiDimensions {
}; };
} }
export function getDocumentThumbnailDimensions(smaller?: boolean): ApiDimensions { export function getDocumentThumbnailDimensions(
if (smaller) { size: 'small' | 'medium' | 'large' = 'medium',
return { ): ApiDimensions {
width: 3 * REM, switch (size) {
height: 3 * REM, case 'small':
}; return {
width: 3 * REM,
height: 3 * REM,
};
case 'large':
return {
width: 4.5 * REM,
height: 4.5 * REM,
};
default:
return {
width: 3.375 * REM,
height: 3.375 * REM,
};
} }
return {
width: 3.375 * REM,
height: 3.375 * REM,
};
} }
export function getStickerDimensions(sticker: ApiSticker, isMobile?: boolean): ApiDimensions { export function getStickerDimensions(sticker: ApiSticker, isMobile?: boolean): ApiDimensions {

View File

@ -1,13 +1,49 @@
@use "../../../styles/mixins"; @use "../../../styles/mixins";
@keyframes pickerChipAppear {
from {
transform: scale(0.8);
opacity: 0;
}
}
/* stylelint-disable plugin/no-low-performance-animation-properties */
@keyframes pickerChipDisappear {
0% {
transform: scale(1);
max-width: 10rem;
opacity: 1;
}
50% {
transform: scale(0.8);
max-width: 10rem;
opacity: 0;
}
100% {
transform: scale(0.8);
max-width: 0;
margin-inline-end: 0;
opacity: 0;
}
}
/* stylelint-enable plugin/no-low-performance-animation-properties */
.ChatOrUserPicker { .ChatOrUserPicker {
.modal-dialog { .modal-dialog {
overflow: hidden; overflow: hidden;
max-width: 25rem;
height: 70%; max-width: 26.25rem;
height: 38.75rem;
max-height: 80%;
border-radius: 2.5rem;
background-color: var(--color-background-secondary);
@media (max-width: 600px) { @media (max-width: 600px) {
height: 90%; height: 90%;
max-height: 90%;
} }
} }
@ -16,40 +52,144 @@
flex-direction: column; flex-direction: column;
} }
.modal-header { .picker-header {
display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
.search-wrapper { &-topics {
display: flex; flex-direction: row;
gap: 0.5rem;
align-items: center; align-items: center;
.Button { .picker-back-button {
margin-right: 0.5rem; flex-shrink: 0;
} }
.input-group { .search-input-wrapper,
.search-row-with-chips {
flex: 1; flex: 1;
margin: 0;
}
.form-control {
unicode-bidi: plaintext;
height: 2.75rem;
padding: 0.5rem;
border: none;
font-size: 1.25rem;
line-height: 1.75rem;
box-shadow: none !important;
} }
} }
.TabList { .search-input-wrapper {
margin-bottom: -0.375rem; position: relative;
margin-inline: -1rem; display: flex;
align-items: center;
.icon-search {
pointer-events: none;
position: absolute;
left: 1rem;
font-size: 1.5rem;
color: var(--color-text-secondary);
opacity: 0.75;
}
}
.search-input {
flex: 1;
height: 3rem;
padding: 0.5rem 0.75rem 0.5rem 3.125rem;
border: none;
border-radius: 1.5rem;
font-size: 1rem;
line-height: 1.5rem;
color: var(--color-text);
background-color: var(--color-background);
outline: none;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
&::placeholder {
color: var(--color-text-secondary);
opacity: 0.75;
}
}
.chips-and-search-scroll {
overflow-x: auto;
overflow-y: hidden;
display: flex;
gap: 0.5rem;
align-items: center;
height: 3rem;
padding: 0.5rem 0.625rem;
border-radius: 1.5rem;
white-space: nowrap;
background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
}
.picker-chip-wrapper {
flex-shrink: 0;
}
.picker-chip-appear {
animation: pickerChipAppear 0.2s ease-out;
}
.picker-chip-disappear {
overflow: hidden;
animation: pickerChipDisappear 0.3s ease-out forwards;
}
.picker-chip {
flex-shrink: 0;
max-width: 10rem;
&:not(:hover) {
background-color: var(--color-primary-opacity);
}
}
.picker-chip:not(:hover) .picker-chip-name {
color: var(--color-primary);
}
.picker-chip-name {
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s ease;
}
.inline-search {
display: flex;
flex: 1;
align-items: center;
min-width: 10rem;
.icon-search {
font-size: 1.5rem;
color: var(--color-text-secondary);
opacity: 0.75;
}
.search-input {
min-width: 0;
height: auto;
padding: 0 0.5rem;
border: none;
border-radius: 0;
font-size: 1rem;
background-color: transparent;
outline: none;
box-shadow: none;
&::placeholder {
color: var(--color-text-secondary);
}
}
} }
} }
@ -57,7 +197,9 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 !important;
padding-block: 0 !important;
padding-inline: 1rem !important;
> .Transition { > .Transition {
overflow: hidden; overflow: hidden;
@ -69,10 +211,15 @@
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
padding-block: 0.125rem; margin-top: 1rem;
margin-bottom: 1rem;
padding-block: 0.375rem;
padding-inline: 0.5rem; padding-inline: 0.5rem;
border-radius: 1.5rem;
@include mixins.adapt-padding-to-scrollbar(0.5rem); background-color: var(--color-background);
@include mixins.adapt-padding-to-scrollbar(0.5rem, 0.125rem);
body.is-ios &, body.is-ios &,
body.is-android & { body.is-android & {
@ -85,6 +232,14 @@
} }
} }
&:has(.picker-footer) {
.picker-list {
margin-bottom: 3rem;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
.no-results { .no-results {
display: flex; display: flex;
align-items: center; align-items: center;
@ -97,8 +252,16 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.scroll-container { .picker-list-loading {
position: relative; display: flex;
align-items: center;
justify-content: center;
}
.picker-list-spacer {
position: absolute;
width: 100%;
height: 1.25rem;
} }
.ChatOrUserPicker-item { .ChatOrUserPicker-item {
@ -108,6 +271,86 @@
.online { .online {
color: var(--color-primary); color: var(--color-primary);
} }
@media (hover: hover) {
&:hover .forum-badge {
outline-color: var(--color-item-hover);
}
}
}
.picker-avatar-wrapper {
position: relative;
}
.forum-badge {
position: absolute;
z-index: 2;
right: -0.125rem;
bottom: -0.125rem;
display: flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
font-size: 0.625rem;
color: var(--color-white);
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(50px);
/* stylelint-disable-next-line plugin/whole-pixel */
outline: 1.5px solid var(--color-background);
}
.picker-checkbox {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid var(--color-borders-input);
border-radius: 50%;
font-size: 1.125rem;
line-height: 1;
color: var(--color-white);
transition: background-color 0.15s, border-color 0.15s;
&.selected {
border-color: var(--color-primary);
background-color: var(--color-primary);
}
}
.picker-checkbox-count {
position: absolute;
right: -0.375rem;
bottom: -0.375rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 0.875rem;
height: 0.875rem;
border: 1px solid var(--color-white);
border-radius: 0.4375rem;
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
line-height: 0.625rem;
color: var(--color-white);
background-color: var(--color-primary);
} }
.topic-icon { .topic-icon {
@ -143,4 +386,84 @@
height: 1rem; height: 1rem;
} }
} }
.picker-footer {
position: absolute;
z-index: 3;
right: 1rem;
bottom: 1rem;
left: 1rem;
padding-top: 1rem;
background:
linear-gradient(
to top,
var(--color-background-secondary) 0%,
var(--color-background-secondary) 70%,
transparent 100%
);
}
.picker-caption-wrapper {
display: flex;
flex: 1;
gap: 0.5rem;
align-items: center;
padding: 0.1875rem;
padding-inline-start: 1rem;
border-radius: 1.5rem;
background-color: var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.picker-caption-input {
flex: 1;
min-width: 0;
height: 2rem;
padding: 0;
border: none;
font-size: 1rem;
line-height: 2rem;
background-color: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.picker-footer-button {
width: 100%;
border-radius: 1.5rem;
font-weight: var(--font-weight-semibold);
}
.picker-footer-input {
display: flex;
}
.picker-send-button {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: auto !important;
min-width: auto !important;
height: 2.625rem;
padding: 0 1.0625rem !important;
border-radius: 1.375rem;
font-size: 2rem;
&:has(.icon-star) {
font-size: 1rem;
}
}
} }

View File

@ -1,4 +1,4 @@
import type { TeactNode } from '../../../lib/teact/teact'; import type { ElementRef, TeactNode } from '../../../lib/teact/teact';
import { import {
memo, useCallback, useEffect, useMemo, useRef, useState, memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
@ -34,14 +34,13 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import Button from '../../ui/Button';
import InfiniteScroll, { type OwnProps as InfiniteScrollProps } from '../../ui/InfiniteScroll'; import InfiniteScroll, { type OwnProps as InfiniteScrollProps } from '../../ui/InfiniteScroll';
import InputText from '../../ui/InputText';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import Avatar from '../Avatar'; import Avatar from '../Avatar';
import FullNameTitle from '../FullNameTitle'; import FullNameTitle from '../FullNameTitle';
import Icon from '../icons/Icon';
import TopicIcon from '../TopicIcon'; import TopicIcon from '../TopicIcon';
import PickerItem from './PickerItem'; import PickerItem from './PickerItem';
@ -51,12 +50,17 @@ export type OwnProps = {
currentUserId?: string; currentUserId?: string;
chatOrUserIds: string[]; chatOrUserIds: string[];
isOpen: boolean; isOpen: boolean;
title?: string;
searchPlaceholder: string; searchPlaceholder: string;
search: string; search: string;
className?: string; className?: string;
isLowStackPriority?: boolean; isLowStackPriority?: boolean;
listActiveKey?: number; listActiveKey?: number;
subheader?: TeactNode; subheader?: TeactNode;
renderSearchRow?: (props: SearchRowRenderProps) => TeactNode;
footer?: TeactNode;
viewportFooter?: TeactNode;
selectedIds?: string[];
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void;
@ -75,16 +79,29 @@ const TOPIC_ICON_SIZE = 2.75 * REM;
const ITEM_CLASS_NAME = 'ChatOrUserPicker-item'; const ITEM_CLASS_NAME = 'ChatOrUserPicker-item';
const TOPIC_ITEM_HEIGHT_PX = 56; const TOPIC_ITEM_HEIGHT_PX = 56;
export type SearchRowRenderProps = {
inputRef: ElementRef<HTMLInputElement>;
value: string;
placeholder: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
};
const ChatOrUserPicker = ({ const ChatOrUserPicker = ({
isOpen, isOpen,
currentUserId, currentUserId,
chatOrUserIds, chatOrUserIds,
title,
search, search,
searchPlaceholder, searchPlaceholder,
className, className,
isLowStackPriority, isLowStackPriority,
subheader, subheader,
renderSearchRow,
footer,
viewportFooter,
listActiveKey, listActiveKey,
selectedIds,
animationLevel, animationLevel,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
loadMore, loadMore,
@ -113,13 +130,13 @@ const ChatOrUserPicker = ({
useInputFocusOnOpen(searchRef, isOpen && activeKey === CHAT_LIST_SLIDE, resetSearch); useInputFocusOnOpen(searchRef, isOpen && activeKey === CHAT_LIST_SLIDE, resetSearch);
useInputFocusOnOpen(topicSearchRef, isOpen && activeKey === TOPIC_LIST_SLIDE); useInputFocusOnOpen(topicSearchRef, isOpen && activeKey === TOPIC_LIST_SLIDE);
const selectTopicsById = useLastCallback((global: GlobalState) => { const selectTopicsById = useCallback((global: GlobalState) => {
if (!forumId) { if (!forumId) {
return undefined; return undefined;
} }
return selectTopics(global, forumId); return selectTopics(global, forumId);
}); }, [forumId]);
const forumTopicsById = useSelector(selectTopicsById); const forumTopicsById = useSelector(selectTopicsById);
@ -190,6 +207,8 @@ const ChatOrUserPicker = ({
} }
}, `.${ITEM_CLASS_NAME}`, true); }, `.${ITEM_CLASS_NAME}`, true);
const isMultiSelect = Boolean(selectedIds);
const handleClick = useLastCallback((chatId: string) => { const handleClick = useLastCallback((chatId: string) => {
const chatsById = getGlobal().chats.byId; const chatsById = getGlobal().chats.byId;
const chat = chatsById?.[chatId]; const chat = chatsById?.[chatId];
@ -214,7 +233,16 @@ const ChatOrUserPicker = ({
peer = monoforumChannel; peer = monoforumChannel;
} }
const chat = global.chats.byId[id];
const isForum = chat?.isForum;
const isSelf = peer && !isApiPeerChat(peer) ? peer.isSelf : undefined; const isSelf = peer && !isApiPeerChat(peer) ? peer.isSelf : undefined;
const isSelected = selectedIds?.includes(id);
const selectedTopicsCount = isForum && selectedIds
? selectedIds.filter((selId) => selId.startsWith(`${id}:`)).length
: 0;
const hasSelectedTopics = selectedTopicsCount > 0;
function getSubtitle() { function getSubtitle() {
if (!peer) return undefined; if (!peer) return undefined;
@ -232,6 +260,15 @@ const ChatOrUserPicker = ({
const [subtitle, subtitleClassName] = getSubtitle() || []; const [subtitle, subtitleClassName] = getSubtitle() || [];
const checkboxElement = selectedIds ? (
<div className={buildClassName('picker-checkbox', (isSelected || hasSelectedTopics) && 'selected')}>
{(isSelected || hasSelectedTopics) && <Icon name="check-bold" />}
{hasSelectedTopics && (
<div className="picker-checkbox-count">{selectedTopicsCount}</div>
)}
</div>
) : undefined;
return ( return (
<PickerItem <PickerItem
key={id} key={id}
@ -247,13 +284,18 @@ const ChatOrUserPicker = ({
</div> </div>
)} )}
avatarElement={( avatarElement={(
<Avatar <div className="picker-avatar-wrapper">
peer={peer} <Avatar
asMessageBubble={Boolean(monoforumChannel)} peer={peer}
isSavedMessages={isSelf} asMessageBubble={Boolean(monoforumChannel)}
size="medium" isSavedMessages={isSelf}
/> size="medium"
/>
{isForum && <Icon name="forums" className="forum-badge" />}
</div>
)} )}
inputElement={checkboxElement}
inputPosition="end"
subtitle={subtitle} subtitle={subtitle}
subtitleClassName={subtitleClassName} subtitleClassName={subtitleClassName}
ripple ripple
@ -262,29 +304,33 @@ const ChatOrUserPicker = ({
onClick={() => handleClick(id)} onClick={() => handleClick(id)}
/> />
); );
}, [currentUserId, oldLang, lang, viewportOffset]); }, [currentUserId, oldLang, lang, viewportOffset, selectedIds]);
function renderTopicList() { function renderTopicList() {
return ( return (
<> <>
<div className="modal-header modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}> <div className="picker-header" dir={lang.isRtl ? 'rtl' : undefined}>
<div className="search-wrapper"> {renderSearchRow ? renderSearchRow({
<Button inputRef: topicSearchRef,
round value: topicSearch,
color="translucent" placeholder: searchPlaceholder,
size="tiny" onChange: handleTopicSearchChange,
ariaLabel={oldLang('Back')} onKeyDown: handleTopicKeyDown,
onClick={handleHeaderBackClick} }) : (
iconName="arrow-left" <div className="search-input-wrapper">
/> <i className="icon icon-search" />
<InputText <input
ref={topicSearchRef} ref={topicSearchRef}
value={topicSearch} className="search-input"
onChange={handleTopicSearchChange} type="text"
onKeyDown={handleTopicKeyDown} dir="auto"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
/> value={topicSearch}
</div> onChange={handleTopicSearchChange}
onKeyDown={handleTopicKeyDown}
/>
</div>
)}
</div> </div>
{topicIds?.length ? ( {topicIds?.length ? (
<InfiniteScroll <InfiniteScroll
@ -295,29 +341,51 @@ const ChatOrUserPicker = ({
maxHeight={(topicIds?.length || 0) * TOPIC_ITEM_HEIGHT_PX} maxHeight={(topicIds?.length || 0) * TOPIC_ITEM_HEIGHT_PX}
onKeyDown={handleTopicKeyDown} onKeyDown={handleTopicKeyDown}
> >
{topicIds.map((topicId, i) => ( {topicIds.map((topicId, i) => {
<PickerItem const selectionId = `${forumId}:${topicId}`;
key={`${forumId}_${topicId}`} const isTopicSelected = selectedIds?.includes(selectionId);
className={ITEM_CLASS_NAME}
onClick={() => onSelectChatOrUser(forumId!, topicId)} const topicCheckboxElement = isMultiSelect ? (
style={`top: ${i * TOPIC_ITEM_HEIGHT_PX}px;`} <div className={buildClassName('picker-checkbox', isTopicSelected && 'selected')}>
avatarElement={( {isTopicSelected && <Icon name="check-bold" />}
<TopicIcon </div>
size={TOPIC_ICON_SIZE} ) : undefined;
topic={topics[topicId]}
className="topic-icon" return (
letterClassName="topic-icon-letter" <PickerItem
/> key={`${forumId}_${topicId}`}
)} className={ITEM_CLASS_NAME}
title={renderText(topics[topicId].title)} onClick={() => onSelectChatOrUser(forumId!, topicId)}
style={`top: ${i * TOPIC_ITEM_HEIGHT_PX}px;`}
avatarElement={(
<div className="picker-avatar-wrapper">
<TopicIcon
size={TOPIC_ICON_SIZE}
topic={topics[topicId]}
className="topic-icon"
letterClassName="topic-icon-letter"
/>
</div>
)}
title={renderText(topics[topicId].title)}
inputElement={topicCheckboxElement}
inputPosition="end"
/>
);
})}
{Boolean(viewportFooter) && (
<div
className="picker-list-spacer"
style={`top: ${topicIds.length * TOPIC_ITEM_HEIGHT_PX}px`}
/> />
))} )}
</InfiniteScroll> </InfiniteScroll>
) : topicIds && !topicIds.length ? ( ) : topicIds && !topicIds.length ? (
<p className="no-results">{lang('NothingFound')}</p> <p className="no-results">{lang('NothingFound')}</p>
) : ( ) : (
<Loading /> <div className="picker-list picker-list-loading">
<Loading />
</div>
)} )}
</> </>
); );
@ -326,24 +394,28 @@ const ChatOrUserPicker = ({
function renderChatList() { function renderChatList() {
return ( return (
<> <>
<div className="modal-header modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}> <div className="picker-header" dir={lang.isRtl ? 'rtl' : undefined}>
<div className="search-wrapper"> {renderSearchRow ? renderSearchRow({
<Button inputRef: searchRef,
round value: search,
color="translucent" placeholder: searchPlaceholder,
size="tiny" onChange: handleSearchChange,
ariaLabel={oldLang('Close')} onKeyDown: chatKeyDownHandler,
onClick={onClose} }) : (
iconName="close" <div className="search-input-wrapper">
/> <i className="icon icon-search" />
<InputText <input
ref={searchRef} ref={searchRef}
value={search} className="search-input"
onChange={handleSearchChange} type="text"
onKeyDown={chatKeyDownHandler} dir="auto"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
/> value={search}
</div> onChange={handleSearchChange}
onKeyDown={chatKeyDownHandler}
/>
</div>
)}
{subheader} {subheader}
</div> </div>
<Transition <Transition
@ -355,6 +427,7 @@ const ChatOrUserPicker = ({
isOpen={isOpen} isOpen={isOpen}
viewportIds={viewportIds} viewportIds={viewportIds}
maxHeight={chatOrUserIds.length * PEER_PICKER_ITEM_HEIGHT_PX} maxHeight={chatOrUserIds.length * PEER_PICKER_ITEM_HEIGHT_PX}
viewportFooter={viewportFooter}
onLoadMore={getMore} onLoadMore={getMore}
onSelect={handleChatSelect} onSelect={handleChatSelect}
renderItem={renderChatItem} renderItem={renderChatItem}
@ -365,12 +438,24 @@ const ChatOrUserPicker = ({
); );
} }
const handleModalClose = useLastCallback(() => {
if (forumId) {
handleHeaderBackClick();
} else {
onClose();
}
});
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
title={title}
hasCloseButton
isBackButton={Boolean(forumId)}
headerClassName="modal-header-condensed-wide"
className={buildClassName('ChatOrUserPicker', className)} className={buildClassName('ChatOrUserPicker', className)}
isLowStackPriority={isLowStackPriority} isLowStackPriority={isLowStackPriority}
onClose={onClose} onClose={handleModalClose}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
> >
<Transition activeKey={activeKey} name="slideFade" slideClassName="ChatOrUserPicker_slide"> <Transition activeKey={activeKey} name="slideFade" slideClassName="ChatOrUserPicker_slide">
@ -378,6 +463,7 @@ const ChatOrUserPicker = ({
return activeKey === TOPIC_LIST_SLIDE ? renderTopicList() : renderChatList(); return activeKey === TOPIC_LIST_SLIDE ? renderTopicList() : renderChatList();
}} }}
</Transition> </Transition>
{footer}
</Modal> </Modal>
); );
}; };
@ -386,6 +472,7 @@ type ChatListContentProps = {
isOpen: boolean; isOpen: boolean;
viewportIds?: string[]; viewportIds?: string[];
maxHeight: number; maxHeight: number;
viewportFooter?: TeactNode;
onLoadMore: InfiniteScrollProps['onLoadMore']; onLoadMore: InfiniteScrollProps['onLoadMore'];
onSelect: (index: number) => void; onSelect: (index: number) => void;
renderItem: (id: string, index: number) => TeactNode; renderItem: (id: string, index: number) => TeactNode;
@ -396,6 +483,7 @@ function ChatListContent({
isOpen, isOpen,
viewportIds, viewportIds,
maxHeight, maxHeight,
viewportFooter,
onLoadMore, onLoadMore,
onSelect, onSelect,
onKeyDownHandlerUpdate, onKeyDownHandlerUpdate,
@ -424,6 +512,9 @@ function ChatListContent({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
{viewportIds.map(renderItem)} {viewportIds.map(renderItem)}
{Boolean(viewportFooter) && (
<div className="picker-list-spacer" style={`top: ${maxHeight}px`} />
)}
</InfiniteScroll> </InfiniteScroll>
) : viewportIds && !viewportIds.length ? ( ) : viewportIds && !viewportIds.length ? (
<p className="no-results">{lang('NothingFound')}</p> <p className="no-results">{lang('NothingFound')}</p>

View File

@ -0,0 +1,110 @@
.root {
overflow: hidden;
padding: 0.3125rem 0;
border-radius: 1.5rem;
background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
}
.scrollContainer {
scrollbar-width: none;
overflow-x: auto;
overflow-y: hidden;
display: flex;
gap: 1rem;
padding-inline: 0.5625rem;
white-space: nowrap;
&::-webkit-scrollbar {
display: none;
}
}
.item {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.375rem;
padding-top: 0.5rem;
padding-bottom: 0.375rem;
padding-inline: 0.25rem;
border-radius: 0.75rem;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-chat-hover);
}
&:active {
background-color: var(--color-item-active);
}
}
.avatarWrapper {
position: relative;
margin-bottom: 0.3125rem;
}
.checkmark {
position: absolute;
z-index: 1;
right: -0.125rem;
bottom: -0.125rem;
transform: scale(0);
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-background);
border-radius: 50%;
font-size: 0.75rem;
color: var(--color-white);
opacity: 0;
background-color: var(--color-primary);
transition: transform 0.15s, opacity 0.15s, border-color 0.15s;
.item:hover & {
border-color: var(--color-item-active);
}
}
.name {
overflow: hidden;
width: 100%;
font-size: 0.75rem;
line-height: 1rem;
color: var(--color-text);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.15s;
}
.selected {
.name {
color: var(--color-primary);
}
.checkmark {
transform: scale(1);
opacity: 1;
}
}

View File

@ -0,0 +1,88 @@
import { memo, useRef } from '../../../lib/teact/teact';
import { getGlobal } from '../../../global';
import { getPeerTitle } from '../../../global/helpers/peers';
import { selectPeer } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import renderText from '../helpers/renderText';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Avatar from '../Avatar';
import Icon from '../icons/Icon';
import styles from './PickerRecentContacts.module.scss';
type OwnProps = {
contactIds: string[];
currentUserId?: string;
selectedIds?: string[];
className?: string;
onSelect: (id: string) => void;
};
const PickerRecentContacts = ({
contactIds,
currentUserId,
selectedIds,
className,
onSelect,
}: OwnProps) => {
const lang = useLang();
const containerRef = useRef<HTMLDivElement>();
useHorizontalScroll(containerRef, !contactIds.length);
const handleClick = useLastCallback((id: string) => {
onSelect(id);
});
// Current user (Saved Messages) goes first, then contacts
const displayIds = currentUserId
? [currentUserId, ...contactIds.filter((id) => id !== currentUserId)]
: contactIds;
if (!displayIds.length) {
return undefined;
}
return (
<div className={buildClassName(styles.root, className)} dir={lang.isRtl ? 'rtl' : undefined}>
<div ref={containerRef} className={styles.scrollContainer}>
{displayIds.map((peerId) => {
const global = getGlobal();
const peer = selectPeer(global, peerId);
if (!peer) return undefined;
const isSelf = peerId === currentUserId;
const isSelected = selectedIds?.includes(peerId);
const name = isSelf ? lang('SavedMessagesShort') : getPeerTitle(lang, peer);
return (
<div
key={peerId}
className={buildClassName(styles.item, isSelected && styles.selected)}
onClick={() => handleClick(peerId)}
>
<div className={styles.avatarWrapper}>
<Avatar
peer={peer}
isSavedMessages={isSelf}
size={48}
/>
<div className={styles.checkmark}>
<Icon name="check-bold" />
</div>
</div>
<div className={styles.name}>{renderText(name || lang('ActionFallbackSomeone'))}</div>
</div>
);
})}
</div>
</div>
);
};
export default memo(PickerRecentContacts);

View File

@ -26,7 +26,7 @@ import useScrolledState from '../../../hooks/useScrolledState';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import StoryRibbon from '../../story/StoryRibbon'; import StoryRibbon from '../../story/StoryRibbon';
import TabList from '../../ui/TabList'; import SquareTabList from '../../ui/SquareTabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import ChatList from './ChatList'; import ChatList from './ChatList';
@ -259,7 +259,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
> >
{shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />} {shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />}
{shouldRenderFolders ? ( {shouldRenderFolders ? (
<TabList <SquareTabList
contextRootElementSelector="#LeftColumn" contextRootElementSelector="#LeftColumn"
tabs={folderTabs} tabs={folderTabs}
activeTab={activeChatFolder} activeTab={activeChatFolder}

View File

@ -30,7 +30,7 @@
} }
} }
.TabList { .SquareTabList {
z-index: 1; z-index: 1;
justify-content: flex-start; justify-content: flex-start;
@ -48,7 +48,7 @@
} }
} }
&--tabs-hidden .TabList { &--tabs-hidden .SquareTabList {
pointer-events: none; pointer-events: none;
opacity: 0.25; opacity: 0.25;
} }

View File

@ -112,7 +112,7 @@ const FileResults: FC<OwnProps & StateProps> = ({
document={getMessageDocument(message)!} document={getMessageDocument(message)!}
message={message} message={message}
datetime={message.date} datetime={message.date}
smaller fileSize="small"
sender={getSenderName(lang, message, chatsById, usersById)} sender={getSenderName(lang, message, chatsById, usersById)}
className="scroll-item" className="scroll-item"
isDownloading={getIsDownloading(activeDownloads, message.content.document!)} isDownloading={getIsDownloading(activeDownloads, message.content.document!)}

View File

@ -11,7 +11,7 @@
flex: 1; flex: 1;
} }
.TabList { .SquareTabList {
z-index: 1; z-index: 1;
} }

View File

@ -21,7 +21,7 @@ import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation'
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import TabList from '../../ui/TabList'; import SquareTabList from '../../ui/SquareTabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import AudioResults from './AudioResults'; import AudioResults from './AudioResults';
import BotAppResults from './BotAppResults'; import BotAppResults from './BotAppResults';
@ -122,7 +122,7 @@ const LeftSearch: FC<OwnProps & StateProps> = ({
return ( return (
<div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}> <div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}>
<TabList activeTab={activeTab} tabs={tabs} onSwitchTab={handleSwitchTab} /> <SquareTabList activeTab={activeTab} tabs={tabs} onSwitchTab={handleSwitchTab} />
<Transition <Transition
name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)} name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
renderCount={tabs.length} renderCount={tabs.length}

View File

@ -12,7 +12,7 @@ import { filterPeersByQuery } from '../../../global/helpers/peers';
import { selectTabState } from '../../../global/selectors'; import { selectTabState } from '../../../global/selectors';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
import useOldLang from '../../../hooks/useOldLang'; import useLang from '../../../hooks/useLang';
import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker'; import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker';
@ -43,7 +43,7 @@ const BlockUserModal: FC<OwnProps & StateProps> = ({
blockUser, blockUser,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useLang();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
useEffect(() => { useEffect(() => {
@ -76,7 +76,8 @@ const BlockUserModal: FC<OwnProps & StateProps> = ({
<ChatOrUserPicker <ChatOrUserPicker
isOpen={isOpen} isOpen={isOpen}
chatOrUserIds={filteredContactIds} chatOrUserIds={filteredContactIds}
searchPlaceholder={lang('BlockedUsers.BlockUser')} title={lang('BlockedUsersBlockUser')}
searchPlaceholder={lang('Search')}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
onSelectChatOrUser={handleRemoveUser} onSelectChatOrUser={handleRemoveUser}

View File

@ -5,7 +5,7 @@ import { getActions } from '../../global';
import type { TabState } from '../../global/types'; import type { TabState } from '../../global/types';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useOldLang from '../../hooks/useOldLang'; import useLang from '../../hooks/useLang';
import RecipientPicker from '../common/RecipientPicker'; import RecipientPicker from '../common/RecipientPicker';
@ -17,7 +17,7 @@ const AttachBotRecipientPicker: FC<OwnProps> = ({
requestedAttachBotInChat, requestedAttachBotInChat,
}) => { }) => {
const { cancelAttachBotInChat, callAttachBot } = getActions(); const { cancelAttachBotInChat, callAttachBot } = getActions();
const lang = useOldLang(); const lang = useLang();
const isOpen = Boolean(requestedAttachBotInChat); const isOpen = Boolean(requestedAttachBotInChat);
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
@ -41,6 +41,7 @@ const AttachBotRecipientPicker: FC<OwnProps> = ({
return ( return (
<RecipientPicker <RecipientPicker
isOpen={isOpen} isOpen={isOpen}
title={lang('SelectChat')}
searchPlaceholder={lang('Search')} searchPlaceholder={lang('Search')}
filter={filter} filter={filter}
onSelectRecipient={handlePeerRecipient} onSelectRecipient={handlePeerRecipient}

View File

@ -8,7 +8,7 @@ import type { TabState } from '../../global/types';
import type { ThreadId } from '../../types'; import type { ThreadId } from '../../types';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useOldLang from '../../hooks/useOldLang'; import useLang from '../../hooks/useLang';
import RecipientPicker from '../common/RecipientPicker'; import RecipientPicker from '../common/RecipientPicker';
@ -25,7 +25,7 @@ const DraftRecipientPicker: FC<OwnProps> = ({
resetOpenChatWithDraft, resetOpenChatWithDraft,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useLang();
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
useEffect(() => { useEffect(() => {
@ -54,7 +54,8 @@ const DraftRecipientPicker: FC<OwnProps> = ({
return ( return (
<RecipientPicker <RecipientPicker
isOpen={isOpen} isOpen={isOpen}
searchPlaceholder={lang('ForwardTo')} title={lang('ShareWith')}
searchPlaceholder={lang('Search')}
filter={requestedDraft?.filter} filter={requestedDraft?.filter}
onSelectRecipient={handleSelectRecipient} onSelectRecipient={handleSelectRecipient}
onClose={handleClose} onClose={handleClose}

View File

@ -0,0 +1,29 @@
.buttonLayer {
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.composerLayer {
pointer-events: none;
position: absolute;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.visible {
pointer-events: auto;
opacity: 1;
}
.buttonSlide {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import { import {
memo, useCallback, useEffect, memo, useCallback, useEffect, useMemo, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
@ -8,17 +8,33 @@ import type { ThreadId } from '../../types';
import { getChatTitle, getUserFirstOrLastName } from '../../global/helpers'; import { getChatTitle, getUserFirstOrLastName } from '../../global/helpers';
import { import {
selectCanCopyMessageLink,
selectChat, selectChat,
selectChatMessages,
selectPeerPaidMessagesStars,
selectTabState, selectTabState,
selectUser, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { isUserId } from '../../util/entities/ids'; import { isUserId } from '../../util/entities/ids';
import { formatStarsAsIcon, formatStarsAsText } from '../../util/localization/format';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useFrozenProps from '../../hooks/useFrozenProps';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import AnimatedCounter from '../common/AnimatedCounter';
import Icon from '../common/icons/Icon';
import RecipientPicker from '../common/RecipientPicker'; import RecipientPicker from '../common/RecipientPicker';
import Button from '../ui/Button';
import Checkbox from '../ui/Checkbox';
import ConfirmDialog from '../ui/ConfirmDialog';
import Transition from '../ui/Transition';
import styles from './ForwardRecipientPicker.module.scss';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
@ -29,6 +45,9 @@ interface StateProps {
isManyMessages?: boolean; isManyMessages?: boolean;
isStory?: boolean; isStory?: boolean;
isForwarding?: boolean; isForwarding?: boolean;
fromChatId?: string;
forwardMessageIds?: number[];
shouldPaidMessageAutoApprove?: boolean;
} }
const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
@ -37,26 +56,80 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
isManyMessages, isManyMessages,
isStory, isStory,
isForwarding, isForwarding,
fromChatId,
forwardMessageIds,
shouldPaidMessageAutoApprove,
}) => { }) => {
const { const {
openChatOrTopicWithReplyInDraft, openChatOrTopicWithReplyInDraft,
setForwardChatOrTopic, setForwardChatOrTopic,
exitForwardMode, exitForwardMode,
forwardToSavedMessages, forwardToSavedMessages,
forwardToMultipleChats,
forwardStory, forwardStory,
showNotification, showNotification,
copyMessageLink,
openStarsBalanceModal,
setPaidMessageAutoApprove,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useLang();
const oldLang = useOldLang();
const renderingIsStory = usePreviousDeprecated(isStory, true); const renderingIsStory = usePreviousDeprecated(isStory, true);
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [caption, setCaption] = useState('');
const [isPaymentConfirmOpen, openPaymentConfirm, closePaymentConfirm] = useFlag();
const [shouldAutoApprove, setShouldAutoApprove] = useState(shouldPaidMessageAutoApprove);
const isMultiSelect = isForwarding && !isStory;
const messageCount = forwardMessageIds?.length || 0;
const paidChatsInfo = useMemo(() => {
if (!selectedIds.length) return { paidChatsCount: 0, totalStars: 0, totalMessages: 0 };
const global = getGlobal();
let paidChatsCount = 0;
let totalStars = 0;
const hasCaption = caption.trim().length > 0;
const totalMessages = messageCount + (hasCaption ? 1 : 0);
for (const chatId of selectedIds) {
const paidStars = selectPeerPaidMessagesStars(global, chatId);
if (paidStars) {
paidChatsCount++;
totalStars += paidStars * totalMessages;
}
}
return { paidChatsCount, totalStars, totalMessages };
}, [selectedIds, messageCount, caption]);
const canCopyLink = useMemo(() => {
if (!fromChatId || forwardMessageIds?.length !== 1) return false;
const global = getGlobal();
const chatMessages = selectChatMessages(global, fromChatId);
if (!chatMessages) return false;
const message = chatMessages[forwardMessageIds[0]];
return message && selectCanCopyMessageLink(global, message);
}, [fromChatId, forwardMessageIds]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
markIsShown(); markIsShown();
} }
}, [isOpen, markIsShown]); }, [isOpen, markIsShown]);
useEffect(() => {
if (!isOpen) {
setSelectedIds([]);
setCaption('');
}
}, [isOpen]);
const handleSelectRecipient = useCallback((recipientId: string, threadId?: ThreadId) => { const handleSelectRecipient = useCallback((recipientId: string, threadId?: ThreadId) => {
const isSelf = recipientId === currentUserId; const isSelf = recipientId === currentUserId;
if (isStory) { if (isStory) {
@ -65,8 +138,8 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (isUserId(recipientId)) { if (isUserId(recipientId)) {
showNotification({ showNotification({
message: isSelf message: isSelf
? lang('Conversation.StoryForwardTooltip.SavedMessages.One') ? oldLang('Conversation.StoryForwardTooltip.SavedMessages.One')
: lang( : oldLang(
'StorySharedTo', 'StorySharedTo',
getUserFirstOrLastName(selectUser(global, recipientId)), getUserFirstOrLastName(selectUser(global, recipientId)),
), ),
@ -76,14 +149,14 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (!chat) return; if (!chat) return;
showNotification({ showNotification({
message: lang('StorySharedTo', getChatTitle(lang, chat)), message: oldLang('StorySharedTo', getChatTitle(oldLang, chat)),
}); });
} }
return; return;
} }
if (isSelf) { if (isSelf) {
const message = lang( const message = oldLang(
isManyMessages isManyMessages
? 'Conversation.ForwardTooltip.SavedMessages.Many' ? 'Conversation.ForwardTooltip.SavedMessages.Many'
: 'Conversation.ForwardTooltip.SavedMessages.One', : 'Conversation.ForwardTooltip.SavedMessages.One',
@ -100,37 +173,239 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
openChatOrTopicWithReplyInDraft({ chatId, topicId }); openChatOrTopicWithReplyInDraft({ chatId, topicId });
} }
} }
}, [currentUserId, isManyMessages, isStory, lang, isForwarding]); }, [currentUserId, isManyMessages, isStory, oldLang, isForwarding]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
exitForwardMode(); exitForwardMode();
}, [exitForwardMode]); }, [exitForwardMode]);
const handleSelectedIdsChange = useLastCallback((ids: string[]) => {
setSelectedIds(ids);
});
const handleCopyLink = useLastCallback(() => {
if (!fromChatId || !forwardMessageIds?.length) return;
copyMessageLink({
chatId: fromChatId,
messageId: forwardMessageIds[0],
});
exitForwardMode();
});
const handleForwardToMultiple = useLastCallback(() => {
if (!selectedIds.length) return;
if (selectedIds.length === 1) {
setForwardChatOrTopic({ chatId: selectedIds[0] });
return;
}
if (paidChatsInfo.totalStars > 0 && !shouldPaidMessageAutoApprove) {
openPaymentConfirm();
return;
}
if (paidChatsInfo.totalStars > 0) {
const starsBalance = getGlobal().stars?.balance?.amount || 0;
if (paidChatsInfo.totalStars > starsBalance) {
openStarsBalanceModal({
topup: {
balanceNeeded: paidChatsInfo.totalStars,
},
});
return;
}
}
executeForward();
});
const executeForward = useLastCallback(() => {
forwardToMultipleChats({ toChatIds: selectedIds, comment: caption || undefined });
showNotification({
message: lang('FwdMessagesToChats', { count: selectedIds.length }, { pluralValue: selectedIds.length }),
});
exitForwardMode();
});
const handlePaymentConfirm = useLastCallback(() => {
const { totalStars } = paidChatsInfo;
const starsBalance = getGlobal().stars?.balance?.amount || 0;
if (totalStars > starsBalance) {
openStarsBalanceModal({
topup: {
balanceNeeded: totalStars,
},
});
return;
}
closePaymentConfirm();
if (shouldAutoApprove) {
setPaidMessageAutoApprove();
}
executeForward();
});
const viewportFooter = useMemo(() => (
<div className="picker-list-spacer" />
), []);
const selectedCount = selectedIds.length;
const showComposer = selectedCount >= 2;
const { totalStars: displayedTotalStars } = useFrozenProps(
{ totalStars: paidChatsInfo.totalStars },
!showComposer,
);
const footerContent = useMemo(() => {
if (!isForwarding || isStory) return undefined;
const renderButton = () => {
const isInitial = selectedCount === 0;
const singleChatStars = selectedCount === 1 ? paidChatsInfo.totalStars : 0;
return (
<Button
className="picker-footer-button"
color="primary"
disabled={isInitial && !canCopyLink}
onClick={isInitial ? handleCopyLink : handleForwardToMultiple}
>
<Transition name="fade" activeKey={isInitial ? 0 : 1} slideClassName={styles.buttonSlide}>
<span>
{isInitial
? (canCopyLink ? oldLang('CopyLink') : lang('SelectChats'))
: (singleChatStars > 0
? lang(
'ForwardForStars',
{ price: formatStarsAsIcon(lang, singleChatStars, { asFont: true }) },
{ withNodes: true },
)
: lang('Forward'))}
</span>
</Transition>
</Button>
);
};
const renderComposer = () => (
<div className="picker-footer-input">
<div className="picker-caption-wrapper">
<input
className="picker-caption-input"
type="text"
value={caption}
onChange={(e) => setCaption(e.currentTarget.value)}
placeholder={lang('AttachmentCaptionPlaceholder')}
/>
<Button
className="picker-send-button"
color="primary"
onClick={handleForwardToMultiple}
ariaLabel={lang('Forward')}
>
{displayedTotalStars > 0 ? (
<>
<Icon name="star" className="star-icon" />
<AnimatedCounter text={String(displayedTotalStars)} />
</>
) : <i className="icon icon-new-send" />}
</Button>
</div>
</div>
);
return (
<div className="picker-footer">
<div className={buildClassName(styles.buttonLayer, !showComposer && styles.visible)}>
{renderButton()}
</div>
<div className={buildClassName(styles.composerLayer, showComposer && styles.visible)}>
{renderComposer()}
</div>
</div>
);
}, [isForwarding, isStory, selectedCount, showComposer, caption, canCopyLink, displayedTotalStars,
paidChatsInfo, handleForwardToMultiple, handleCopyLink, lang, oldLang]);
if (!isOpen && !isShown) { if (!isOpen && !isShown) {
return undefined; return undefined;
} }
const confirmPaymentMessage = paidChatsInfo.totalStars > 0 ? lang(
'ForwardPaidChatsConfirmation',
{
chatsSelected: lang(
'ForwardPaidChatsSelected',
{ paidChatsCount: paidChatsInfo.paidChatsCount },
{ withNodes: true, withMarkdown: true, pluralValue: paidChatsInfo.paidChatsCount },
),
payConfirmation: lang(
'ForwardPaidChatsPayConfirmation',
{
totalAmount: formatStarsAsText(lang, paidChatsInfo.totalStars),
count: paidChatsInfo.totalMessages,
},
{ withNodes: true, withMarkdown: true, pluralValue: paidChatsInfo.totalMessages },
),
},
{ withNodes: true },
) : undefined;
const confirmLabel = lang('PayForMessage', { count: paidChatsInfo.totalMessages }, {
withNodes: true,
pluralValue: paidChatsInfo.totalMessages,
});
return ( return (
<RecipientPicker <>
isOpen={isOpen} <RecipientPicker
className={renderingIsStory ? 'component-theme-dark' : undefined} isOpen={isOpen}
searchPlaceholder={lang(isForwarding ? 'ForwardTo' : 'ReplyToDialog')} className={renderingIsStory ? 'component-theme-dark' : undefined}
onSelectRecipient={handleSelectRecipient} title={lang('ShareWith')}
onClose={handleClose} searchPlaceholder={lang('Search')}
onCloseAnimationEnd={unmarkIsShown} isMultiSelect={isMultiSelect}
isForwarding={isForwarding} footer={footerContent}
withFolders viewportFooter={viewportFooter}
/> onSelectRecipient={handleSelectRecipient}
onSelectedIdsChange={handleSelectedIdsChange}
onClose={handleClose}
onCloseAnimationEnd={unmarkIsShown}
isForwarding={isForwarding}
withFolders
/>
<ConfirmDialog
title={lang('TitleConfirmPayment')}
confirmLabel={confirmLabel}
isOpen={isPaymentConfirmOpen}
onClose={closePaymentConfirm}
confirmHandler={handlePaymentConfirm}
>
{confirmPaymentMessage}
<Checkbox
label={lang('DoNotAskAgain')}
checked={shouldAutoApprove}
onCheck={setShouldAutoApprove}
/>
</ConfirmDialog>
</>
); );
}; };
export default memo(withGlobal<OwnProps>((global): Complete<StateProps> => { export default memo(withGlobal<OwnProps>((global): Complete<StateProps> => {
const { messageIds, storyId } = selectTabState(global).forwardMessages; const { messageIds, storyId, fromChatId } = selectTabState(global).forwardMessages;
const isForwarding = (messageIds && messageIds.length > 0); const isForwarding = (messageIds && messageIds.length > 0);
return { return {
currentUserId: global.currentUserId, currentUserId: global.currentUserId,
isManyMessages: (messageIds?.length || 0) > 1, isManyMessages: (messageIds?.length || 0) > 1,
isStory: Boolean(storyId), isStory: Boolean(storyId),
isForwarding, isForwarding,
fromChatId,
forwardMessageIds: messageIds,
shouldPaidMessageAutoApprove: global.settings.byKey.shouldPaidMessageAutoApprove,
}; };
})(ForwardRecipientPicker)); })(ForwardRecipientPicker));

View File

@ -4,13 +4,15 @@
:global { :global {
.modal-dialog { .modal-dialog {
max-width: 26.25rem; max-width: 26.25rem;
border-radius: 2.5rem;
background-color: var(--color-background-secondary);
@media (max-width: 600px) { @media (max-width: 600px) {
max-height: 100%; max-height: 100%;
} }
} }
.modal-header-condensed { .modal-header-condensed-wide {
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
transition: border-color 250ms ease-in-out; transition: border-color 250ms ease-in-out;
} }
@ -32,15 +34,16 @@
flex-shrink: 0; flex-shrink: 0;
align-self: flex-end; align-self: flex-end;
width: 3.5rem !important; width: 3rem !important;
height: 3.5rem !important; height: 3rem !important;
padding: 0 !important; padding: 0 !important;
background: none !important; background: none !important;
} }
.symbol-menu-button, .mobile-symbol-menu-button { .symbol-menu-button, .mobile-symbol-menu-button {
margin-right: -1.75rem; align-self: center;
margin-right: -1.5rem;
margin-left: -0.25rem !important; margin-left: -0.25rem !important;
color: var(--color-composer-button); color: var(--color-composer-button);
} }
@ -59,12 +62,8 @@
&.mobile :global { &.mobile :global {
.modal-dialog { .modal-dialog {
align-self: end;
max-width: 100% !important; max-width: 100% !important;
margin: 0; margin: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
} }
} }
@ -80,37 +79,49 @@
transform: translate3d(0, calc((var(--symbol-menu-height) - env(safe-area-inset-bottom)) * -1), 0); transform: translate3d(0, calc((var(--symbol-menu-height) - env(safe-area-inset-bottom)) * -1), 0);
} }
} }
&.header-border :global(.modal-header-condensed) {
border-bottom-color: var(--color-borders);
}
} }
.attachments { .attachments {
scrollbar-width: none;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-shrink: 1; flex-shrink: 1;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.125rem;
min-height: 5rem; min-height: 5rem;
max-height: 26rem; max-height: 26rem;
margin: 0 0.25rem; margin: 0 1rem;
padding: 0 0.25rem; border-radius: 1.5rem;
&::-webkit-scrollbar {
display: none;
}
&.asFile {
gap: 0;
padding-block: 0.5rem;
background-color: var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
@media (max-width: 600px) { @media (max-width: 600px) {
max-height: 80vh; max-height: 80vh;
} }
} }
.attachments-bottom-padding {
padding-bottom: 0.5rem;
}
.caption-wrapper { .caption-wrapper {
position: relative; position: relative;
padding: 0 0.5rem;
border-top: 1px solid transparent; margin: 1rem;
padding: 0 0.25rem;
padding-inline-start: 0.5rem;
border-radius: 1.5rem;
background-color: var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
transition: border-color 250ms ease-in-out; transition: border-color 250ms ease-in-out;
:global { :global {
@ -147,13 +158,14 @@
position: absolute; position: absolute;
z-index: 2; z-index: 2;
left: 0.5rem; left: 1rem;
overflow: visible !important; overflow: visible !important;
width: calc(100% - 1rem); width: calc(100% - 2rem);
height: calc(100% - 0.5rem); height: calc(100% - 1.125rem);
padding-top: 0.5rem; padding-top: 0.0625rem;
border-radius: 0.25rem;
opacity: 0; opacity: 0;
background-color: var(--color-background); background-color: var(--color-background);
@ -207,12 +219,20 @@
.send-wrapper { .send-wrapper {
position: relative; position: relative;
align-self: flex-end;
padding-bottom: 0.5rem;
} }
.send { .send {
padding: 0 1rem !important; display: flex;
height: 2.625rem;
padding: 0 1.0625rem !important;
border-radius: 1.375rem;
font-size: 1rem;
}
.sendIcon {
font-size: 2rem;
} }
:global { :global {

View File

@ -9,7 +9,6 @@ import type { Signal } from '../../../util/signals';
import { import {
BASE_EMOJI_KEYWORD_LANG, BASE_EMOJI_KEYWORD_LANG,
EDITABLE_INPUT_MODAL_ID, EDITABLE_INPUT_MODAL_ID,
GIF_MIME_TYPE,
SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
@ -39,7 +38,6 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
import useResizeObserver from '../../../hooks/useResizeObserver'; import useResizeObserver from '../../../hooks/useResizeObserver';
import useScrolledState from '../../../hooks/useScrolledState';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import useEmojiTooltip from './hooks/useEmojiTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionTooltip from './hooks/useMentionTooltip'; import useMentionTooltip from './hooks/useMentionTooltip';
@ -197,14 +195,6 @@ const AttachmentModal = ({
); );
const [renderingShouldSendInHighQuality, setRenderingShouldSendInHighQuality] = useState(shouldSendInHighQuality); const [renderingShouldSendInHighQuality, setRenderingShouldSendInHighQuality] = useState(shouldSendInHighQuality);
const {
handleScroll: handleAttachmentsScroll,
isAtBeginning: areAttachmentsNotScrolled,
isAtEnd: areAttachmentsScrolledToBottom,
} = useScrolledState();
const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState();
const isOpen = Boolean(attachments.length); const isOpen = Boolean(attachments.length);
const renderingIsOpen = Boolean(renderingAttachments?.length); const renderingIsOpen = Boolean(renderingAttachments?.length);
const [isHovered, markHovered, unmarkHovered] = useFlag(); const [isHovered, markHovered, unmarkHovered] = useFlag();
@ -520,14 +510,13 @@ const AttachmentModal = ({
const isQuickGallery = isSendingCompressed && hasOnlyMedia; const isQuickGallery = isSendingCompressed && hasOnlyMedia;
const { const {
areAllPhotos, areAllVideos, areAllAudios, areAllGifs, hasAnyPhoto, areAllPhotos, areAllVideos, areAllAudios, hasAnyPhoto,
} = useMemo(() => { } = useMemo(() => {
if (!isQuickGallery || !renderingAttachments) { if (!isQuickGallery || !renderingAttachments) {
return { return {
areAllPhotos: false, areAllPhotos: false,
areAllVideos: false, areAllVideos: false,
areAllAudios: false, areAllAudios: false,
areAllGifs: false,
hasAnyPhoto: false, hasAnyPhoto: false,
}; };
} }
@ -535,7 +524,6 @@ const AttachmentModal = ({
areAllPhotos: renderingAttachments.every((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)), areAllPhotos: renderingAttachments.every((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)),
areAllVideos: renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType)), areAllVideos: renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType)),
areAllAudios: renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)), areAllAudios: renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)),
areAllGifs: renderingAttachments.every((a) => a.gif || a.mimeType === GIF_MIME_TYPE),
hasAnyPhoto: renderingAttachments.some((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)), hasAnyPhoto: renderingAttachments.some((a) => SUPPORTED_PHOTO_CONTENT_TYPES.has(a.mimeType)),
}; };
}, [renderingAttachments, isQuickGallery]); }, [renderingAttachments, isQuickGallery]);
@ -575,10 +563,7 @@ const AttachmentModal = ({
let title = ''; let title = '';
const attachmentsLength = renderingAttachments.length; const attachmentsLength = renderingAttachments.length;
if (areAllPhotos) {
if (areAllGifs) {
title = lang(isEditing ? 'AttachmentReplaceGif' : 'AttachmentSendGif');
} else if (areAllPhotos) {
title = lang( title = lang(
`Attachment${isEditing ? 'Replace' : 'Send'}Photo`, `Attachment${isEditing ? 'Replace' : 'Send'}Photo`,
{ count: attachmentsLength }, { count: attachmentsLength },
@ -610,7 +595,7 @@ const AttachmentModal = ({
} }
return ( return (
<div className="modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}> <div className="modal-header-condensed-wide" dir={lang.isRtl ? 'rtl' : undefined}>
<Button <Button
round round
color="translucent" color="translucent"
@ -701,14 +686,13 @@ const AttachmentModal = ({
); );
} }
const isBottomDividerShown = !areAttachmentsScrolledToBottom || !isCaptionNotScrolled; const paidSendButtonCaption = paidMessagesStars ? formatStarsAsIcon(
const buttonSendCaption = paidMessagesStars ? formatStarsAsIcon(
lang, lang,
attachmentsLength * paidMessagesStars, attachmentsLength * paidMessagesStars,
{ {
asFont: true, asFont: true,
}, },
) : lang('Send'); ) : undefined;
return ( return (
<Modal <Modal
@ -717,7 +701,6 @@ const AttachmentModal = ({
className={buildClassName( className={buildClassName(
styles.root, styles.root,
isHovered && styles.hovered, isHovered && styles.hovered,
!areAttachmentsNotScrolled && styles.headerBorder,
isMobile && styles.mobile, isMobile && styles.mobile,
isSymbolMenuOpen && styles.symbolMenuOpen, isSymbolMenuOpen && styles.symbolMenuOpen,
forceDarkTheme && 'component-theme-dark', forceDarkTheme && 'component-theme-dark',
@ -744,9 +727,8 @@ const AttachmentModal = ({
className={buildClassName( className={buildClassName(
styles.attachments, styles.attachments,
'custom-scroll', 'custom-scroll',
isBottomDividerShown && styles.attachmentsBottomPadding, !isSendingCompressed && styles.asFile,
)} )}
onScroll={handleAttachmentsScroll}
> >
{renderingAttachments.map((attachment, i) => ( {renderingAttachments.map((attachment, i) => (
<AttachmentModalItem <AttachmentModalItem
@ -765,7 +747,6 @@ const AttachmentModal = ({
<div <div
className={buildClassName( className={buildClassName(
styles.captionWrapper, styles.captionWrapper,
isBottomDividerShown && styles.captionTopBorder,
)} )}
> >
<MentionTooltip <MentionTooltip
@ -823,7 +804,6 @@ const AttachmentModal = ({
placeholder={lang('AttachmentCaptionPlaceholder')} placeholder={lang('AttachmentCaptionPlaceholder')}
onUpdate={onCaptionUpdate} onUpdate={onCaptionUpdate}
onSend={handleSendClick} onSend={handleSendClick}
onScroll={handleCaptionScroll}
canAutoFocus={Boolean(isReady && isForCurrentMessageList && attachments.length)} canAutoFocus={Boolean(isReady && isForCurrentMessageList && attachments.length)}
captionLimit={leftChars} captionLimit={leftChars}
shouldSuppressFocus={isMobile && isSymbolMenuOpen} shouldSuppressFocus={isMobile && isSymbolMenuOpen}
@ -837,9 +817,11 @@ const AttachmentModal = ({
inline inline
onClick={handleSendClick} onClick={handleSendClick}
onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined} onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined}
iconName={!editingMessage && !shouldSchedule && !paidMessagesStars ? 'new-send' : undefined}
iconClassName={styles.sendIcon}
> >
{shouldSchedule && !editingMessage ? lang('Next') {shouldSchedule && !editingMessage ? lang('Next')
: editingMessage ? lang('Save') : buttonSendCaption} : editingMessage ? lang('Save') : paidSendButtonCaption}
</Button> </Button>
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu

View File

@ -7,7 +7,6 @@
align-items: center; align-items: center;
min-width: 0; min-width: 0;
border-radius: var(--border-radius-default);
} }
.preview { .preview {
@ -42,12 +41,22 @@
.no-grouping { .no-grouping {
flex-basis: 100%; flex-basis: 100%;
margin-inline: 0.5rem;
border-radius: 1rem;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-item-hover);
}
} }
.file { .file {
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
margin: 0.5rem; margin-block: 0.25rem;
margin-inline-start: 0.25rem;
margin-inline-end: 0.75rem;
} }
.overlay { .overlay {
@ -57,12 +66,23 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
gap: 0.25rem;
border-radius: var(--border-radius-default); padding-inline: 0.25rem;
border-radius: 1rem;
font-size: 1.25rem;
opacity: 0;
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
transition: opacity 0.15s ease-in-out;
.root:hover & {
opacity: 1;
}
:global(body.no-menu-blur) & { :global(body.no-menu-blur) & {
background-color: #707579; background-color: #707579;
backdrop-filter: none; backdrop-filter: none;
@ -74,15 +94,15 @@
display: block; display: block;
padding: 0.3125rem; padding: 0.375rem;
border-radius: var(--border-radius-messages-small); border-radius: 1.125rem;
color: white; color: white;
transition: 0.2s background-color ease-in-out; transition: 0.2s background-color;
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.15); background-color: rgba(0, 0, 0, 0.05);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -91,7 +111,16 @@
} }
.delete-file { .delete-file {
margin-right: 1rem; margin-inline-end: 0.625rem;
font-size: 1.5rem; font-size: 1.5rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
.root:hover & {
opacity: 1;
}
} }

View File

@ -9,6 +9,7 @@ import { getFileExtension } from '../../common/helpers/documentInfo';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import File from '../../common/File'; import File from '../../common/File';
@ -42,6 +43,7 @@ const AttachmentModalItem = ({
onToggleSpoiler, onToggleSpoiler,
onEdit, onEdit,
}: OwnProps) => { }: OwnProps) => {
const lang = useLang();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const displayType = getDisplayType(attachment, shouldDisplayCompressed); const displayType = getDisplayType(attachment, shouldDisplayCompressed);
@ -82,15 +84,16 @@ const AttachmentModalItem = ({
); );
default: { default: {
const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile; const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile;
const isPhoto = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType);
return ( return (
<> <>
<File <File
className={styles.file} className={styles.file}
name={attachment.filename} name={attachment.filename}
extension={getFileExtension(attachment.filename, attachment.mimeType)} extension={getFileExtension(attachment.filename, attachment.mimeType)}
previewData={attachment.previewBlobUrl} previewData={isPhoto && attachment.blobUrl ? attachment.blobUrl : attachment.previewBlobUrl}
size={attachment.size} size={attachment.size}
smaller previewSize="large"
onClick={canEdit ? handleEditClick : undefined} onClick={canEdit ? handleEditClick : undefined}
actionIcon={canEdit ? 'edit' : undefined} actionIcon={canEdit ? 'edit' : undefined}
/> />
@ -117,7 +120,7 @@ const AttachmentModalItem = ({
); );
return ( return (
<div className={rootClassName}> <div className={rootClassName} dir={lang.isRtl ? 'rtl' : undefined}>
{content} {content}
<MediaSpoiler <MediaSpoiler
isVisible={shouldDisplaySpoiler} isVisible={shouldDisplaySpoiler}

View File

@ -26,7 +26,7 @@ import Button from '../../../ui/Button';
import InfiniteScroll from '../../../ui/InfiniteScroll'; import InfiniteScroll from '../../../ui/InfiniteScroll';
import Link from '../../../ui/Link'; import Link from '../../../ui/Link';
import Modal from '../../../ui/Modal'; import Modal from '../../../ui/Modal';
import TabList, { type TabWithProperties } from '../../../ui/TabList'; import SquareTabList, { type TabWithProperties } from '../../../ui/SquareTabList';
import Transition from '../../../ui/Transition'; import Transition from '../../../ui/Transition';
import GiftAttributeItem from '../GiftAttributeItem'; import GiftAttributeItem from '../GiftAttributeItem';
import UniqueGiftHeader from '../UniqueGiftHeader'; import UniqueGiftHeader from '../UniqueGiftHeader';
@ -474,7 +474,7 @@ const GiftPreviewModal = ({ modal, animationLevel }: OwnProps & StateProps) => {
onClose={handleClose} onClose={handleClose}
> >
{renderHeader()} {renderHeader()}
<TabList <SquareTabList
className={styles.tabs} className={styles.tabs}
activeTab={selectedTabIndex} activeTab={selectedTabIndex}
tabs={tabs} tabs={tabs}

View File

@ -26,6 +26,7 @@ const QuickChatPickerModal = ({
return ( return (
<RecipientPicker <RecipientPicker
isOpen={isOpen} isOpen={isOpen}
title={lang('SelectChat')}
searchPlaceholder={lang('Search')} searchPlaceholder={lang('Search')}
onSelectRecipient={handleSelectRecipient} onSelectRecipient={handleSelectRecipient}
onClose={closeQuickChatPicker} onClose={closeQuickChatPicker}

View File

@ -149,6 +149,7 @@ const SharePreparedMessageModal: FC<OwnProps & StateProps> = ({
<> <>
<RecipientPicker <RecipientPicker
isOpen={isOpen} isOpen={isOpen}
title={lang('SelectChat')}
searchPlaceholder={lang('Search')} searchPlaceholder={lang('Search')}
filter={filter} filter={filter}
onSelectRecipient={handleSelectRecipient} onSelectRecipient={handleSelectRecipient}

View File

@ -31,7 +31,7 @@ import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import InfiniteScroll from '../../ui/InfiniteScroll'; import InfiniteScroll from '../../ui/InfiniteScroll';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
import TabList, { type TabWithProperties } from '../../ui/TabList'; import SquareTabList, { type TabWithProperties } from '../../ui/SquareTabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import ParticlesHeader from '../common/ParticlesHeader.tsx'; import ParticlesHeader from '../common/ParticlesHeader.tsx';
import BalanceBlock from './BalanceBlock'; import BalanceBlock from './BalanceBlock';
@ -426,7 +426,7 @@ const StarsBalanceModal = ({
</InfiniteScroll> </InfiniteScroll>
</Transition> </Transition>
</div> </div>
<TabList <SquareTabList
ref={tabsRef} ref={tabsRef}
className={buildClassName(styles.tabs, areTabsPinned && styles.pinned)} className={buildClassName(styles.tabs, areTabsPinned && styles.pinned)}
tabClassName={styles.tab} tabClassName={styles.tab}

View File

@ -89,7 +89,7 @@
@include mixins.with-vt-type('rightColumn'); @include mixins.with-vt-type('rightColumn');
.TabList { .SquareTabList {
z-index: 1; z-index: 1;
background: var(--color-background); background: var(--color-background);
} }

View File

@ -119,7 +119,7 @@ import InfiniteScroll from '../ui/InfiniteScroll';
import Link from '../ui/Link'; import Link from '../ui/Link';
import ListItem, { type MenuItemContextAction } from '../ui/ListItem'; import ListItem, { type MenuItemContextAction } from '../ui/ListItem';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import TabList, { type TabWithProperties } from '../ui/TabList'; import SquareTabList, { type TabWithProperties } from '../ui/SquareTabList';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import DeleteMemberModal from './DeleteMemberModal'; import DeleteMemberModal from './DeleteMemberModal';
import StarGiftCollectionList from './gifts/StarGiftCollectionList'; import StarGiftCollectionList from './gifts/StarGiftCollectionList';
@ -953,7 +953,7 @@ const Profile = ({
id={`shared-media${getMessageHtmlId(id)}`} id={`shared-media${getMessageHtmlId(id)}`}
document={getMessageDocument(messagesById[id])!} document={getMessageDocument(messagesById[id])!}
datetime={messagesById[id].date} datetime={messagesById[id].date}
smaller fileSize="small"
className="scroll-item" className="scroll-item"
isDownloading={getIsDownloading(activeDownloads, getMessageDocument(messagesById[id])!)} isDownloading={getIsDownloading(activeDownloads, getMessageDocument(messagesById[id])!)}
observeIntersection={observeIntersectionForMedia} observeIntersection={observeIntersectionForMedia}
@ -1264,7 +1264,7 @@ const Profile = ({
> >
{renderContent()} {renderContent()}
</Transition> </Transition>
<TabList activeTab={activeTabIndex} tabs={tabs} onSwitchTab={handleSwitchTab} /> <SquareTabList activeTab={activeTabIndex} tabs={tabs} onSwitchTab={handleSwitchTab} />
</div> </div>
)} )}

View File

@ -37,7 +37,7 @@ export default function useProfileState({
useEffectWithPrevDeps(([prevTabType]) => { useEffectWithPrevDeps(([prevTabType]) => {
if ((prevTabType && prevTabType !== tabType && allowAutoScrollToTabs) || (tabType && forceScrollProfileTab)) { if ((prevTabType && prevTabType !== tabType && allowAutoScrollToTabs) || (tabType && forceScrollProfileTab)) {
const container = containerRef.current!; const container = containerRef.current!;
const tabsEl = container.querySelector<HTMLDivElement>('.TabList')!; const tabsEl = container.querySelector<HTMLDivElement>('.SquareTabList')!;
handleStopAutoScrollToTabs(); handleStopAutoScrollToTabs();
if (container.scrollTop < tabsEl.offsetTop) { if (container.scrollTop < tabsEl.offsetTop) {
onProfileStateChange(getStateFromTabType(tabType)); onProfileStateChange(getStateFromTabType(tabType));
@ -69,7 +69,7 @@ export default function useProfileState({
return; return;
} }
const tabListEl = container.querySelector<HTMLDivElement>('.TabList'); const tabListEl = container.querySelector<HTMLDivElement>('.SquareTabList');
if (!tabListEl || tabListEl.offsetTop > container.scrollTop) { if (!tabListEl || tabListEl.offsetTop > container.scrollTop) {
return; return;
} }
@ -94,7 +94,7 @@ export default function useProfileState({
return; return;
} }
const tabListEl = container.querySelector<HTMLDivElement>('.TabList'); const tabListEl = container.querySelector<HTMLDivElement>('.SquareTabList');
if (!tabListEl) { if (!tabListEl) {
return; return;
} }

View File

@ -12,7 +12,7 @@ export default function useTransitionFixes(
function setMinHeight() { function setMinHeight() {
const container = containerRef.current!; const container = containerRef.current!;
const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector); const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector);
const tabsEl = container.querySelector<HTMLDivElement>('.TabList'); const tabsEl = container.querySelector<HTMLDivElement>('.SquareTabList');
if (transitionEl && tabsEl) { if (transitionEl && tabsEl) {
const newHeight = container.clientHeight - tabsEl.offsetHeight; const newHeight = container.clientHeight - tabsEl.offsetHeight;

View File

@ -9,8 +9,8 @@ import type { ApiChat, ApiChatMember } from '../../../api/types';
import { filterPeersByQuery } from '../../../global/helpers/peers'; import { filterPeersByQuery } from '../../../global/helpers/peers';
import { selectChatFullInfo } from '../../../global/selectors'; import { selectChatFullInfo } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker'; import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker';
@ -37,7 +37,7 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps> = ({
deleteChatMember, deleteChatMember,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useLang();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const usersId = useMemo(() => { const usersId = useMemo(() => {
@ -65,7 +65,8 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps> = ({
<ChatOrUserPicker <ChatOrUserPicker
isOpen={isOpen} isOpen={isOpen}
chatOrUserIds={usersId} chatOrUserIds={usersId}
searchPlaceholder={lang('ChannelBlockUser')} title={lang('ChannelBlockUser')}
searchPlaceholder={lang('Search')}
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
loadMore={handleLoadMore} loadMore={handleLoadMore}

View File

@ -25,7 +25,7 @@ import PrivateChatInfo from '../../common/PrivateChatInfo';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import TabList from '../../ui/TabList'; import SquareTabList from '../../ui/SquareTabList';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import StatisticsOverview from './StatisticsOverview'; import StatisticsOverview from './StatisticsOverview';
@ -366,7 +366,7 @@ const BoostStatistics = ({
> >
{renderContent()} {renderContent()}
</Transition> </Transition>
<TabList activeTab={renderingActiveTab} tabs={tabs} onSwitchTab={setActiveTab} /> <SquareTabList activeTab={renderingActiveTab} tabs={tabs} onSwitchTab={setActiveTab} />
</div> </div>
) : ( ) : (
<> <>

View File

@ -145,11 +145,11 @@
} }
} }
.modal-header-condensed-wide,
.modal-header-condensed { .modal-header-condensed {
@extend %modal-header; @extend %modal-header;
min-height: 3.5rem; min-height: 3.5rem;
padding: 0.375rem 0.75rem !important;
.modal-action-button { .modal-action-button {
width: auto; width: auto;
@ -175,6 +175,19 @@
} }
} }
.modal-header-condensed {
padding: 0.375rem 0.75rem !important;
}
.modal-header-condensed-wide {
padding: 0.375rem 1.375rem !important;
.modal-title {
&:not(:only-child) {
margin: 0 1.375rem;
}
}
}
.modal-content { .modal-content {
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;

View File

@ -38,6 +38,7 @@ export type OwnProps = {
hasCloseButton?: boolean; hasCloseButton?: boolean;
hasAbsoluteCloseButton?: boolean; hasAbsoluteCloseButton?: boolean;
absoluteCloseButtonColor?: ButtonProps['color']; absoluteCloseButtonColor?: ButtonProps['color'];
isBackButton?: boolean;
noBackdrop?: boolean; noBackdrop?: boolean;
noBackdropClose?: boolean; noBackdropClose?: boolean;
children: React.ReactNode; children: React.ReactNode;
@ -91,6 +92,7 @@ const Modal = (props: OwnProps) => {
hasCloseButton, hasCloseButton,
hasAbsoluteCloseButton, hasAbsoluteCloseButton,
absoluteCloseButtonColor = 'translucent', absoluteCloseButtonColor = 'translucent',
isBackButton,
noBackdrop, noBackdrop,
style, style,
dialogStyle, dialogStyle,
@ -176,16 +178,22 @@ const Modal = (props: OwnProps) => {
return header; return header;
} }
const closeIconClassName = buildClassName(
'animated-close-icon',
isBackButton && 'state-back',
);
const closeButton = withCloseButton ? ( const closeButton = withCloseButton ? (
<Button <Button
className={buildClassName(hasAbsoluteCloseButton && 'modal-absolute-close-button')} className={buildClassName(hasAbsoluteCloseButton && 'modal-absolute-close-button')}
round round
color={absoluteCloseButtonColor} color={absoluteCloseButtonColor}
size="tiny" size="tiny"
iconName="close" ariaLabel={isBackButton ? lang('Back') : lang('Close')}
ariaLabel={lang('Close')}
onClick={onClose} onClick={onClose}
/> >
<div className={closeIconClassName} />
</Button>
) : undefined; ) : undefined;
return title ? ( return title ? (

View File

@ -1,4 +1,4 @@
.TabList { .SquareTabList {
scrollbar-color: rgba(0, 0, 0, 0); scrollbar-color: rgba(0, 0, 0, 0);
scrollbar-width: none; scrollbar-width: none;

View File

@ -0,0 +1,111 @@
import type { ElementRef, TeactNode } from '../../lib/teact/teact';
import { memo, useEffect, useRef } from '../../lib/teact/teact';
import type { ApiMessageEntityCustomEmoji } from '../../api/types';
import type { MenuItemContextAction } from './ListItem';
import animateHorizontalScroll from '../../util/animateHorizontalScroll';
import { IS_ANDROID, IS_IOS } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLang from '../../hooks/useLang';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import Tab from './Tab';
import './SquareTabList.scss';
export type TabWithProperties = {
id?: number;
title: TeactNode;
badgeCount?: number;
isBlocked?: boolean;
isBadgeActive?: boolean;
contextActions?: MenuItemContextAction[];
emoticon?: string | ApiMessageEntityCustomEmoji;
noTitleAnimations?: boolean;
};
type OwnProps = {
tabs: readonly TabWithProperties[];
activeTab: number;
className?: string;
tabClassName?: string;
contextRootElementSelector?: string;
ref?: ElementRef<HTMLDivElement>;
onSwitchTab: (index: number) => void;
};
const TAB_SCROLL_THRESHOLD_PX = 16;
// Should match duration from `--slide-transition` CSS variable
const SCROLL_DURATION = IS_IOS ? 450 : IS_ANDROID ? 400 : 300;
const SquareTabList = ({
tabs,
activeTab,
className,
tabClassName,
contextRootElementSelector,
ref,
onSwitchTab,
}: OwnProps) => {
const internalRef = useRef<HTMLDivElement>();
const containerRef = ref || internalRef;
const previousActiveTab = usePreviousDeprecated(activeTab);
const lang = useLang();
useHorizontalScroll(containerRef, undefined, true);
// Scroll container to place active tab in the center
useEffect(() => {
const container = containerRef.current!;
const { scrollWidth, offsetWidth, scrollLeft } = container;
if (scrollWidth <= offsetWidth) {
return;
}
const activeTabElement = container.childNodes[activeTab] as HTMLElement | null;
if (!activeTabElement) {
return;
}
const { offsetLeft: activeTabOffsetLeft, offsetWidth: activeTabOffsetWidth } = activeTabElement;
const newLeft = activeTabOffsetLeft - (offsetWidth / 2) + (activeTabOffsetWidth / 2);
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
animateHorizontalScroll(container, newLeft, SCROLL_DURATION);
}, [activeTab, containerRef]);
return (
<div
className={buildClassName('SquareTabList', 'no-scrollbar', className)}
ref={containerRef}
dir={lang.isRtl ? 'rtl' : undefined}
>
{tabs.map((tab, i) => (
<Tab
key={tab.id}
title={tab.title}
isActive={i === activeTab}
isBlocked={tab.isBlocked}
badgeCount={tab.badgeCount}
isBadgeActive={tab.isBadgeActive}
previousActiveTab={previousActiveTab}
onClick={onSwitchTab}
clickArg={i}
contextActions={tab.contextActions}
contextRootElementSelector={contextRootElementSelector}
className={tabClassName}
/>
))}
</div>
);
};
export default memo(SquareTabList);

View File

@ -0,0 +1,85 @@
.container,
.activeIndicator {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
padding-block: 0.375rem;
padding-inline: 0.25rem;
}
.container {
user-select: none;
scrollbar-width: none;
position: relative;
overflow-x: auto;
border-radius: 1.5rem;
opacity: 0;
background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
transition: opacity 150ms;
&::-webkit-scrollbar {
display: none;
}
&.ready {
opacity: 1;
}
}
.activeIndicator {
will-change: clip-path;
isolation: isolate;
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
contain: layout style paint;
overflow: hidden;
width: fit-content;
background-color: var(--color-primary-opacity);
transition: clip-path var(--slide-transition);
}
.tab {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
padding: 0.375rem 1rem;
border-radius: 1.25rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
&:hover {
opacity: 0.85;
}
.activeIndicator & {
color: var(--color-primary);
}
}
.lockIcon {
font-size: 0.875rem;
}

View File

@ -1,111 +1,90 @@
import type { ElementRef, TeactNode } from '../../lib/teact/teact'; import { memo, useEffect, useRef, useState } from '../../lib/teact/teact';
import { memo, useEffect, useRef } from '../../lib/teact/teact';
import type { ApiMessageEntityCustomEmoji } from '../../api/types'; import type { TabWithProperties } from './SquareTabList';
import type { MenuItemContextAction } from './ListItem';
export type { TabWithProperties };
import animateHorizontalScroll from '../../util/animateHorizontalScroll';
import { IS_ANDROID, IS_IOS } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback';
import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import useResizeObserver from '../../hooks/useResizeObserver';
import Tab from './Tab'; import Icon from '../common/icons/Icon';
import './TabList.scss'; import styles from './TabList.module.scss';
export type TabWithProperties = {
id?: number;
title: TeactNode;
badgeCount?: number;
isBlocked?: boolean;
isBadgeActive?: boolean;
contextActions?: MenuItemContextAction[];
emoticon?: string | ApiMessageEntityCustomEmoji;
noTitleAnimations?: boolean;
};
type OwnProps = { type OwnProps = {
tabs: readonly TabWithProperties[]; tabs: readonly TabWithProperties[];
activeTab: number; activeTab: number;
className?: string; className?: string;
tabClassName?: string;
contextRootElementSelector?: string;
ref?: ElementRef<HTMLDivElement>;
onSwitchTab: (index: number) => void; onSwitchTab: (index: number) => void;
}; };
const TAB_SCROLL_THRESHOLD_PX = 16;
// Should match duration from `--slide-transition` CSS variable
const SCROLL_DURATION = IS_IOS ? 450 : IS_ANDROID ? 400 : 300;
const TabList = ({ const TabList = ({
tabs, tabs,
activeTab, activeTab,
className, className,
tabClassName,
contextRootElementSelector,
ref,
onSwitchTab, onSwitchTab,
}: OwnProps) => { }: OwnProps) => {
let containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
if (ref) { const clipPathContainerRef = useRef<HTMLDivElement>();
containerRef = ref; const [clipPath, setClipPath] = useState<string>('');
}
const previousActiveTab = usePreviousDeprecated(activeTab);
const lang = useLang(); useHorizontalScroll(containerRef, !tabs.length, true);
useHorizontalScroll(containerRef, undefined, true); const updateClipPath = useLastCallback(() => {
const clipPathContainer = clipPathContainerRef.current;
const activeTabEl = activeTab >= 0 && clipPathContainer?.childNodes[activeTab] as HTMLElement | undefined;
if (clipPathContainer && activeTabEl && clipPathContainer.offsetWidth > 0) {
const { offsetLeft, offsetWidth } = activeTabEl;
const containerWidth = clipPathContainer.offsetWidth;
const left = (offsetLeft / containerWidth * 100).toFixed(1);
const right = ((containerWidth - (offsetLeft + offsetWidth)) / containerWidth * 100).toFixed(1);
setClipPath(`inset(0.25rem ${right}% 0.25rem ${left}% round 1.25rem)`);
}
});
// Scroll container to place active tab in the center
useEffect(() => { useEffect(() => {
const container = containerRef.current!; updateClipPath();
const { scrollWidth, offsetWidth, scrollLeft } = container; }, [activeTab, tabs]);
if (scrollWidth <= offsetWidth) {
return;
}
const activeTabElement = container.childNodes[activeTab] as HTMLElement | null; useResizeObserver(clipPathContainerRef, updateClipPath);
if (!activeTabElement) {
return;
}
const { offsetLeft: activeTabOffsetLeft, offsetWidth: activeTabOffsetWidth } = activeTabElement; const handleTabClick = useLastCallback((index: number) => {
const newLeft = activeTabOffsetLeft - (offsetWidth / 2) + (activeTabOffsetWidth / 2); onSwitchTab(index);
});
// Prevent scrolling by only a couple of pixels, which doesn't look smooth if (!tabs.length) return undefined;
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
animateHorizontalScroll(container, newLeft, SCROLL_DURATION); const renderTab = (tab: TabWithProperties, index: number) => (
}, [activeTab]); <div
key={tab.id ?? index}
className={styles.tab}
onClick={() => handleTabClick(index)}
>
{tab.title}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div>
);
return ( return (
<div <div
className={buildClassName('TabList', 'no-scrollbar', className)}
ref={containerRef} ref={containerRef}
dir={lang.isRtl ? 'rtl' : undefined} className={buildClassName(styles.container, className, clipPath && styles.ready)}
> >
{tabs.map((tab, i) => ( {tabs.map(renderTab)}
<Tab
key={tab.id} <div
title={tab.title} ref={clipPathContainerRef}
isActive={i === activeTab} className={styles.activeIndicator}
isBlocked={tab.isBlocked} style={clipPath ? `clip-path: ${clipPath}` : undefined}
badgeCount={tab.badgeCount} aria-hidden
isBadgeActive={tab.isBadgeActive} >
previousActiveTab={previousActiveTab} {tabs.map(renderTab)}
onClick={onSwitchTab} </div>
clickArg={i}
contextActions={tab.contextActions}
contextRootElementSelector={contextRootElementSelector}
className={tabClassName}
/>
))}
</div> </div>
); );
}; };

View File

@ -31,7 +31,7 @@ import Icon from '../../common/icons/Icon';
import Button from '../Button'; import Button from '../Button';
import FloatingActionButton from '../FloatingActionButton'; import FloatingActionButton from '../FloatingActionButton';
import Portal from '../Portal'; import Portal from '../Portal';
import TabList from '../TabList'; import SquareTabList from '../SquareTabList';
import Transition from '../Transition'; import Transition from '../Transition';
import CropOverlay from './CropOverlay'; import CropOverlay from './CropOverlay';
import CropPanel from './CropPanel'; import CropPanel from './CropPanel';
@ -807,7 +807,7 @@ const MediaEditor = ({
> >
{renderPanelContent()} {renderPanelContent()}
</Transition> </Transition>
<TabList <SquareTabList
tabs={TABS} tabs={TABS}
activeTab={activeTabIndex} activeTab={activeTabIndex}
onSwitchTab={handleTabSwitch} onSwitchTab={handleTabSwitch}

View File

@ -145,6 +145,7 @@ import {
selectMessageReplyInfo, selectMessageReplyInfo,
selectOutlyingListByMessageId, selectOutlyingListByMessageId,
selectPeer, selectPeer,
selectPeerPaidMessagesStars,
selectPeerStory, selectPeerStory,
selectPinnedIds, selectPinnedIds,
selectPollFromMessage, selectPollFromMessage,
@ -2607,6 +2608,143 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet
actions.forwardMessages({ isSilent: true, scheduledAt, tabId }); actions.forwardMessages({ isSilent: true, scheduledAt, tabId });
}); });
interface ForwardToChatOptions {
global: GlobalState;
fromChat: ApiChat;
toChat: ApiChat;
realMessages: ApiMessage[];
serviceMessages: ApiMessage[];
comment?: string;
withMyScore?: boolean;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium: boolean;
}
function forwardMessagesToChat({
global,
fromChat,
toChat,
realMessages,
serviceMessages,
comment,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
}: ForwardToChatOptions) {
const sendAs = selectSendAs(global, toChat.id);
const lastMessageId = selectChatLastMessageId(global, toChat.id);
const messagePriceInStars = selectPeerPaidMessagesStars(global, toChat.id);
if (comment) {
sendMessage(global, {
chat: toChat,
text: comment,
sendAs,
lastMessageId,
messagePriceInStars,
});
}
if (realMessages.length) {
const messageSlices = global.config?.maxForwardedCount
? splitMessagesForForwarding(realMessages, global.config.maxForwardedCount)
: [realMessages];
for (const slice of messageSlices) {
const forwardParams: ForwardMessagesParams = {
fromChat,
toChat,
toThreadId: MAIN_THREAD_ID,
messages: slice,
isSilent: true,
sendAs,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
wasDrafted: false,
lastMessageId,
messagePriceInStars,
};
callApi('forwardMessages', forwardParams);
}
}
for (const message of serviceMessages) {
const { text, entities } = message.content.text || {};
const { sticker } = message.content;
sendMessage(global, {
chat: toChat,
text,
entities,
sticker,
isSilent: true,
sendAs,
lastMessageId,
messagePriceInStars,
});
}
}
addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionReturnType => {
const { toChatIds, comment, tabId = getCurrentTabId() } = payload;
const {
fromChatId, messageIds, withMyScore, noAuthors, noCaptions,
} = selectTabState(global, tabId).forwardMessages;
const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const messages = fromChatId && messageIds
? messageIds
.sort((a, b) => a - b)
.map((id) => selectChatMessage(global, fromChatId, id)).filter(Boolean)
: undefined;
if (!fromChat || !messages?.length) {
return;
}
const [realMessages, serviceMessages] = partition(messages, (m) => !isServiceNotificationMessage(m));
const forwardableRealMessages = realMessages.filter((message) => selectCanForwardMessage(global, message));
if (!forwardableRealMessages.length && !serviceMessages.length) {
return;
}
for (const toChatId of toChatIds) {
const toChat = selectChat(global, toChatId);
if (!toChat) continue;
forwardMessagesToChat({
global,
fromChat,
toChat,
realMessages: forwardableRealMessages,
serviceMessages,
comment,
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
});
}
global = updateTabState(global, {
forwardMessages: {},
isShareMessageModalShown: false,
}, tabId);
actions.exitMessageSelectMode({ tabId });
return global;
});
addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => { addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => {
const { toChatId, tabId = getCurrentTabId() } = payload || {}; const { toChatId, tabId = getCurrentTabId() } = payload || {};

View File

@ -692,6 +692,20 @@ export function selectAllowedMessageActionsSlow<T extends GlobalState>(
}; };
} }
export function selectCanCopyMessageLink<T extends GlobalState>(
global: T, message: ApiMessage,
) {
const chat = selectChat(global, message.chatId);
if (!chat || selectIsChatRestricted(global, message.chatId)) return false;
const isLocal = isMessageLocal(message);
const isAction = isActionMessage(message);
const isChannel = isChatChannel(chat);
const isSuperGroup = isChatSuperGroup(chat);
return !isLocal && !isAction && (isChannel || isSuperGroup) && !chat.isMonoforum;
}
export function selectCanDeleteMessages<T extends GlobalState>( export function selectCanDeleteMessages<T extends GlobalState>(
global: T, global: T,
chatId: string, chatId: string,

View File

@ -2023,6 +2023,10 @@ export interface ActionPayloads {
} & WithTabId; } & WithTabId;
exitForwardMode: WithTabId | undefined; exitForwardMode: WithTabId | undefined;
changeRecipient: WithTabId | undefined; changeRecipient: WithTabId | undefined;
forwardToMultipleChats: {
toChatIds: string[];
comment?: string;
} & WithTabId;
forwardToSavedMessages: { forwardToSavedMessages: {
scheduledAt?: number; scheduledAt?: number;
} & WithTabId; } & WithTabId;

View File

@ -3,7 +3,7 @@ import { getActions, getGlobal } from '../global';
import type { ApiMessageEntity, ApiMessageEntityCustomEmoji } from '../api/types'; import type { ApiMessageEntity, ApiMessageEntityCustomEmoji } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem'; import type { MenuItemContextAction } from '../components/ui/ListItem';
import type { TabWithProperties } from '../components/ui/TabList'; import type { TabWithProperties } from '../components/ui/SquareTabList';
import { type ApiChatFolder, type ApiChatlistExportedInvite, ApiMessageEntityTypes } from '../api/types'; import { type ApiChatFolder, type ApiChatlistExportedInvite, ApiMessageEntityTypes } from '../api/types';
import { SettingsScreens } from '../types'; import { SettingsScreens } from '../types';

View File

@ -3,8 +3,8 @@
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
src: url("./icons.woff2?4ca2ae9f8c7763ea420459de680c8340") format("woff2"), src: url("./icons.woff2?711696a367cfec0ddfb1ac8f13f32988") format("woff2"),
url("./icons.woff?4ca2ae9f8c7763ea420459de680c8340") format("woff"); url("./icons.woff?711696a367cfec0ddfb1ac8f13f32988") format("woff");
} }
.icon-char::before { .icon-char::before {
@ -444,564 +444,570 @@ url("./icons.woff?4ca2ae9f8c7763ea420459de680c8340") format("woff");
.icon-next-link::before { .icon-next-link::before {
content: "\f18d"; content: "\f18d";
} }
.icon-new-chat-filled::before { .icon-new-send::before {
content: "\f18e"; content: "\f18e";
} }
.icon-my-notes::before { .icon-new-chat-filled::before {
content: "\f18f"; content: "\f18f";
} }
.icon-muted::before { .icon-my-notes::before {
content: "\f190"; content: "\f190";
} }
.icon-mute::before { .icon-muted::before {
content: "\f191"; content: "\f191";
} }
.icon-move-caption-up::before { .icon-mute::before {
content: "\f192"; content: "\f192";
} }
.icon-move-caption-down::before { .icon-move-caption-up::before {
content: "\f193"; content: "\f193";
} }
.icon-more::before { .icon-move-caption-down::before {
content: "\f194"; content: "\f194";
} }
.icon-more-circle::before { .icon-more::before {
content: "\f195"; content: "\f195";
} }
.icon-monospace::before { .icon-more-circle::before {
content: "\f196"; content: "\f196";
} }
.icon-microphone::before { .icon-monospace::before {
content: "\f197"; content: "\f197";
} }
.icon-microphone-alt::before { .icon-microphone::before {
content: "\f198"; content: "\f198";
} }
.icon-message::before { .icon-microphone-alt::before {
content: "\f199"; content: "\f199";
} }
.icon-message-succeeded::before { .icon-message::before {
content: "\f19a"; content: "\f19a";
} }
.icon-message-read::before { .icon-message-succeeded::before {
content: "\f19b"; content: "\f19b";
} }
.icon-message-pending::before { .icon-message-read::before {
content: "\f19c"; content: "\f19c";
} }
.icon-message-failed::before { .icon-message-pending::before {
content: "\f19d"; content: "\f19d";
} }
.icon-menu::before { .icon-message-failed::before {
content: "\f19e"; content: "\f19e";
} }
.icon-mention::before { .icon-menu::before {
content: "\f19f"; content: "\f19f";
} }
.icon-loop::before { .icon-mention::before {
content: "\f1a0"; content: "\f1a0";
} }
.icon-logout::before { .icon-loop::before {
content: "\f1a1"; content: "\f1a1";
} }
.icon-lock::before { .icon-logout::before {
content: "\f1a2"; content: "\f1a2";
} }
.icon-lock-badge::before { .icon-lock::before {
content: "\f1a3"; content: "\f1a3";
} }
.icon-location::before { .icon-lock-badge::before {
content: "\f1a4"; content: "\f1a4";
} }
.icon-link::before { .icon-location::before {
content: "\f1a5"; content: "\f1a5";
} }
.icon-link-broken::before { .icon-link::before {
content: "\f1a6"; content: "\f1a6";
} }
.icon-link-badge::before { .icon-link-broken::before {
content: "\f1a7"; content: "\f1a7";
} }
.icon-large-play::before { .icon-link-badge::before {
content: "\f1a8"; content: "\f1a8";
} }
.icon-large-pause::before { .icon-large-play::before {
content: "\f1a9"; content: "\f1a9";
} }
.icon-language::before { .icon-large-pause::before {
content: "\f1aa"; content: "\f1aa";
} }
.icon-lamp::before { .icon-language::before {
content: "\f1ab"; content: "\f1ab";
} }
.icon-keyboard::before { .icon-lamp::before {
content: "\f1ac"; content: "\f1ac";
} }
.icon-key::before { .icon-keyboard::before {
content: "\f1ad"; content: "\f1ad";
} }
.icon-italic::before { .icon-key::before {
content: "\f1ae"; content: "\f1ae";
} }
.icon-install::before { .icon-italic::before {
content: "\f1af"; content: "\f1af";
} }
.icon-info::before { .icon-install::before {
content: "\f1b0"; content: "\f1b0";
} }
.icon-info-filled::before { .icon-info::before {
content: "\f1b1"; content: "\f1b1";
} }
.icon-help::before { .icon-info-filled::before {
content: "\f1b2"; content: "\f1b2";
} }
.icon-heart::before { .icon-help::before {
content: "\f1b3"; content: "\f1b3";
} }
.icon-heart-outline::before { .icon-heart::before {
content: "\f1b4"; content: "\f1b4";
} }
.icon-hd-photo::before { .icon-heart-outline::before {
content: "\f1b5"; content: "\f1b5";
} }
.icon-hashtag::before { .icon-hd-photo::before {
content: "\f1b6"; content: "\f1b6";
} }
.icon-hand-stop::before { .icon-hashtag::before {
content: "\f1b7"; content: "\f1b7";
} }
.icon-hand-stop-filled::before { .icon-hand-stop::before {
content: "\f1b8"; content: "\f1b8";
} }
.icon-grouped::before { .icon-hand-stop-filled::before {
content: "\f1b9"; content: "\f1b9";
} }
.icon-grouped-disable::before { .icon-grouped::before {
content: "\f1ba"; content: "\f1ba";
} }
.icon-group::before { .icon-grouped-disable::before {
content: "\f1bb"; content: "\f1bb";
} }
.icon-group-filled::before { .icon-group::before {
content: "\f1bc"; content: "\f1bc";
} }
.icon-gift::before { .icon-group-filled::before {
content: "\f1bd"; content: "\f1bd";
} }
.icon-gift-transfer-inline::before { .icon-gift::before {
content: "\f1be"; content: "\f1be";
} }
.icon-gifs::before { .icon-gift-transfer-inline::before {
content: "\f1bf"; content: "\f1bf";
} }
.icon-fullscreen::before { .icon-gifs::before {
content: "\f1c0"; content: "\f1c0";
} }
.icon-frozen-time::before { .icon-fullscreen::before {
content: "\f1c1"; content: "\f1c1";
} }
.icon-fragment::before { .icon-frozen-time::before {
content: "\f1c2"; content: "\f1c2";
} }
.icon-forward::before { .icon-fragment::before {
content: "\f1c3"; content: "\f1c3";
} }
.icon-forums::before { .icon-forward::before {
content: "\f1c4"; content: "\f1c4";
} }
.icon-fontsize::before { .icon-forums::before {
content: "\f1c5"; content: "\f1c5";
} }
.icon-folder::before { .icon-fontsize::before {
content: "\f1c6"; content: "\f1c6";
} }
.icon-folder-badge::before { .icon-folder::before {
content: "\f1c7"; content: "\f1c7";
} }
.icon-flip::before { .icon-folder-badge::before {
content: "\f1c8"; content: "\f1c8";
} }
.icon-flag::before { .icon-flip::before {
content: "\f1c9"; content: "\f1c9";
} }
.icon-file-badge::before { .icon-flag::before {
content: "\f1ca"; content: "\f1ca";
} }
.icon-favorite::before { .icon-file-badge::before {
content: "\f1cb"; content: "\f1cb";
} }
.icon-favorite-filled::before { .icon-favorite::before {
content: "\f1cc"; content: "\f1cc";
} }
.icon-eye::before { .icon-favorite-filled::before {
content: "\f1cd"; content: "\f1cd";
} }
.icon-eye-outline::before { .icon-eye::before {
content: "\f1ce"; content: "\f1ce";
} }
.icon-eye-crossed::before { .icon-eye-outline::before {
content: "\f1cf"; content: "\f1cf";
} }
.icon-eye-crossed-outline::before { .icon-eye-crossed::before {
content: "\f1d0"; content: "\f1d0";
} }
.icon-expand::before { .icon-eye-crossed-outline::before {
content: "\f1d1"; content: "\f1d1";
} }
.icon-expand-modal::before { .icon-expand::before {
content: "\f1d2"; content: "\f1d2";
} }
.icon-enter::before { .icon-expand-modal::before {
content: "\f1d3"; content: "\f1d3";
} }
.icon-email::before { .icon-enter::before {
content: "\f1d4"; content: "\f1d4";
} }
.icon-edit::before { .icon-email::before {
content: "\f1d5"; content: "\f1d5";
} }
.icon-eats::before { .icon-edit::before {
content: "\f1d6"; content: "\f1d6";
} }
.icon-dropdown-arrows::before { .icon-eats::before {
content: "\f1d7"; content: "\f1d7";
} }
.icon-download::before { .icon-dropdown-arrows::before {
content: "\f1d8"; content: "\f1d8";
} }
.icon-down::before { .icon-download::before {
content: "\f1d9"; content: "\f1d9";
} }
.icon-double-badge::before { .icon-down::before {
content: "\f1da"; content: "\f1da";
} }
.icon-document::before { .icon-double-badge::before {
content: "\f1db"; content: "\f1db";
} }
.icon-diamond::before { .icon-document::before {
content: "\f1dc"; content: "\f1dc";
} }
.icon-delete::before { .icon-diamond::before {
content: "\f1dd"; content: "\f1dd";
} }
.icon-delete-user::before { .icon-delete::before {
content: "\f1de"; content: "\f1de";
} }
.icon-delete-left::before { .icon-delete-user::before {
content: "\f1df"; content: "\f1df";
} }
.icon-delete-filled::before { .icon-delete-left::before {
content: "\f1e0"; content: "\f1e0";
} }
.icon-data::before { .icon-delete-filled::before {
content: "\f1e1"; content: "\f1e1";
} }
.icon-darkmode::before { .icon-data::before {
content: "\f1e2"; content: "\f1e2";
} }
.icon-crown-wear::before { .icon-darkmode::before {
content: "\f1e3"; content: "\f1e3";
} }
.icon-crown-wear-outline::before { .icon-crown-wear::before {
content: "\f1e4"; content: "\f1e4";
} }
.icon-crown-take-off::before { .icon-crown-wear-outline::before {
content: "\f1e5"; content: "\f1e5";
} }
.icon-crown-take-off-outline::before { .icon-crown-take-off::before {
content: "\f1e6"; content: "\f1e6";
} }
.icon-crop::before { .icon-crown-take-off-outline::before {
content: "\f1e7"; content: "\f1e7";
} }
.icon-craft::before { .icon-crop::before {
content: "\f1e8"; content: "\f1e8";
} }
.icon-copy::before { .icon-craft::before {
content: "\f1e9"; content: "\f1e9";
} }
.icon-copy-media::before { .icon-copy::before {
content: "\f1ea"; content: "\f1ea";
} }
.icon-comments::before { .icon-copy-media::before {
content: "\f1eb"; content: "\f1eb";
} }
.icon-comments-sticker::before { .icon-comments::before {
content: "\f1ec"; content: "\f1ec";
} }
.icon-combine-craft::before { .icon-comments-sticker::before {
content: "\f1ed"; content: "\f1ed";
} }
.icon-colorize::before { .icon-combine-craft::before {
content: "\f1ee"; content: "\f1ee";
} }
.icon-collapse::before { .icon-colorize::before {
content: "\f1ef"; content: "\f1ef";
} }
.icon-collapse-modal::before { .icon-collapse::before {
content: "\f1f0"; content: "\f1f0";
} }
.icon-cloud-download::before { .icon-collapse-modal::before {
content: "\f1f1"; content: "\f1f1";
} }
.icon-closed-gift::before { .icon-cloud-download::before {
content: "\f1f2"; content: "\f1f2";
} }
.icon-close::before { .icon-closed-gift::before {
content: "\f1f3"; content: "\f1f3";
} }
.icon-close-topic::before { .icon-close::before {
content: "\f1f4"; content: "\f1f4";
} }
.icon-close-circle::before { .icon-close-topic::before {
content: "\f1f5"; content: "\f1f5";
} }
.icon-clock::before { .icon-close-circle::before {
content: "\f1f6"; content: "\f1f6";
} }
.icon-clock-edit::before { .icon-clock::before {
content: "\f1f7"; content: "\f1f7";
} }
.icon-check::before { .icon-clock-edit::before {
content: "\f1f8"; content: "\f1f8";
} }
.icon-chats-badge::before { .icon-check::before {
content: "\f1f9"; content: "\f1f9";
} }
.icon-chat-badge::before { .icon-check-bold::before {
content: "\f1fa"; content: "\f1fa";
} }
.icon-channelviews::before { .icon-chats-badge::before {
content: "\f1fb"; content: "\f1fb";
} }
.icon-channel::before { .icon-chat-badge::before {
content: "\f1fc"; content: "\f1fc";
} }
.icon-channel-filled::before { .icon-channelviews::before {
content: "\f1fd"; content: "\f1fd";
} }
.icon-cash-circle::before { .icon-channel::before {
content: "\f1fe"; content: "\f1fe";
} }
.icon-card::before { .icon-channel-filled::before {
content: "\f1ff"; content: "\f1ff";
} }
.icon-car::before { .icon-cash-circle::before {
content: "\f200"; content: "\f200";
} }
.icon-camera::before { .icon-card::before {
content: "\f201"; content: "\f201";
} }
.icon-camera-add::before { .icon-car::before {
content: "\f202"; content: "\f202";
} }
.icon-calendar::before { .icon-camera::before {
content: "\f203"; content: "\f203";
} }
.icon-calendar-filter::before { .icon-camera-add::before {
content: "\f204"; content: "\f204";
} }
.icon-bug::before { .icon-calendar::before {
content: "\f205"; content: "\f205";
} }
.icon-brush::before { .icon-calendar-filter::before {
content: "\f206"; content: "\f206";
} }
.icon-bots::before { .icon-bug::before {
content: "\f207"; content: "\f207";
} }
.icon-bot-commands-filled::before { .icon-brush::before {
content: "\f208"; content: "\f208";
} }
.icon-bot-command::before { .icon-bots::before {
content: "\f209"; content: "\f209";
} }
.icon-boosts::before { .icon-bot-commands-filled::before {
content: "\f20a"; content: "\f20a";
} }
.icon-boostcircle::before { .icon-bot-command::before {
content: "\f20b"; content: "\f20b";
} }
.icon-boost::before { .icon-boosts::before {
content: "\f20c"; content: "\f20c";
} }
.icon-boost-outline::before { .icon-boostcircle::before {
content: "\f20d"; content: "\f20d";
} }
.icon-boost-craft-chance::before { .icon-boost::before {
content: "\f20e"; content: "\f20e";
} }
.icon-bold::before { .icon-boost-outline::before {
content: "\f20f"; content: "\f20f";
} }
.icon-avatar-saved-messages::before { .icon-boost-craft-chance::before {
content: "\f210"; content: "\f210";
} }
.icon-avatar-deleted-account::before { .icon-bold::before {
content: "\f211"; content: "\f211";
} }
.icon-avatar-archived-chats::before { .icon-avatar-saved-messages::before {
content: "\f212"; content: "\f212";
} }
.icon-author-hidden::before { .icon-avatar-deleted-account::before {
content: "\f213"; content: "\f213";
} }
.icon-auction::before { .icon-avatar-archived-chats::before {
content: "\f214"; content: "\f214";
} }
.icon-auction-next-round::before { .icon-author-hidden::before {
content: "\f215"; content: "\f215";
} }
.icon-auction-filled::before { .icon-auction::before {
content: "\f216"; content: "\f216";
} }
.icon-auction-drop::before { .icon-auction-next-round::before {
content: "\f217"; content: "\f217";
} }
.icon-attach::before { .icon-auction-filled::before {
content: "\f218"; content: "\f218";
} }
.icon-ask-support::before { .icon-auction-drop::before {
content: "\f219"; content: "\f219";
} }
.icon-arrow-right::before { .icon-attach::before {
content: "\f21a"; content: "\f21a";
} }
.icon-arrow-left::before { .icon-ask-support::before {
content: "\f21b"; content: "\f21b";
} }
.icon-arrow-down::before { .icon-arrow-right::before {
content: "\f21c"; content: "\f21c";
} }
.icon-arrow-down-circle::before { .icon-arrow-left::before {
content: "\f21d"; content: "\f21d";
} }
.icon-archive::before { .icon-arrow-down::before {
content: "\f21e"; content: "\f21e";
} }
.icon-archive-to-main::before { .icon-arrow-down-circle::before {
content: "\f21f"; content: "\f21f";
} }
.icon-archive-from-main::before { .icon-archive::before {
content: "\f220"; content: "\f220";
} }
.icon-archive-filled::before { .icon-archive-to-main::before {
content: "\f221"; content: "\f221";
} }
.icon-animations::before { .icon-archive-from-main::before {
content: "\f222"; content: "\f222";
} }
.icon-animals::before { .icon-archive-filled::before {
content: "\f223"; content: "\f223";
} }
.icon-allow-speak::before { .icon-animations::before {
content: "\f224"; content: "\f224";
} }
.icon-allow-share::before { .icon-animals::before {
content: "\f225"; content: "\f225";
} }
.icon-admin::before { .icon-allow-speak::before {
content: "\f226"; content: "\f226";
} }
.icon-add::before { .icon-allow-share::before {
content: "\f227"; content: "\f227";
} }
.icon-add-user::before { .icon-admin::before {
content: "\f228"; content: "\f228";
} }
.icon-add-user-filled::before { .icon-add::before {
content: "\f229"; content: "\f229";
} }
.icon-add-one-badge::before { .icon-add-user::before {
content: "\f22a"; content: "\f22a";
} }
.icon-add-filled::before { .icon-add-user-filled::before {
content: "\f22b"; content: "\f22b";
} }
.icon-add-caption::before { .icon-add-one-badge::before {
content: "\f22c"; content: "\f22c";
} }
.icon-active-sessions::before { .icon-add-filled::before {
content: "\f22d"; content: "\f22d";
} }
.icon-rating-icons-negative::before { .icon-add-caption::before {
content: "\f22e"; content: "\f22e";
} }
.icon-rating-icons-level90::before { .icon-active-sessions::before {
content: "\f22f"; content: "\f22f";
} }
.icon-rating-icons-level9::before { .icon-rating-icons-negative::before {
content: "\f230"; content: "\f230";
} }
.icon-rating-icons-level80::before { .icon-rating-icons-level90::before {
content: "\f231"; content: "\f231";
} }
.icon-rating-icons-level8::before { .icon-rating-icons-level9::before {
content: "\f232"; content: "\f232";
} }
.icon-rating-icons-level70::before { .icon-rating-icons-level80::before {
content: "\f233"; content: "\f233";
} }
.icon-rating-icons-level7::before { .icon-rating-icons-level8::before {
content: "\f234"; content: "\f234";
} }
.icon-rating-icons-level60::before { .icon-rating-icons-level70::before {
content: "\f235"; content: "\f235";
} }
.icon-rating-icons-level6::before { .icon-rating-icons-level7::before {
content: "\f236"; content: "\f236";
} }
.icon-rating-icons-level50::before { .icon-rating-icons-level60::before {
content: "\f237"; content: "\f237";
} }
.icon-rating-icons-level5::before { .icon-rating-icons-level6::before {
content: "\f238"; content: "\f238";
} }
.icon-rating-icons-level40::before { .icon-rating-icons-level50::before {
content: "\f239"; content: "\f239";
} }
.icon-rating-icons-level4::before { .icon-rating-icons-level5::before {
content: "\f23a"; content: "\f23a";
} }
.icon-rating-icons-level30::before { .icon-rating-icons-level40::before {
content: "\f23b"; content: "\f23b";
} }
.icon-rating-icons-level3::before { .icon-rating-icons-level4::before {
content: "\f23c"; content: "\f23c";
} }
.icon-rating-icons-level20::before { .icon-rating-icons-level30::before {
content: "\f23d"; content: "\f23d";
} }
.icon-rating-icons-level2::before { .icon-rating-icons-level3::before {
content: "\f23e"; content: "\f23e";
} }
.icon-rating-icons-level10::before { .icon-rating-icons-level20::before {
content: "\f23f"; content: "\f23f";
} }
.icon-rating-icons-level1::before { .icon-rating-icons-level2::before {
content: "\f240"; content: "\f240";
} }
.icon-folder-tabs-user::before { .icon-rating-icons-level10::before {
content: "\f241"; content: "\f241";
} }
.icon-folder-tabs-star::before { .icon-rating-icons-level1::before {
content: "\f242"; content: "\f242";
} }
.icon-folder-tabs-group::before { .icon-folder-tabs-user::before {
content: "\f243"; content: "\f243";
} }
.icon-folder-tabs-folder::before { .icon-folder-tabs-star::before {
content: "\f244"; content: "\f244";
} }
.icon-folder-tabs-chats::before { .icon-folder-tabs-group::before {
content: "\f245"; content: "\f245";
} }
.icon-folder-tabs-chat::before { .icon-folder-tabs-folder::before {
content: "\f246"; content: "\f246";
} }
.icon-folder-tabs-channel::before { .icon-folder-tabs-chats::before {
content: "\f247"; content: "\f247";
} }
.icon-folder-tabs-bot::before { .icon-folder-tabs-chat::before {
content: "\f248"; content: "\f248";
} }
.icon-folder-tabs-channel::before {
content: "\f249";
}
.icon-folder-tabs-bot::before {
content: "\f24a";
}

View File

@ -157,191 +157,193 @@ $icons-map: (
"no-download": "\f18b", "no-download": "\f18b",
"next": "\f18c", "next": "\f18c",
"next-link": "\f18d", "next-link": "\f18d",
"new-chat-filled": "\f18e", "new-send": "\f18e",
"my-notes": "\f18f", "new-chat-filled": "\f18f",
"muted": "\f190", "my-notes": "\f190",
"mute": "\f191", "muted": "\f191",
"move-caption-up": "\f192", "mute": "\f192",
"move-caption-down": "\f193", "move-caption-up": "\f193",
"more": "\f194", "move-caption-down": "\f194",
"more-circle": "\f195", "more": "\f195",
"monospace": "\f196", "more-circle": "\f196",
"microphone": "\f197", "monospace": "\f197",
"microphone-alt": "\f198", "microphone": "\f198",
"message": "\f199", "microphone-alt": "\f199",
"message-succeeded": "\f19a", "message": "\f19a",
"message-read": "\f19b", "message-succeeded": "\f19b",
"message-pending": "\f19c", "message-read": "\f19c",
"message-failed": "\f19d", "message-pending": "\f19d",
"menu": "\f19e", "message-failed": "\f19e",
"mention": "\f19f", "menu": "\f19f",
"loop": "\f1a0", "mention": "\f1a0",
"logout": "\f1a1", "loop": "\f1a1",
"lock": "\f1a2", "logout": "\f1a2",
"lock-badge": "\f1a3", "lock": "\f1a3",
"location": "\f1a4", "lock-badge": "\f1a4",
"link": "\f1a5", "location": "\f1a5",
"link-broken": "\f1a6", "link": "\f1a6",
"link-badge": "\f1a7", "link-broken": "\f1a7",
"large-play": "\f1a8", "link-badge": "\f1a8",
"large-pause": "\f1a9", "large-play": "\f1a9",
"language": "\f1aa", "large-pause": "\f1aa",
"lamp": "\f1ab", "language": "\f1ab",
"keyboard": "\f1ac", "lamp": "\f1ac",
"key": "\f1ad", "keyboard": "\f1ad",
"italic": "\f1ae", "key": "\f1ae",
"install": "\f1af", "italic": "\f1af",
"info": "\f1b0", "install": "\f1b0",
"info-filled": "\f1b1", "info": "\f1b1",
"help": "\f1b2", "info-filled": "\f1b2",
"heart": "\f1b3", "help": "\f1b3",
"heart-outline": "\f1b4", "heart": "\f1b4",
"hd-photo": "\f1b5", "heart-outline": "\f1b5",
"hashtag": "\f1b6", "hd-photo": "\f1b6",
"hand-stop": "\f1b7", "hashtag": "\f1b7",
"hand-stop-filled": "\f1b8", "hand-stop": "\f1b8",
"grouped": "\f1b9", "hand-stop-filled": "\f1b9",
"grouped-disable": "\f1ba", "grouped": "\f1ba",
"group": "\f1bb", "grouped-disable": "\f1bb",
"group-filled": "\f1bc", "group": "\f1bc",
"gift": "\f1bd", "group-filled": "\f1bd",
"gift-transfer-inline": "\f1be", "gift": "\f1be",
"gifs": "\f1bf", "gift-transfer-inline": "\f1bf",
"fullscreen": "\f1c0", "gifs": "\f1c0",
"frozen-time": "\f1c1", "fullscreen": "\f1c1",
"fragment": "\f1c2", "frozen-time": "\f1c2",
"forward": "\f1c3", "fragment": "\f1c3",
"forums": "\f1c4", "forward": "\f1c4",
"fontsize": "\f1c5", "forums": "\f1c5",
"folder": "\f1c6", "fontsize": "\f1c6",
"folder-badge": "\f1c7", "folder": "\f1c7",
"flip": "\f1c8", "folder-badge": "\f1c8",
"flag": "\f1c9", "flip": "\f1c9",
"file-badge": "\f1ca", "flag": "\f1ca",
"favorite": "\f1cb", "file-badge": "\f1cb",
"favorite-filled": "\f1cc", "favorite": "\f1cc",
"eye": "\f1cd", "favorite-filled": "\f1cd",
"eye-outline": "\f1ce", "eye": "\f1ce",
"eye-crossed": "\f1cf", "eye-outline": "\f1cf",
"eye-crossed-outline": "\f1d0", "eye-crossed": "\f1d0",
"expand": "\f1d1", "eye-crossed-outline": "\f1d1",
"expand-modal": "\f1d2", "expand": "\f1d2",
"enter": "\f1d3", "expand-modal": "\f1d3",
"email": "\f1d4", "enter": "\f1d4",
"edit": "\f1d5", "email": "\f1d5",
"eats": "\f1d6", "edit": "\f1d6",
"dropdown-arrows": "\f1d7", "eats": "\f1d7",
"download": "\f1d8", "dropdown-arrows": "\f1d8",
"down": "\f1d9", "download": "\f1d9",
"double-badge": "\f1da", "down": "\f1da",
"document": "\f1db", "double-badge": "\f1db",
"diamond": "\f1dc", "document": "\f1dc",
"delete": "\f1dd", "diamond": "\f1dd",
"delete-user": "\f1de", "delete": "\f1de",
"delete-left": "\f1df", "delete-user": "\f1df",
"delete-filled": "\f1e0", "delete-left": "\f1e0",
"data": "\f1e1", "delete-filled": "\f1e1",
"darkmode": "\f1e2", "data": "\f1e2",
"crown-wear": "\f1e3", "darkmode": "\f1e3",
"crown-wear-outline": "\f1e4", "crown-wear": "\f1e4",
"crown-take-off": "\f1e5", "crown-wear-outline": "\f1e5",
"crown-take-off-outline": "\f1e6", "crown-take-off": "\f1e6",
"crop": "\f1e7", "crown-take-off-outline": "\f1e7",
"craft": "\f1e8", "crop": "\f1e8",
"copy": "\f1e9", "craft": "\f1e9",
"copy-media": "\f1ea", "copy": "\f1ea",
"comments": "\f1eb", "copy-media": "\f1eb",
"comments-sticker": "\f1ec", "comments": "\f1ec",
"combine-craft": "\f1ed", "comments-sticker": "\f1ed",
"colorize": "\f1ee", "combine-craft": "\f1ee",
"collapse": "\f1ef", "colorize": "\f1ef",
"collapse-modal": "\f1f0", "collapse": "\f1f0",
"cloud-download": "\f1f1", "collapse-modal": "\f1f1",
"closed-gift": "\f1f2", "cloud-download": "\f1f2",
"close": "\f1f3", "closed-gift": "\f1f3",
"close-topic": "\f1f4", "close": "\f1f4",
"close-circle": "\f1f5", "close-topic": "\f1f5",
"clock": "\f1f6", "close-circle": "\f1f6",
"clock-edit": "\f1f7", "clock": "\f1f7",
"check": "\f1f8", "clock-edit": "\f1f8",
"chats-badge": "\f1f9", "check": "\f1f9",
"chat-badge": "\f1fa", "check-bold": "\f1fa",
"channelviews": "\f1fb", "chats-badge": "\f1fb",
"channel": "\f1fc", "chat-badge": "\f1fc",
"channel-filled": "\f1fd", "channelviews": "\f1fd",
"cash-circle": "\f1fe", "channel": "\f1fe",
"card": "\f1ff", "channel-filled": "\f1ff",
"car": "\f200", "cash-circle": "\f200",
"camera": "\f201", "card": "\f201",
"camera-add": "\f202", "car": "\f202",
"calendar": "\f203", "camera": "\f203",
"calendar-filter": "\f204", "camera-add": "\f204",
"bug": "\f205", "calendar": "\f205",
"brush": "\f206", "calendar-filter": "\f206",
"bots": "\f207", "bug": "\f207",
"bot-commands-filled": "\f208", "brush": "\f208",
"bot-command": "\f209", "bots": "\f209",
"boosts": "\f20a", "bot-commands-filled": "\f20a",
"boostcircle": "\f20b", "bot-command": "\f20b",
"boost": "\f20c", "boosts": "\f20c",
"boost-outline": "\f20d", "boostcircle": "\f20d",
"boost-craft-chance": "\f20e", "boost": "\f20e",
"bold": "\f20f", "boost-outline": "\f20f",
"avatar-saved-messages": "\f210", "boost-craft-chance": "\f210",
"avatar-deleted-account": "\f211", "bold": "\f211",
"avatar-archived-chats": "\f212", "avatar-saved-messages": "\f212",
"author-hidden": "\f213", "avatar-deleted-account": "\f213",
"auction": "\f214", "avatar-archived-chats": "\f214",
"auction-next-round": "\f215", "author-hidden": "\f215",
"auction-filled": "\f216", "auction": "\f216",
"auction-drop": "\f217", "auction-next-round": "\f217",
"attach": "\f218", "auction-filled": "\f218",
"ask-support": "\f219", "auction-drop": "\f219",
"arrow-right": "\f21a", "attach": "\f21a",
"arrow-left": "\f21b", "ask-support": "\f21b",
"arrow-down": "\f21c", "arrow-right": "\f21c",
"arrow-down-circle": "\f21d", "arrow-left": "\f21d",
"archive": "\f21e", "arrow-down": "\f21e",
"archive-to-main": "\f21f", "arrow-down-circle": "\f21f",
"archive-from-main": "\f220", "archive": "\f220",
"archive-filled": "\f221", "archive-to-main": "\f221",
"animations": "\f222", "archive-from-main": "\f222",
"animals": "\f223", "archive-filled": "\f223",
"allow-speak": "\f224", "animations": "\f224",
"allow-share": "\f225", "animals": "\f225",
"admin": "\f226", "allow-speak": "\f226",
"add": "\f227", "allow-share": "\f227",
"add-user": "\f228", "admin": "\f228",
"add-user-filled": "\f229", "add": "\f229",
"add-one-badge": "\f22a", "add-user": "\f22a",
"add-filled": "\f22b", "add-user-filled": "\f22b",
"add-caption": "\f22c", "add-one-badge": "\f22c",
"active-sessions": "\f22d", "add-filled": "\f22d",
"rating-icons-negative": "\f22e", "add-caption": "\f22e",
"rating-icons-level90": "\f22f", "active-sessions": "\f22f",
"rating-icons-level9": "\f230", "rating-icons-negative": "\f230",
"rating-icons-level80": "\f231", "rating-icons-level90": "\f231",
"rating-icons-level8": "\f232", "rating-icons-level9": "\f232",
"rating-icons-level70": "\f233", "rating-icons-level80": "\f233",
"rating-icons-level7": "\f234", "rating-icons-level8": "\f234",
"rating-icons-level60": "\f235", "rating-icons-level70": "\f235",
"rating-icons-level6": "\f236", "rating-icons-level7": "\f236",
"rating-icons-level50": "\f237", "rating-icons-level60": "\f237",
"rating-icons-level5": "\f238", "rating-icons-level6": "\f238",
"rating-icons-level40": "\f239", "rating-icons-level50": "\f239",
"rating-icons-level4": "\f23a", "rating-icons-level5": "\f23a",
"rating-icons-level30": "\f23b", "rating-icons-level40": "\f23b",
"rating-icons-level3": "\f23c", "rating-icons-level4": "\f23c",
"rating-icons-level20": "\f23d", "rating-icons-level30": "\f23d",
"rating-icons-level2": "\f23e", "rating-icons-level3": "\f23e",
"rating-icons-level10": "\f23f", "rating-icons-level20": "\f23f",
"rating-icons-level1": "\f240", "rating-icons-level2": "\f240",
"folder-tabs-user": "\f241", "rating-icons-level10": "\f241",
"folder-tabs-star": "\f242", "rating-icons-level1": "\f242",
"folder-tabs-group": "\f243", "folder-tabs-user": "\f243",
"folder-tabs-folder": "\f244", "folder-tabs-star": "\f244",
"folder-tabs-chats": "\f245", "folder-tabs-group": "\f245",
"folder-tabs-chat": "\f246", "folder-tabs-folder": "\f246",
"folder-tabs-channel": "\f247", "folder-tabs-chats": "\f247",
"folder-tabs-bot": "\f248", "folder-tabs-chat": "\f248",
"folder-tabs-channel": "\f249",
"folder-tabs-bot": "\f24a",
); );

Binary file not shown.

Binary file not shown.

View File

@ -140,6 +140,7 @@ export type FontIconName =
| 'no-download' | 'no-download'
| 'next' | 'next'
| 'next-link' | 'next-link'
| 'new-send'
| 'new-chat-filled' | 'new-chat-filled'
| 'my-notes' | 'my-notes'
| 'muted' | 'muted'
@ -247,6 +248,7 @@ export type FontIconName =
| 'clock' | 'clock'
| 'clock-edit' | 'clock-edit'
| 'check' | 'check'
| 'check-bold'
| 'chats-badge' | 'chats-badge'
| 'chat-badge' | 'chat-badge'
| 'channelviews' | 'channelviews'

View File

@ -262,6 +262,7 @@ export interface LangPair {
'DialogPin': undefined; 'DialogPin': undefined;
'ConversationPinMessageAlertPinAndNotifyMembers': undefined; 'ConversationPinMessageAlertPinAndNotifyMembers': undefined;
'SavedMessages': undefined; 'SavedMessages': undefined;
'SavedMessagesShort': undefined;
'AccDescrPrevious': undefined; 'AccDescrPrevious': undefined;
'ReportReasonSpam': undefined; 'ReportReasonSpam': undefined;
'ReportReasonViolence': undefined; 'ReportReasonViolence': undefined;
@ -526,6 +527,8 @@ export interface LangPair {
'ContactShare': undefined; 'ContactShare': undefined;
'OK': undefined; 'OK': undefined;
'ForwardTo': undefined; 'ForwardTo': undefined;
'ShareWith': undefined;
'SelectChats': undefined;
'AttachGame': undefined; 'AttachGame': undefined;
'JumpToDate': undefined; 'JumpToDate': undefined;
'FloodWait': undefined; 'FloodWait': undefined;
@ -1978,8 +1981,6 @@ export interface LangPair {
'AttachmentMenuUngroupAllMedia': undefined; 'AttachmentMenuUngroupAllMedia': undefined;
'AttachmentMenuEnableSpoiler': undefined; 'AttachmentMenuEnableSpoiler': undefined;
'AttachmentMenuDisableSpoiler': undefined; 'AttachmentMenuDisableSpoiler': undefined;
'AttachmentSendGif': undefined;
'AttachmentReplaceGif': undefined;
'AttachmentDragAddItems': undefined; 'AttachmentDragAddItems': undefined;
'AttachmentCaptionPlaceholder': undefined; 'AttachmentCaptionPlaceholder': undefined;
'MessageSummaryTitle': undefined; 'MessageSummaryTitle': undefined;
@ -2187,6 +2188,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'ConversationOpenBotLinkAllowMessages': { 'ConversationOpenBotLinkAllowMessages': {
'bot': V; 'bot': V;
}; };
'ForwardForStars': {
'price': V;
};
'BlockUserTitle': { 'BlockUserTitle': {
'user': V; 'user': V;
}; };
@ -3085,6 +3089,10 @@ export interface LangPairWithVariables<V = LangVariable> {
'ComposerPlaceholderPaidReply': { 'ComposerPlaceholderPaidReply': {
'amount': V; 'amount': V;
}; };
'ForwardPaidChatsConfirmation': {
'chatsSelected': V;
'payConfirmation': V;
};
'MessageSentPaidToastText': { 'MessageSentPaidToastText': {
'amount': V; 'amount': V;
}; };
@ -3910,6 +3918,13 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'PayForMessage': { 'PayForMessage': {
'count': V; 'count': V;
}; };
'ForwardPaidChatsSelected': {
'paidChatsCount': V;
};
'ForwardPaidChatsPayConfirmation': {
'totalAmount': V;
'count': V;
};
'MessageSentPaidToastTitle': { 'MessageSentPaidToastTitle': {
'count': V; 'count': V;
}; };
@ -4045,6 +4060,9 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'GiftPreviewCountBackdrops': { 'GiftPreviewCountBackdrops': {
'count': V; 'count': V;
}; };
'FwdMessagesToChats': {
'count': V;
};
} }
export type RegularLangKey = keyof LangPair; export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables; export type RegularLangKeyWithVariables = keyof LangPairWithVariables;