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
lang('PluralKey', undefined, { pluralValue: 3 });
lang('PluralKey', { count: 3 }, { pluralValue: 3 }); // if key has variables
// String replacements
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}";
"ConversationPinMessageAlertPinAndNotifyMembers" = "Pin and notify all members";
"SavedMessages" = "Saved Messages";
"SavedMessagesShort" = "Saved";
"AccDescrPrevious" = "Previous";
"ReportReasonSpam" = "Spam";
"ReportReasonViolence" = "Violence";
@ -584,6 +585,8 @@
"ContactShare" = "Share Contact";
"OK" = "OK";
"ForwardTo" = "Forward to...";
"ShareWith" = "Share with";
"SelectChats" = "Select chats";
"AttachGame" = "Game";
"JumpToDate" = "Jump to Date";
"FloodWait" = "Too many attempts, please try again later.";
@ -617,6 +620,7 @@
"WebAppAddToAttachmentAdd" = "Add";
"AccActionDownload" = "Download";
"Forward" = "Forward";
"ForwardForStars" = "Forward for {price}";
"MediaZoomOut" = "Zoom Out";
"MediaZoomIn" = "Zoom In";
"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?**";
"PayForMessage_one" = "Pay for {count} message";
"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_other" = "{count} messages sent!";
"MessageSentPaidToastText" = "You paid {amount}";
@ -2690,8 +2699,6 @@
"AttachmentSendAudio_other" = "Send {count} Audios";
"AttachmentSendFile_one" = "Send File";
"AttachmentSendFile_other" = "Send {count} Files";
"AttachmentSendGif" = "Send GIF";
"AttachmentReplaceGif" = "Replace GIF";
"AttachmentDragAddItems" = "Add Items";
"AttachmentCaptionPlaceholder" = "Add a caption...";
"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.";
"RankEditText" = "Add a short tag next to {user}'s name.";
"MenuAddCaption" = "Add Caption";
"FwdMessagesToChats_one" = "Message forwarded to {count} chat";
"FwdMessagesToChats_other" = "Message forwarded to {count} chats";
"MenuCopyDate" = "Copy Date";
"DateCopiedToast" = "Date copied to clipboard";
"ReminderSetToast" = "You set up a reminder in **Saved Messages**";

View File

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

View File

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

View File

@ -185,7 +185,7 @@
padding-inline: 0.25rem;
}
&.smaller {
&.size-small {
--background-color: var(--color-background);
--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"] {
.file-progress,

View File

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

View File

@ -8,7 +8,7 @@
align-items: center;
min-width: 0;
height: 2rem;
height: var(--chip-size, 2rem);
margin-inline: 0.25rem;
padding-right: 0.75rem;
border-radius: 1rem;
@ -47,12 +47,17 @@
cursor: default;
}
&.small {
margin-inline: 0;
border-radius: 1.4375rem;
}
.avatar,
.iconWrapper {
flex-shrink: 0;
width: 2rem;
height: 2rem;
width: var(--chip-size, 2rem);
height: var(--chip-size, 2rem);
opacity: 1;
@ -102,8 +107,8 @@
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: var(--chip-size, 2rem);
height: var(--chip-size, 2rem);
border-radius: 50%;
font-size: 1.5rem;
@ -118,10 +123,9 @@
&.squareAvatar {
--border-radius-forum-avatar: 0.625rem;
border-start-start-radius: 0.625rem;
border-end-start-radius: 0.625rem;
border-radius: 0.625rem;
&.minimized, .remove {
.remove {
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 buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import { REM } from './helpers/mediaDimensions';
import useLang from '../../hooks/useLang';
import usePeerColor from '../../hooks/usePeerColor';
@ -20,10 +21,14 @@ import Icon from './icons/Icon';
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> = {
peerId?: string;
size?: PeerChipSize;
forceShowSelf?: boolean;
customPeer?: CustomPeer;
mockPeer?: ApiPeer;
@ -33,11 +38,11 @@ type OwnProps<T = undefined> = {
canClose?: boolean;
isCloseNonDestructive?: boolean;
className?: string;
itemClassName?: string;
withPeerColors?: boolean;
withEmojiStatus?: boolean;
clickArg?: T;
onClick?: (arg: T) => void;
itemClassName?: string;
};
type StateProps = {
@ -49,6 +54,7 @@ type StateProps = {
const PeerChip = <T,>({
icon,
title,
size = 'medium',
isMinimized,
canClose,
isCloseNonDestructive,
@ -57,10 +63,10 @@ const PeerChip = <T,>({
mockPeer,
customPeer,
className,
itemClassName,
isSavedMessages,
withPeerColors,
withEmojiStatus,
itemClassName,
theme,
onClick,
}: OwnProps<T> & StateProps) => {
@ -106,6 +112,7 @@ const PeerChip = <T,>({
const fullClassName = buildClassName(
styles.root,
size === 'small' && styles.small,
(chat?.isForum || customPeer?.isAvatarSquare) && styles.squareAvatar,
isMinimized && styles.minimized,
canClose && styles.closeable,
@ -115,7 +122,10 @@ const PeerChip = <T,>({
className,
);
const chipSize = size === 'small' ? CHIP_SIZE_SMALL : CHIP_SIZE_MEDIUM;
const style = buildStyle(
`--chip-size: ${chipSize}px`,
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 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 {
getCanPostInChat,
getChatTitle,
getHasAdminRight,
isChatChannel,
isDeletedUser,
isSystemBot,
} from '../../global/helpers';
import { filterPeersByQuery } from '../../global/helpers/peers';
import {
filterChatIdsByType, selectChat, selectChatFullInfo, selectIsMonoforumAdmin, selectUser,
filterChatIdsByType, selectCanAnimateInterface, selectChat, selectChatFullInfo, selectIsMonoforumAdmin,
selectTopic, selectUser,
} from '../../global/selectors';
import { selectCurrentLimit } from '../../global/selectors/limits';
import buildClassName from '../../util/buildClassName';
import { unique } from '../../util/iteratees';
import sortChatIds from './helpers/sortChatIds';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import { useFolderManagerForOrderedIds } from '../../hooks/useFolderManager';
import useFolderTabs from '../../hooks/useFolderTabs';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import { useStateRef } from '../../hooks/useStateRef';
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 = {
isOpen: boolean;
title?: string;
searchPlaceholder: string;
className?: string;
filter?: readonly ApiChatType[];
isLowStackPriority?: boolean;
isForwarding?: boolean;
isMultiSelect?: boolean;
withFolders?: boolean;
footer?: TeactNode;
viewportFooter?: TeactNode;
loadMore?: NoneToVoidFunction;
onSelectRecipient: (peerId: string, threadId?: ThreadId) => void;
onSelectedIdsChange?: (ids: string[]) => void;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
};
@ -52,6 +68,8 @@ type StateProps = {
maxFolders: number;
};
const RECENT_CONTACTS_LIMIT = 15;
const RecipientPicker = ({
isOpen,
currentUserId,
@ -60,21 +78,34 @@ const RecipientPicker = ({
pinnedIds,
contactIds,
filter = API_CHAT_TYPES,
title,
className,
searchPlaceholder,
isLowStackPriority,
chatFoldersById,
orderedFolderIds,
isForwarding,
isMultiSelect,
maxFolders,
withFolders,
footer,
viewportFooter,
loadMore,
onSelectRecipient,
onSelectedIdsChange,
onClose,
onCloseAnimationEnd,
}: OwnProps & StateProps) => {
const { openLimitReachedModal } = getActions();
const lang = useLang();
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 { displayedFolders, folderTabs } = useFolderTabs({
@ -85,7 +116,7 @@ const RecipientPicker = ({
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 orderedChatIds = useFolderManagerForOrderedIds(displayedFolderId);
@ -103,6 +134,57 @@ const RecipientPicker = ({
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(() => {
if (!isOpen) return [];
@ -120,6 +202,8 @@ const RecipientPicker = ({
];
const peerIds = allIds.filter((id) => {
if (isSystemBot(id)) return false;
const chat = selectChat(global, id);
const user = selectUser(global, id);
const hasAdminRights = chat && getHasAdminRight(chat, 'postMessages');
@ -171,16 +255,145 @@ const RecipientPicker = ({
const renderingIds = useCurrentOrPrev(ids, true);
const chatFolders = useMemo(() => {
if (!shouldRenderFolders) return undefined;
const recentContactIds = useMemo(() => {
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 (
<TabList
tabs={folderTabs}
activeTab={activeFolderIndex}
onSwitchTab={handleSwitchFolderIndex}
/>
<div className="search-row-with-chips">
<div className="chips-and-search-scroll no-scrollbar">
{selectedIds.map((selectionId) => {
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 (
<ChatOrUserPicker
@ -188,13 +401,18 @@ const RecipientPicker = ({
className={className}
chatOrUserIds={renderingIds}
currentUserId={currentUserId}
title={title}
searchPlaceholder={searchPlaceholder}
search={search}
subheader={chatFolders}
renderSearchRow={renderSearchRow}
subheader={subheaderContent}
footer={footer}
viewportFooter={viewportFooter}
listActiveKey={activeFolderIndex}
selectedIds={isMultiSelect ? selectedIds : undefined}
onSearchChange={setSearch}
loadMore={loadMore}
onSelectChatOrUser={onSelectRecipient}
onSelectChatOrUser={handleSelect}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
isLowStackPriority={isLowStackPriority}

View File

@ -192,18 +192,26 @@ export function getPictogramDimensions(): ApiDimensions {
};
}
export function getDocumentThumbnailDimensions(smaller?: boolean): ApiDimensions {
if (smaller) {
return {
width: 3 * REM,
height: 3 * REM,
};
export function getDocumentThumbnailDimensions(
size: 'small' | 'medium' | 'large' = 'medium',
): ApiDimensions {
switch (size) {
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 {

View File

@ -1,13 +1,49 @@
@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 {
.modal-dialog {
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) {
height: 90%;
max-height: 90%;
}
}
@ -16,40 +52,144 @@
flex-direction: column;
}
.modal-header {
.picker-header {
display: flex;
flex-direction: column;
align-items: stretch;
.search-wrapper {
display: flex;
&-topics {
flex-direction: row;
gap: 0.5rem;
align-items: center;
.Button {
margin-right: 0.5rem;
.picker-back-button {
flex-shrink: 0;
}
.input-group {
.search-input-wrapper,
.search-row-with-chips {
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 {
margin-bottom: -0.375rem;
margin-inline: -1rem;
.search-input-wrapper {
position: relative;
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;
display: flex;
flex-direction: column;
padding: 0 !important;
padding-block: 0 !important;
padding-inline: 1rem !important;
> .Transition {
overflow: hidden;
@ -69,10 +211,15 @@
overflow-y: auto;
height: 100%;
padding-block: 0.125rem;
margin-top: 1rem;
margin-bottom: 1rem;
padding-block: 0.375rem;
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-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 {
display: flex;
align-items: center;
@ -97,8 +252,16 @@
color: var(--color-text-secondary);
}
.scroll-container {
position: relative;
.picker-list-loading {
display: flex;
align-items: center;
justify-content: center;
}
.picker-list-spacer {
position: absolute;
width: 100%;
height: 1.25rem;
}
.ChatOrUserPicker-item {
@ -108,6 +271,86 @@
.online {
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 {
@ -143,4 +386,84 @@
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 {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
@ -34,14 +34,13 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Button from '../../ui/Button';
import InfiniteScroll, { type OwnProps as InfiniteScrollProps } from '../../ui/InfiniteScroll';
import InputText from '../../ui/InputText';
import Loading from '../../ui/Loading';
import Modal from '../../ui/Modal';
import Transition from '../../ui/Transition';
import Avatar from '../Avatar';
import FullNameTitle from '../FullNameTitle';
import Icon from '../icons/Icon';
import TopicIcon from '../TopicIcon';
import PickerItem from './PickerItem';
@ -51,12 +50,17 @@ export type OwnProps = {
currentUserId?: string;
chatOrUserIds: string[];
isOpen: boolean;
title?: string;
searchPlaceholder: string;
search: string;
className?: string;
isLowStackPriority?: boolean;
listActiveKey?: number;
subheader?: TeactNode;
renderSearchRow?: (props: SearchRowRenderProps) => TeactNode;
footer?: TeactNode;
viewportFooter?: TeactNode;
selectedIds?: string[];
loadMore?: NoneToVoidFunction;
onSearchChange: (search: string) => 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 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 = ({
isOpen,
currentUserId,
chatOrUserIds,
title,
search,
searchPlaceholder,
className,
isLowStackPriority,
subheader,
renderSearchRow,
footer,
viewportFooter,
listActiveKey,
selectedIds,
animationLevel,
shouldSkipHistoryAnimations,
loadMore,
@ -113,13 +130,13 @@ const ChatOrUserPicker = ({
useInputFocusOnOpen(searchRef, isOpen && activeKey === CHAT_LIST_SLIDE, resetSearch);
useInputFocusOnOpen(topicSearchRef, isOpen && activeKey === TOPIC_LIST_SLIDE);
const selectTopicsById = useLastCallback((global: GlobalState) => {
const selectTopicsById = useCallback((global: GlobalState) => {
if (!forumId) {
return undefined;
}
return selectTopics(global, forumId);
});
}, [forumId]);
const forumTopicsById = useSelector(selectTopicsById);
@ -190,6 +207,8 @@ const ChatOrUserPicker = ({
}
}, `.${ITEM_CLASS_NAME}`, true);
const isMultiSelect = Boolean(selectedIds);
const handleClick = useLastCallback((chatId: string) => {
const chatsById = getGlobal().chats.byId;
const chat = chatsById?.[chatId];
@ -214,7 +233,16 @@ const ChatOrUserPicker = ({
peer = monoforumChannel;
}
const chat = global.chats.byId[id];
const isForum = chat?.isForum;
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() {
if (!peer) return undefined;
@ -232,6 +260,15 @@ const ChatOrUserPicker = ({
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 (
<PickerItem
key={id}
@ -247,13 +284,18 @@ const ChatOrUserPicker = ({
</div>
)}
avatarElement={(
<Avatar
peer={peer}
asMessageBubble={Boolean(monoforumChannel)}
isSavedMessages={isSelf}
size="medium"
/>
<div className="picker-avatar-wrapper">
<Avatar
peer={peer}
asMessageBubble={Boolean(monoforumChannel)}
isSavedMessages={isSelf}
size="medium"
/>
{isForum && <Icon name="forums" className="forum-badge" />}
</div>
)}
inputElement={checkboxElement}
inputPosition="end"
subtitle={subtitle}
subtitleClassName={subtitleClassName}
ripple
@ -262,29 +304,33 @@ const ChatOrUserPicker = ({
onClick={() => handleClick(id)}
/>
);
}, [currentUserId, oldLang, lang, viewportOffset]);
}, [currentUserId, oldLang, lang, viewportOffset, selectedIds]);
function renderTopicList() {
return (
<>
<div className="modal-header modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}>
<div className="search-wrapper">
<Button
round
color="translucent"
size="tiny"
ariaLabel={oldLang('Back')}
onClick={handleHeaderBackClick}
iconName="arrow-left"
/>
<InputText
ref={topicSearchRef}
value={topicSearch}
onChange={handleTopicSearchChange}
onKeyDown={handleTopicKeyDown}
placeholder={searchPlaceholder}
/>
</div>
<div className="picker-header" dir={lang.isRtl ? 'rtl' : undefined}>
{renderSearchRow ? renderSearchRow({
inputRef: topicSearchRef,
value: topicSearch,
placeholder: searchPlaceholder,
onChange: handleTopicSearchChange,
onKeyDown: handleTopicKeyDown,
}) : (
<div className="search-input-wrapper">
<i className="icon icon-search" />
<input
ref={topicSearchRef}
className="search-input"
type="text"
dir="auto"
placeholder={searchPlaceholder}
value={topicSearch}
onChange={handleTopicSearchChange}
onKeyDown={handleTopicKeyDown}
/>
</div>
)}
</div>
{topicIds?.length ? (
<InfiniteScroll
@ -295,29 +341,51 @@ const ChatOrUserPicker = ({
maxHeight={(topicIds?.length || 0) * TOPIC_ITEM_HEIGHT_PX}
onKeyDown={handleTopicKeyDown}
>
{topicIds.map((topicId, i) => (
<PickerItem
key={`${forumId}_${topicId}`}
className={ITEM_CLASS_NAME}
{topicIds.map((topicId, i) => {
const selectionId = `${forumId}:${topicId}`;
const isTopicSelected = selectedIds?.includes(selectionId);
onClick={() => onSelectChatOrUser(forumId!, topicId)}
style={`top: ${i * TOPIC_ITEM_HEIGHT_PX}px;`}
avatarElement={(
<TopicIcon
size={TOPIC_ICON_SIZE}
topic={topics[topicId]}
className="topic-icon"
letterClassName="topic-icon-letter"
/>
)}
title={renderText(topics[topicId].title)}
const topicCheckboxElement = isMultiSelect ? (
<div className={buildClassName('picker-checkbox', isTopicSelected && 'selected')}>
{isTopicSelected && <Icon name="check-bold" />}
</div>
) : undefined;
return (
<PickerItem
key={`${forumId}_${topicId}`}
className={ITEM_CLASS_NAME}
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>
) : topicIds && !topicIds.length ? (
<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() {
return (
<>
<div className="modal-header modal-header-condensed" dir={lang.isRtl ? 'rtl' : undefined}>
<div className="search-wrapper">
<Button
round
color="translucent"
size="tiny"
ariaLabel={oldLang('Close')}
onClick={onClose}
iconName="close"
/>
<InputText
ref={searchRef}
value={search}
onChange={handleSearchChange}
onKeyDown={chatKeyDownHandler}
placeholder={searchPlaceholder}
/>
</div>
<div className="picker-header" dir={lang.isRtl ? 'rtl' : undefined}>
{renderSearchRow ? renderSearchRow({
inputRef: searchRef,
value: search,
placeholder: searchPlaceholder,
onChange: handleSearchChange,
onKeyDown: chatKeyDownHandler,
}) : (
<div className="search-input-wrapper">
<i className="icon icon-search" />
<input
ref={searchRef}
className="search-input"
type="text"
dir="auto"
placeholder={searchPlaceholder}
value={search}
onChange={handleSearchChange}
onKeyDown={chatKeyDownHandler}
/>
</div>
)}
{subheader}
</div>
<Transition
@ -355,6 +427,7 @@ const ChatOrUserPicker = ({
isOpen={isOpen}
viewportIds={viewportIds}
maxHeight={chatOrUserIds.length * PEER_PICKER_ITEM_HEIGHT_PX}
viewportFooter={viewportFooter}
onLoadMore={getMore}
onSelect={handleChatSelect}
renderItem={renderChatItem}
@ -365,12 +438,24 @@ const ChatOrUserPicker = ({
);
}
const handleModalClose = useLastCallback(() => {
if (forumId) {
handleHeaderBackClick();
} else {
onClose();
}
});
return (
<Modal
isOpen={isOpen}
title={title}
hasCloseButton
isBackButton={Boolean(forumId)}
headerClassName="modal-header-condensed-wide"
className={buildClassName('ChatOrUserPicker', className)}
isLowStackPriority={isLowStackPriority}
onClose={onClose}
onClose={handleModalClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
<Transition activeKey={activeKey} name="slideFade" slideClassName="ChatOrUserPicker_slide">
@ -378,6 +463,7 @@ const ChatOrUserPicker = ({
return activeKey === TOPIC_LIST_SLIDE ? renderTopicList() : renderChatList();
}}
</Transition>
{footer}
</Modal>
);
};
@ -386,6 +472,7 @@ type ChatListContentProps = {
isOpen: boolean;
viewportIds?: string[];
maxHeight: number;
viewportFooter?: TeactNode;
onLoadMore: InfiniteScrollProps['onLoadMore'];
onSelect: (index: number) => void;
renderItem: (id: string, index: number) => TeactNode;
@ -396,6 +483,7 @@ function ChatListContent({
isOpen,
viewportIds,
maxHeight,
viewportFooter,
onLoadMore,
onSelect,
onKeyDownHandlerUpdate,
@ -424,6 +512,9 @@ function ChatListContent({
onKeyDown={handleKeyDown}
>
{viewportIds.map(renderItem)}
{Boolean(viewportFooter) && (
<div className="picker-list-spacer" style={`top: ${maxHeight}px`} />
)}
</InfiniteScroll>
) : viewportIds && !viewportIds.length ? (
<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 StoryRibbon from '../../story/StoryRibbon';
import TabList from '../../ui/TabList';
import SquareTabList from '../../ui/SquareTabList';
import Transition from '../../ui/Transition';
import ChatList from './ChatList';
@ -259,7 +259,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
>
{shouldRenderStoryRibbon && <StoryRibbon isClosing={isStoryRibbonClosing} />}
{shouldRenderFolders ? (
<TabList
<SquareTabList
contextRootElementSelector="#LeftColumn"
tabs={folderTabs}
activeTab={activeChatFolder}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import type { TabState } from '../../global/types';
import type { ThreadId } from '../../types';
import useFlag from '../../hooks/useFlag';
import useOldLang from '../../hooks/useOldLang';
import useLang from '../../hooks/useLang';
import RecipientPicker from '../common/RecipientPicker';
@ -25,7 +25,7 @@ const DraftRecipientPicker: FC<OwnProps> = ({
resetOpenChatWithDraft,
} = getActions();
const lang = useOldLang();
const lang = useLang();
const [isShown, markIsShown, unmarkIsShown] = useFlag();
useEffect(() => {
@ -54,7 +54,8 @@ const DraftRecipientPicker: FC<OwnProps> = ({
return (
<RecipientPicker
isOpen={isOpen}
searchPlaceholder={lang('ForwardTo')}
title={lang('ShareWith')}
searchPlaceholder={lang('Search')}
filter={requestedDraft?.filter}
onSelectRecipient={handleSelectRecipient}
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 {
memo, useCallback, useEffect,
memo, useCallback, useEffect, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
@ -8,17 +8,33 @@ import type { ThreadId } from '../../types';
import { getChatTitle, getUserFirstOrLastName } from '../../global/helpers';
import {
selectCanCopyMessageLink,
selectChat,
selectChatMessages,
selectPeerPaidMessagesStars,
selectTabState,
selectUser,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { isUserId } from '../../util/entities/ids';
import { formatStarsAsIcon, formatStarsAsText } from '../../util/localization/format';
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 usePreviousDeprecated from '../../hooks/usePreviousDeprecated';
import AnimatedCounter from '../common/AnimatedCounter';
import Icon from '../common/icons/Icon';
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 = {
isOpen: boolean;
@ -29,6 +45,9 @@ interface StateProps {
isManyMessages?: boolean;
isStory?: boolean;
isForwarding?: boolean;
fromChatId?: string;
forwardMessageIds?: number[];
shouldPaidMessageAutoApprove?: boolean;
}
const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
@ -37,26 +56,80 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
isManyMessages,
isStory,
isForwarding,
fromChatId,
forwardMessageIds,
shouldPaidMessageAutoApprove,
}) => {
const {
openChatOrTopicWithReplyInDraft,
setForwardChatOrTopic,
exitForwardMode,
forwardToSavedMessages,
forwardToMultipleChats,
forwardStory,
showNotification,
copyMessageLink,
openStarsBalanceModal,
setPaidMessageAutoApprove,
} = getActions();
const lang = useOldLang();
const lang = useLang();
const oldLang = useOldLang();
const renderingIsStory = usePreviousDeprecated(isStory, true);
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(() => {
if (isOpen) {
markIsShown();
}
}, [isOpen, markIsShown]);
useEffect(() => {
if (!isOpen) {
setSelectedIds([]);
setCaption('');
}
}, [isOpen]);
const handleSelectRecipient = useCallback((recipientId: string, threadId?: ThreadId) => {
const isSelf = recipientId === currentUserId;
if (isStory) {
@ -65,8 +138,8 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (isUserId(recipientId)) {
showNotification({
message: isSelf
? lang('Conversation.StoryForwardTooltip.SavedMessages.One')
: lang(
? oldLang('Conversation.StoryForwardTooltip.SavedMessages.One')
: oldLang(
'StorySharedTo',
getUserFirstOrLastName(selectUser(global, recipientId)),
),
@ -76,14 +149,14 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (!chat) return;
showNotification({
message: lang('StorySharedTo', getChatTitle(lang, chat)),
message: oldLang('StorySharedTo', getChatTitle(oldLang, chat)),
});
}
return;
}
if (isSelf) {
const message = lang(
const message = oldLang(
isManyMessages
? 'Conversation.ForwardTooltip.SavedMessages.Many'
: 'Conversation.ForwardTooltip.SavedMessages.One',
@ -100,37 +173,239 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
openChatOrTopicWithReplyInDraft({ chatId, topicId });
}
}
}, [currentUserId, isManyMessages, isStory, lang, isForwarding]);
}, [currentUserId, isManyMessages, isStory, oldLang, isForwarding]);
const handleClose = useCallback(() => {
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) {
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 (
<RecipientPicker
isOpen={isOpen}
className={renderingIsStory ? 'component-theme-dark' : undefined}
searchPlaceholder={lang(isForwarding ? 'ForwardTo' : 'ReplyToDialog')}
onSelectRecipient={handleSelectRecipient}
onClose={handleClose}
onCloseAnimationEnd={unmarkIsShown}
isForwarding={isForwarding}
withFolders
/>
<>
<RecipientPicker
isOpen={isOpen}
className={renderingIsStory ? 'component-theme-dark' : undefined}
title={lang('ShareWith')}
searchPlaceholder={lang('Search')}
isMultiSelect={isMultiSelect}
footer={footerContent}
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> => {
const { messageIds, storyId } = selectTabState(global).forwardMessages;
const { messageIds, storyId, fromChatId } = selectTabState(global).forwardMessages;
const isForwarding = (messageIds && messageIds.length > 0);
return {
currentUserId: global.currentUserId,
isManyMessages: (messageIds?.length || 0) > 1,
isStory: Boolean(storyId),
isForwarding,
fromChatId,
forwardMessageIds: messageIds,
shouldPaidMessageAutoApprove: global.settings.byKey.shouldPaidMessageAutoApprove,
};
})(ForwardRecipientPicker));

View File

@ -4,13 +4,15 @@
:global {
.modal-dialog {
max-width: 26.25rem;
border-radius: 2.5rem;
background-color: var(--color-background-secondary);
@media (max-width: 600px) {
max-height: 100%;
}
}
.modal-header-condensed {
.modal-header-condensed-wide {
border-bottom: 1px solid transparent;
transition: border-color 250ms ease-in-out;
}
@ -32,15 +34,16 @@
flex-shrink: 0;
align-self: flex-end;
width: 3.5rem !important;
height: 3.5rem !important;
width: 3rem !important;
height: 3rem !important;
padding: 0 !important;
background: none !important;
}
.symbol-menu-button, .mobile-symbol-menu-button {
margin-right: -1.75rem;
align-self: center;
margin-right: -1.5rem;
margin-left: -0.25rem !important;
color: var(--color-composer-button);
}
@ -59,12 +62,8 @@
&.mobile :global {
.modal-dialog {
align-self: end;
max-width: 100% !important;
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);
}
}
&.header-border :global(.modal-header-condensed) {
border-bottom-color: var(--color-borders);
}
}
.attachments {
scrollbar-width: none;
overflow: auto;
display: flex;
flex-shrink: 1;
flex-wrap: wrap;
gap: 0.5rem;
gap: 0.125rem;
min-height: 5rem;
max-height: 26rem;
margin: 0 0.25rem;
padding: 0 0.25rem;
margin: 0 1rem;
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) {
max-height: 80vh;
}
}
.attachments-bottom-padding {
padding-bottom: 0.5rem;
}
.caption-wrapper {
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;
:global {
@ -147,13 +158,14 @@
position: absolute;
z-index: 2;
left: 0.5rem;
left: 1rem;
overflow: visible !important;
width: calc(100% - 1rem);
height: calc(100% - 0.5rem);
padding-top: 0.5rem;
width: calc(100% - 2rem);
height: calc(100% - 1.125rem);
padding-top: 0.0625rem;
border-radius: 0.25rem;
opacity: 0;
background-color: var(--color-background);
@ -207,12 +219,20 @@
.send-wrapper {
position: relative;
align-self: flex-end;
padding-bottom: 0.5rem;
}
.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 {

View File

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

View File

@ -7,7 +7,6 @@
align-items: center;
min-width: 0;
border-radius: var(--border-radius-default);
}
.preview {
@ -42,12 +41,22 @@
.no-grouping {
flex-basis: 100%;
margin-inline: 0.5rem;
border-radius: 1rem;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-item-hover);
}
}
.file {
flex-grow: 1;
min-width: 0;
margin: 0.5rem;
margin-block: 0.25rem;
margin-inline-start: 0.25rem;
margin-inline-end: 0.75rem;
}
.overlay {
@ -57,12 +66,23 @@
overflow: hidden;
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);
backdrop-filter: blur(10px);
transition: opacity 0.15s ease-in-out;
.root:hover & {
opacity: 1;
}
:global(body.no-menu-blur) & {
background-color: #707579;
backdrop-filter: none;
@ -74,15 +94,15 @@
display: block;
padding: 0.3125rem;
border-radius: var(--border-radius-messages-small);
padding: 0.375rem;
border-radius: 1.125rem;
color: white;
transition: 0.2s background-color ease-in-out;
transition: 0.2s background-color;
&:hover {
background-color: rgba(0, 0, 0, 0.15);
background-color: rgba(0, 0, 0, 0.05);
}
@media (max-width: 600px) {
@ -91,7 +111,16 @@
}
.delete-file {
margin-right: 1rem;
margin-inline-end: 0.625rem;
font-size: 1.5rem;
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 useAppLayout from '../../../hooks/useAppLayout';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import File from '../../common/File';
@ -42,6 +43,7 @@ const AttachmentModalItem = ({
onToggleSpoiler,
onEdit,
}: OwnProps) => {
const lang = useLang();
const { isMobile } = useAppLayout();
const displayType = getDisplayType(attachment, shouldDisplayCompressed);
@ -82,15 +84,16 @@ const AttachmentModalItem = ({
);
default: {
const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile;
const isPhoto = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType);
return (
<>
<File
className={styles.file}
name={attachment.filename}
extension={getFileExtension(attachment.filename, attachment.mimeType)}
previewData={attachment.previewBlobUrl}
previewData={isPhoto && attachment.blobUrl ? attachment.blobUrl : attachment.previewBlobUrl}
size={attachment.size}
smaller
previewSize="large"
onClick={canEdit ? handleEditClick : undefined}
actionIcon={canEdit ? 'edit' : undefined}
/>
@ -117,7 +120,7 @@ const AttachmentModalItem = ({
);
return (
<div className={rootClassName}>
<div className={rootClassName} dir={lang.isRtl ? 'rtl' : undefined}>
{content}
<MediaSpoiler
isVisible={shouldDisplaySpoiler}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ export default function useTransitionFixes(
function setMinHeight() {
const container = containerRef.current!;
const transitionEl = container.querySelector<HTMLDivElement>(transitionElSelector);
const tabsEl = container.querySelector<HTMLDivElement>('.TabList');
const tabsEl = container.querySelector<HTMLDivElement>('.SquareTabList');
if (transitionEl && tabsEl) {
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 { selectChatFullInfo } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker';
@ -37,7 +37,7 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps> = ({
deleteChatMember,
} = getActions();
const lang = useOldLang();
const lang = useLang();
const [search, setSearch] = useState('');
const usersId = useMemo(() => {
@ -65,7 +65,8 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps> = ({
<ChatOrUserPicker
isOpen={isOpen}
chatOrUserIds={usersId}
searchPlaceholder={lang('ChannelBlockUser')}
title={lang('ChannelBlockUser')}
searchPlaceholder={lang('Search')}
search={search}
onSearchChange={setSearch}
loadMore={handleLoadMore}

View File

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

View File

@ -145,11 +145,11 @@
}
}
.modal-header-condensed-wide,
.modal-header-condensed {
@extend %modal-header;
min-height: 3.5rem;
padding: 0.375rem 0.75rem !important;
.modal-action-button {
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 {
overflow-y: auto;
flex-grow: 1;

View File

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

View File

@ -1,4 +1,4 @@
.TabList {
.SquareTabList {
scrollbar-color: rgba(0, 0, 0, 0);
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 } from '../../lib/teact/teact';
import { memo, useEffect, useRef, useState } from '../../lib/teact/teact';
import type { ApiMessageEntityCustomEmoji } from '../../api/types';
import type { MenuItemContextAction } from './ListItem';
import type { TabWithProperties } from './SquareTabList';
export type { TabWithProperties };
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 useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver';
import Tab from './Tab';
import Icon from '../common/icons/Icon';
import './TabList.scss';
export type TabWithProperties = {
id?: number;
title: TeactNode;
badgeCount?: number;
isBlocked?: boolean;
isBadgeActive?: boolean;
contextActions?: MenuItemContextAction[];
emoticon?: string | ApiMessageEntityCustomEmoji;
noTitleAnimations?: boolean;
};
import styles from './TabList.module.scss';
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 TabList = ({
tabs,
activeTab,
className,
tabClassName,
contextRootElementSelector,
ref,
onSwitchTab,
}: OwnProps) => {
let containerRef = useRef<HTMLDivElement>();
if (ref) {
containerRef = ref;
}
const previousActiveTab = usePreviousDeprecated(activeTab);
const containerRef = useRef<HTMLDivElement>();
const clipPathContainerRef = useRef<HTMLDivElement>();
const [clipPath, setClipPath] = useState<string>('');
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(() => {
const container = containerRef.current!;
const { scrollWidth, offsetWidth, scrollLeft } = container;
if (scrollWidth <= offsetWidth) {
return;
}
updateClipPath();
}, [activeTab, tabs]);
const activeTabElement = container.childNodes[activeTab] as HTMLElement | null;
if (!activeTabElement) {
return;
}
useResizeObserver(clipPathContainerRef, updateClipPath);
const { offsetLeft: activeTabOffsetLeft, offsetWidth: activeTabOffsetWidth } = activeTabElement;
const newLeft = activeTabOffsetLeft - (offsetWidth / 2) + (activeTabOffsetWidth / 2);
const handleTabClick = useLastCallback((index: number) => {
onSwitchTab(index);
});
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
if (!tabs.length) return undefined;
animateHorizontalScroll(container, newLeft, SCROLL_DURATION);
}, [activeTab]);
const renderTab = (tab: TabWithProperties, index: number) => (
<div
key={tab.id ?? index}
className={styles.tab}
onClick={() => handleTabClick(index)}
>
{tab.title}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div>
);
return (
<div
className={buildClassName('TabList', 'no-scrollbar', className)}
ref={containerRef}
dir={lang.isRtl ? 'rtl' : undefined}
className={buildClassName(styles.container, className, clipPath && styles.ready)}
>
{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}
/>
))}
{tabs.map(renderTab)}
<div
ref={clipPathContainerRef}
className={styles.activeIndicator}
style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden
>
{tabs.map(renderTab)}
</div>
</div>
);
};

View File

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

View File

@ -145,6 +145,7 @@ import {
selectMessageReplyInfo,
selectOutlyingListByMessageId,
selectPeer,
selectPeerPaidMessagesStars,
selectPeerStory,
selectPinnedIds,
selectPollFromMessage,
@ -2607,6 +2608,143 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet
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 => {
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>(
global: T,
chatId: string,

View File

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

View File

@ -3,7 +3,7 @@ import { getActions, getGlobal } from '../global';
import type { ApiMessageEntity, ApiMessageEntityCustomEmoji } from '../api/types';
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 { SettingsScreens } from '../types';

View File

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

View File

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