Chat: Implement folder badges (#444)

This commit is contained in:
Shahaf 2025-08-29 11:16:31 +03:00 committed by GitHub
parent e169b09d0b
commit 6d6249487e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 491 additions and 49 deletions

View File

@ -425,6 +425,7 @@ export function buildApiChatFolder(filter: GramJs.DialogFilter | GramJs.DialogFi
hasMyInvites: filter.hasMyInvites,
isChatList: true,
noTitleAnimations: filter.titleNoanimate,
color: filter.color,
title: buildApiFormattedText(filter.title),
};
}
@ -438,6 +439,7 @@ export function buildApiChatFolder(filter: GramJs.DialogFilter | GramJs.DialogFi
pinnedChatIds: filter.pinnedPeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
includedChatIds: filter.includePeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
excludedChatIds: filter.excludePeers.map(getApiChatIdFromMtpPeer).filter(Boolean),
color: filter.color,
title: buildApiFormattedText(filter.title),
noTitleAnimations: filter.titleNoanimate,
};

View File

@ -296,6 +296,7 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi
groups,
channels,
bots,
color,
excludeArchived,
excludeMuted,
excludeRead,
@ -321,6 +322,7 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi
return new GramJs.DialogFilterChatlist({
id: folder.id,
title: buildInputTextWithEntities(folder.title),
color,
emoticon: emoticon || undefined,
pinnedPeers,
includePeers,
@ -337,6 +339,7 @@ export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFi
nonContacts: nonContacts || undefined,
groups: groups || undefined,
bots: bots || undefined,
color,
excludeArchived: excludeArchived || undefined,
excludeMuted: excludeMuted || undefined,
excludeRead: excludeRead || undefined,

View File

@ -1076,7 +1076,7 @@ export async function fetchChatFolders() {
if (!result) {
return undefined;
}
const { filters } = result;
const { filters, tagsEnabled: areTagsEnabled } = result;
const defaultFolderPosition = filters.findIndex((folder) => folder instanceof GramJs.DialogFilterDefault);
const dialogFilters = filters.filter(isChatFolder);
@ -1090,6 +1090,7 @@ export async function fetchChatFolders() {
.map(buildApiChatFolder), 'id',
) as Record<number, ApiChatFolder>,
orderedIds,
areTagsEnabled,
};
}
@ -1182,6 +1183,12 @@ export function sortChatFolders(ids: number[]) {
}));
}
export function toggleDialogFilterTags(isEnabled: boolean) {
return invokeRequest(new GramJs.messages.ToggleDialogFilterTags({
enabled: isEnabled,
}));
}
export async function toggleDialogUnread({
chat, hasUnreadMark,
}: {

View File

@ -223,6 +223,7 @@ export interface ApiChatFolder {
groups?: true;
channels?: true;
bots?: true;
color?: number;
excludeMuted?: true;
excludeRead?: true;
excludeArchived?: true;

View File

@ -364,6 +364,10 @@
"BlockedUsersBlockUser" = "Block User...";
"FilterChatTypes" = "Chat types";
"FilterChats" = "Chats";
"FilterColorTitle" = "Folder Color";
"FilterColorHint" = "This color will be used for the folder's tag in the chat list";
"ShowFolderTags" = "Show Folder Tags";
"ShowFolderTagsHint" = "Display folder names for each chat in the chat list.";
"FilterIncludeInfo" = "Choose chats or types of chats that will appear in this folder.";
"FilterNameHint" = "Folder name";
"FilterInclude" = "Included Chats";

View File

@ -30,6 +30,7 @@ type OwnProps = {
topics?: Record<number, ApiTopic>;
renderLastMessage: () => React.ReactNode;
observeIntersection?: ObserveFn;
noForumTitle?: boolean;
};
const NO_CORNER_THRESHOLD = Number(REM);
@ -40,6 +41,7 @@ const ChatForumLastMessage: FC<OwnProps> = ({
topics,
renderLastMessage,
observeIntersection,
noForumTitle,
}) => {
const { openThread } = getActions();
@ -102,53 +104,63 @@ const ChatForumLastMessage: FC<OwnProps> = ({
dir={lang.isRtl ? 'rtl' : undefined}
style={overwrittenWidth ? `--overwritten-width: ${overwrittenWidth}px` : undefined}
>
{lastActiveTopic && (
<div className={styles.titleRow}>
<div
className={buildClassName(
styles.mainColumn,
lastActiveTopic.unreadCount && styles.unread,
)}
ref={mainColumnRef}
onClick={handleOpenTopicClick}
onMouseDown={handleOpenTopicMouseDown}
>
<TopicIcon
topic={lastActiveTopic}
observeIntersection={observeIntersection}
/>
<div className={styles.title}>{renderText(lastActiveTopic.title)}</div>
{!overwrittenWidth && isReversedCorner && (
<div className={styles.afterWrapper}>
<div className={styles.after} />
{
!noForumTitle && (
<>
{lastActiveTopic && (
<div className={styles.titleRow}>
<div
className={buildClassName(
styles.mainColumn,
lastActiveTopic.unreadCount && styles.unread,
)}
ref={mainColumnRef}
onClick={handleOpenTopicClick}
onMouseDown={handleOpenTopicMouseDown}
>
<TopicIcon
topic={lastActiveTopic}
observeIntersection={observeIntersection}
/>
<div className={styles.title}>{renderText(lastActiveTopic.title)}</div>
{!overwrittenWidth && isReversedCorner && (
<div className={styles.afterWrapper}>
<div className={styles.after} />
</div>
)}
</div>
<div className={styles.otherColumns}>
{otherTopics.map((topic) => (
<div
className={buildClassName(
styles.otherColumn, topic.unreadCount && styles.unread,
)}
key={topic.id}
>
<TopicIcon
topic={topic}
className={styles.otherColumnIcon}
observeIntersection={observeIntersection}
/>
<span className={styles.otherColumnTitle}>{renderText(topic.title)}</span>
</div>
))}
</div>
<div className={styles.ellipsis} />
</div>
)}
</div>
<div className={styles.otherColumns}>
{otherTopics.map((topic) => (
<div
className={buildClassName(
styles.otherColumn, topic.unreadCount && styles.unread,
)}
key={topic.id}
>
<TopicIcon
topic={topic}
className={styles.otherColumnIcon}
observeIntersection={observeIntersection}
/>
<span className={styles.otherColumnTitle}>{renderText(topic.title)}</span>
{!lastActiveTopic && (
<div className={buildClassName(styles.titleRow, styles.loading)}>
{lang('Loading')}
</div>
))}
</div>
<div className={styles.ellipsis} />
</div>
)}
{!lastActiveTopic && <div className={buildClassName(styles.titleRow, styles.loading)}>{lang('Loading')}</div>}
)}
</>
)
}
<div
className={buildClassName(styles.lastMessage, lastActiveTopic?.unreadCount && styles.unread)}
className={buildClassName(styles.lastMessage, lastActiveTopic?.unreadCount && !noForumTitle && styles.unread)}
ref={lastMessageRef}
onClick={handleOpenTopicClick}
onMouseDown={handleOpenTopicMouseDown}

View File

@ -335,6 +335,13 @@
line-height: initial;
vertical-align: text-top;
}
&.has-tags {
line-height: 1.25rem;
.info-row {
height: 1.25rem;
}
}
}
&[dir="rtl"] {
@ -456,6 +463,11 @@
color: var(--color-chat-active);
background-color: #fff;
}
.ChatTags {
color: var(--color-chat-active);
background-color: #fff;
}
}
}

View File

@ -4,6 +4,7 @@ import { getActions, withGlobal } from '../../../global';
import type {
ApiChat,
ApiChatFolder,
ApiDraft,
ApiMessage,
ApiMessageOutgoingStatus,
@ -33,6 +34,7 @@ import {
selectCurrentMessageList,
selectDraft,
selectIsCurrentUserFrozen,
selectIsCurrentUserPremium,
selectIsForumPanelClosed,
selectIsForumPanelOpen,
selectMonoforumChannel,
@ -52,6 +54,7 @@ import {
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { isUserId } from '../../../util/entities/ids';
import { getChatFolderIds } from '../../../util/folderManager';
import { createLocationHash } from '../../../util/routing';
import useSelectorSignal from '../../../hooks/data/useSelectorSignal';
@ -75,9 +78,9 @@ import ChatFolderModal from '../ChatFolderModal.async';
import MuteChatModal from '../MuteChatModal.async';
import ChatBadge from './ChatBadge';
import ChatCallStatus from './ChatCallStatus';
import ChatTags from './ChatTags';
import './Chat.scss';
type OwnProps = {
chatId: string;
folderId?: number;
@ -91,6 +94,7 @@ type OwnProps = {
className?: string;
observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void;
withTags?: boolean;
};
type StateProps = {
@ -118,6 +122,11 @@ type StateProps = {
currentUserId: string;
isSynced?: boolean;
isAccountFrozen?: boolean;
folderIds?: number[];
orderedIds?: number[];
chatFoldersById?: Record<number, ApiChatFolder>;
activeChatFolder?: number;
areTagsEnabled?: boolean;
};
const Chat: FC<OwnProps & StateProps> = ({
@ -157,6 +166,12 @@ const Chat: FC<OwnProps & StateProps> = ({
isSynced,
onDragEnter,
isAccountFrozen,
folderIds,
orderedIds,
chatFoldersById,
activeChatFolder,
areTagsEnabled,
withTags,
}) => {
const {
openChat,
@ -184,6 +199,8 @@ const Chat: FC<OwnProps & StateProps> = ({
useEnsureMessage(isSavedDialog ? currentUserId : chatId, lastMessageId, lastMessage);
const shouldRenderTags = areTagsEnabled && withTags && folderIds && folderIds.length > 1;
const { renderSubtitle, ref } = useChatListEntry({
chat,
chatId,
@ -200,6 +217,7 @@ const Chat: FC<OwnProps & StateProps> = ({
isSavedDialog,
isPreview,
topics,
noForumTitle: shouldRenderTags,
});
const getIsForumPanelClosed = useSelectorSignal(selectIsForumPanelClosed);
@ -388,7 +406,7 @@ const Chat: FC<OwnProps & StateProps> = ({
<ChatCallStatus isMobile={isMobile} isSelected={isSelected} isActive={withInterfaceAnimations} />
)}
</div>
<div className="info">
<div className={buildClassName('info', shouldRenderTags && 'has-tags')}>
<div className="info-row">
<FullNameTitle
peer={isMonoforum ? monoforumChannel! : peer}
@ -423,6 +441,14 @@ const Chat: FC<OwnProps & StateProps> = ({
/>
)}
</div>
{shouldRenderTags && (
<ChatTags
folderIds={folderIds}
orderedIds={orderedIds}
chatFoldersById={chatFoldersById}
activeChatFolder={activeChatFolder}
/>
)}
</div>
{shouldRenderDeleteModal && (
<DeleteChatModal
@ -465,6 +491,10 @@ export default memo(withGlobal<OwnProps>(
};
}
const folderIds = getChatFolderIds(chatId);
const { areTagsEnabled } = global.chatFolders;
const isPremium = selectIsCurrentUserPremium(global);
const lastMessageId = previewMessageId || selectChatLastMessageId(global, chatId, isSavedDialog ? 'saved' : 'all');
const lastMessage = previewMessageId
? selectChatMessage(global, chatId, previewMessageId)
@ -497,6 +527,8 @@ export default memo(withGlobal<OwnProps>(
const monoforumChannel = selectMonoforumChannel(global, chatId);
const activeChatFolder = selectTabState(global).activeChatFolder;
return {
chat,
isMuted: getIsChatMuted(chat, selectNotifyDefaults(global), selectNotifyException(global, chat.id)),
@ -524,6 +556,11 @@ export default memo(withGlobal<OwnProps>(
lastMessageStory,
isAccountFrozen,
monoforumChannel,
folderIds,
orderedIds: global.chatFolders.orderedIds,
activeChatFolder,
chatFoldersById: global.chatFolders.byId,
areTagsEnabled: areTagsEnabled && isPremium,
};
},
)(Chat));

View File

@ -368,6 +368,7 @@ const ChatFolders: FC<OwnProps & StateProps> = ({
archiveSettings={archiveSettings}
sessions={sessions}
isAccountFrozen={isAccountFrozen}
withTags
/>
);
}

View File

@ -54,6 +54,7 @@ type OwnProps = {
isAccountFrozen?: boolean;
isMainList?: boolean;
foldersDispatch?: FolderEditDispatch;
withTags?: boolean;
};
const INTERSECTION_THROTTLE = 200;
@ -72,6 +73,7 @@ const ChatList: FC<OwnProps> = ({
isAccountFrozen,
isMainList,
foldersDispatch,
withTags,
}) => {
const {
openChat,
@ -238,6 +240,7 @@ const ChatList: FC<OwnProps> = ({
offsetTop={offsetTop}
observeIntersection={observe}
onDragEnter={handleDragEnter}
withTags={withTags}
/>
);
});

View File

@ -0,0 +1,33 @@
.wrapper {
display: flex;
gap: 0.25rem;
}
.tag {
position: relative;
flex-shrink: 0;
min-width: 1.5rem;
padding: 0 0.25rem;
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: var(--font-weight-medium);
line-height: 0.75rem;
color: var(--accent-color);
text-align: center;
text-transform: uppercase;
background-color: var(--accent-background-color);
:global(.emoji-small) {
width: 0.5625rem;
height: 0.5625rem;
margin-top: -0.0625rem;
}
&ColorMore {
color: var(--color-text-secondary);
}
}

View File

@ -0,0 +1,74 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiChatFolder } from '../../../api/types';
import { ALL_FOLDER_ID } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import { getApiPeerColorClass } from '../../common/helpers/peerColor';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import styles from './ChatTags.module.scss';
const MAX_VISIBLE_TAGS = 3;
type OwnProps = {
folderIds?: number[];
orderedIds?: number[];
chatFoldersById?: Record<number, ApiChatFolder>;
activeChatFolder?: number;
};
const ChatTags = ({
folderIds,
orderedIds,
chatFoldersById,
activeChatFolder,
}: OwnProps) => {
const activeFolderId = activeChatFolder !== undefined && orderedIds ? orderedIds[activeChatFolder] : undefined;
const orderedFolderIds = useMemo(() => orderedIds?.filter((id) => {
const isFolder = folderIds?.includes(id);
const isActive = id === activeFolderId;
const isAll = id === ALL_FOLDER_ID;
const folder = chatFoldersById?.[id];
const hasColor = folder?.color !== undefined && folder.color !== -1;
return isFolder && !isActive && !isAll && hasColor;
}) || [], [orderedIds, folderIds, activeFolderId, chatFoldersById]);
const visibleFolderIds = orderedFolderIds.slice(0, MAX_VISIBLE_TAGS);
const remainingCount = orderedFolderIds.length - visibleFolderIds.length;
return (
<div className={styles.wrapper}>
{visibleFolderIds.map((folderId) => {
const folder = chatFoldersById?.[folderId];
return folder && (
<div
key={folder.id}
className={buildClassName(
'ChatTags',
styles.tag,
getApiPeerColorClass({ color: folder.color }),
)}
>
{renderTextWithEntities({
text: folder.title.text,
entities: folder.title.entities,
noCustomEmojiPlayback: folder.noTitleAnimations,
})}
</div>
);
})}
{remainingCount > 0 && (
<div className={`ChatTags ${styles.tag} ${styles.tagColorMore}`}>
+
{remainingCount}
</div>
)}
</div>
);
};
export default memo(ChatTags);

View File

@ -52,6 +52,7 @@ export default function useChatListEntry({
isTopic,
isSavedDialog,
isPreview,
noForumTitle,
}: {
chat?: ApiChat;
topics?: Record<number, ApiTopic>;
@ -70,6 +71,7 @@ export default function useChatListEntry({
animationType: ChatAnimationTypes;
orderDiff: number;
withInterfaceAnimations?: boolean;
noForumTitle?: boolean;
}) {
const oldLang = useOldLang();
const ref = useRef<HTMLDivElement>();
@ -153,6 +155,7 @@ export default function useChatListEntry({
renderLastMessage={renderLastMessageOrTyping}
observeIntersection={observeIntersection}
topics={topics}
noForumTitle={noForumTitle}
/>
);
}

View File

@ -116,3 +116,82 @@
flex-direction: column;
height: 100%;
}
.color-picker {
display: flex;
gap: 1rem;
align-items: center;
min-height: 3rem;
padding: 0 1rem 1rem;
}
.color-picker-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.color-picker-item {
cursor: pointer;
width: 1.5rem;
height: 1.5rem;
border: 0.125rem solid transparent;
border-radius: 50%;
color: var(--accent-color);
background-color: currentColor;
outline: none;
transition: border 100ms ease-in-out, outline 100ms ease-in-out;
}
.color-picker-item-disabled {
--accent-color: var(--color-topic-grey);
}
.color-picker-selected-color {
color: var(--accent-color);
}
.color-picker-item-none {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-topic-grey);
.color-picker-item-none-icon {
color: var(--color-background);
}
}
.color-picker-item:not(.color-picker-item-hover-disabled) {
&.color-picker-item-active,
&:focus,
&:hover,
&:active {
border: 0.125rem solid var(--color-background);
outline: 0.125rem solid currentColor;
}
}
.settings-item-relative {
position: relative;
}
.settings-folders-lock-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
padding: 1.3125rem;
font-size: 0.875rem;
color: var(--color-gray);
}
.settings-folders-title {
color: var(--accent-color);
}

View File

@ -12,13 +12,16 @@ import type {
} from '../../../../hooks/reducers/useFoldersReducer';
import { STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config';
import { selectCanShareFolder } from '../../../../global/selectors';
import { selectCanShareFolder, selectIsCurrentUserPremium } from '../../../../global/selectors';
import { selectCurrentLimit } from '../../../../global/selectors/limits';
import buildClassName from '../../../../util/buildClassName';
import { isUserId } from '../../../../util/entities/ids';
import { findIntersectionWithSet } from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { CUSTOM_PEER_EXCLUDED_CHAT_TYPES, CUSTOM_PEER_INCLUDED_CHAT_TYPES } from '../../../../util/objects/customPeer';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { getApiPeerColorClass } from '../../../common/helpers/peerColor';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import { selectChatFilters } from '../../../../hooks/reducers/useFoldersReducer';
import useHistoryBack from '../../../../hooks/useHistoryBack';
@ -55,12 +58,15 @@ type StateProps = {
maxInviteLinks: number;
maxChatLists: number;
chatListCount: number;
isCurrentUserPremium: boolean;
};
const SUBMIT_TIMEOUT = 500;
const INITIAL_CHATS_LIMIT = 5;
const FOLDER_COLORS = [0, 1, 2, 3, 4, 5, 6];
export const ERROR_NO_TITLE = 'Please provide a title for this folder.';
export const ERROR_NO_CHATS = 'ChatList.Filter.Error.Empty';
@ -83,11 +89,13 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
maxChatLists,
chatListCount,
onSaveFolder,
isCurrentUserPremium,
}) => {
const {
loadChatlistInvites,
openLimitReachedModal,
showNotification,
openPremiumModal,
} = getActions();
const isCreating = state.mode === 'create';
@ -346,6 +354,73 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
</div>
)}
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3 color-picker-title" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterColorTitle')}
<div className={buildClassName(
'color-picker-selected-color',
isCurrentUserPremium && state.folder.color !== undefined && state.folder.color !== -1
? getApiPeerColorClass({ color: state.folder.color })
: 'color-picker-item-disabled',
)}
>
{renderTextWithEntities({
text: state.folder.title.text,
entities: state.folder.title.entities,
noCustomEmojiPlayback: state.folder.noTitleAnimations,
})}
</div>
</h4>
<div className="color-picker">
{FOLDER_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => {
if (!isCurrentUserPremium) {
openPremiumModal();
return;
}
dispatch({ type: 'setColor', payload: color });
}}
className={buildClassName(
'color-picker-item',
getApiPeerColorClass({ color }),
!isCurrentUserPremium && 'color-picker-item-hover-disabled',
color === state.folder.color && isCurrentUserPremium && 'color-picker-item-active',
)}
/>
))}
<button
type="button"
onClick={() => {
if (!isCurrentUserPremium) {
openPremiumModal();
return;
}
dispatch({ type: 'setColor', payload: undefined });
}}
className={buildClassName(
'color-picker-item',
'color-picker-item-none',
(state.folder.color === undefined || state.folder.color === -1 || !isCurrentUserPremium)
&& 'color-picker-item-active',
)}
>
{isCurrentUserPremium ? (
<Icon name="close" className="color-picker-item-none-icon" />
) : (
<Icon name="lock-badge" className="color-picker-item-none-icon" />
)}
</button>
</div>
<p className="settings-item-description mb-0" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FilterColorHint')}
</p>
</div>
<div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FolderLinkScreen.Title')}
@ -401,6 +476,8 @@ export default memo(withGlobal<OwnProps>(
const { byId, invites } = global.chatFolders;
const chatListCount = Object.values(byId).reduce((acc, el) => acc + (el.isChatList ? 1 : 0), 0);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return {
loadedActiveChatIds: listIds.active,
loadedArchivedChatIds: listIds.archived,
@ -409,6 +486,7 @@ export default memo(withGlobal<OwnProps>(
maxInviteLinks: selectCurrentLimit(global, 'chatlistInvites'),
maxChatLists: selectCurrentLimit(global, 'chatlistJoined'),
chatListCount,
isCurrentUserPremium,
};
},
)(SettingsFoldersEdit));

View File

@ -10,10 +10,12 @@ import { ALL_FOLDER_ID, STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config'
import { getFolderDescriptionText } from '../../../../global/helpers';
import { selectIsCurrentUserPremium } from '../../../../global/selectors';
import { selectCurrentLimit } from '../../../../global/selectors/limits';
import buildClassName from '../../../../util/buildClassName';
import { isBetween } from '../../../../util/math';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { throttle } from '../../../../util/schedulers';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { getApiPeerColorClass } from '../../../common/helpers/peerColor';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import { useFolderManagerForChatsCount } from '../../../../hooks/useFolderManager';
@ -24,6 +26,7 @@ import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated';
import AnimatedIconWithPreview from '../../../common/AnimatedIconWithPreview';
import Icon from '../../../common/icons/Icon';
import Button from '../../../ui/Button';
import Checkbox from '../../../ui/Checkbox';
import Draggable from '../../../ui/Draggable';
import ListItem from '../../../ui/ListItem';
import Loading from '../../../ui/Loading';
@ -41,6 +44,7 @@ type StateProps = {
recommendedChatFolders?: ApiChatFolder[];
maxFolders: number;
isPremium?: boolean;
areTagsEnabled?: boolean;
};
type SortState = {
@ -62,6 +66,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
isPremium,
recommendedChatFolders,
maxFolders,
areTagsEnabled,
}) => {
const {
loadRecommendedChatFolders,
@ -69,6 +74,8 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
openLimitReachedModal,
openDeleteChatFolderModal,
sortChatFolders,
toggleDialogFilterTags,
openPremiumModal,
} = getActions();
const [state, setState] = useState<SortState>({
@ -145,6 +152,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
title: folder.title,
subtitle: getFolderDescriptionText(lang, folder, chatsCountByFolderId[folder.id]),
isChatList: folder.isChatList,
color: folder.color,
noTitleAnimations: folder.noTitleAnimations,
};
});
@ -162,6 +170,14 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
addChatFolder({ folder });
}, [foldersById, maxFolders, addChatFolder, openLimitReachedModal]);
const handleToggleTags = useCallback(() => {
if (!isPremium) {
return;
}
toggleDialogFilterTags({ isEnabled: !areTagsEnabled });
}, [areTagsEnabled, isPremium, toggleDialogFilterTags]);
const handleDrag = useCallback((translation: { x: number; y: number }, id: string | number) => {
const delta = Math.round(translation.y / FOLDER_HEIGHT_PX);
const index = state.orderedFolderIds?.indexOf(id as number) || 0;
@ -303,7 +319,12 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
}
}}
>
<span className="title">
<span className={buildClassName(
'title',
folder?.color !== undefined && folder.color !== -1 && isPremium
&& `${getApiPeerColorClass({ color: folder.color })} settings-folders-title`,
)}
>
{renderTextWithEntities({
text: folder.title.text,
entities: folder.title.entities,
@ -365,6 +386,23 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
))}
</div>
)}
<div className="settings-item pt-3">
<div className="settings-item-relative">
<Checkbox
label={lang('ShowFolderTags')}
subLabel={lang('ShowFolderTagsHint')}
checked={isPremium && areTagsEnabled}
onChange={handleToggleTags}
onClickLabel={(event) => {
if (!isPremium) {
event.preventDefault();
openPremiumModal();
}
}}
/>
{!isPremium && <Icon name="lock-badge" className="settings-folders-lock-icon" />}
</div>
</div>
</div>
);
};
@ -375,6 +413,7 @@ export default memo(withGlobal<OwnProps>(
orderedIds: folderIds,
byId: foldersById,
recommended: recommendedChatFolders,
areTagsEnabled,
} = global.chatFolders;
return {
@ -383,6 +422,7 @@ export default memo(withGlobal<OwnProps>(
isPremium: selectIsCurrentUserPremium(global),
recommendedChatFolders,
maxFolders: selectCurrentLimit(global, 'dialogFilters'),
areTagsEnabled,
};
},
)(SettingsFoldersMain));

View File

@ -1115,6 +1115,24 @@ addActionHandler('loadRecommendedChatFolders', async (global): Promise<void> =>
}
});
addActionHandler('toggleDialogFilterTags', async (global, actions, payload): Promise<void> => {
const { isEnabled } = payload;
const result = await callApi('toggleDialogFilterTags', isEnabled);
if (result) {
global = getGlobal();
global = {
...global,
chatFolders: {
...global.chatFolders,
areTagsEnabled: isEnabled,
},
};
setGlobal(global);
}
});
addActionHandler('editChatFolders', (global, actions, payload): ActionReturnType => {
const {
chatId, idsToRemove, idsToAdd, tabId = getCurrentTabId(),

View File

@ -198,6 +198,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
chatFolders: {
byId: {},
invites: {},
areTagsEnabled: false,
},
fileUploads: {

View File

@ -396,6 +396,9 @@ export interface ActionPayloads {
deleteChatFolder: {
id: number;
};
toggleDialogFilterTags: {
isEnabled: boolean;
};
openSupportChat: WithTabId | undefined;
openChatByPhoneNumber: {
phoneNumber: string;

View File

@ -276,6 +276,7 @@ export type GlobalState = {
byId: Record<number, ApiChatFolder>;
invites: Record<number, ApiChatlistExportedInvite[]>;
recommended?: ApiChatFolder[];
areTagsEnabled?: boolean;
};
phoneCall?: ApiPhoneCall;

View File

@ -116,7 +116,7 @@ export type FoldersState = {
export type FoldersActions = (
'setTitle' | 'saveFilters' | 'editFolder' | 'reset' | 'setChatFilter' | 'setIsLoading' | 'setError' |
'editIncludeFilters' | 'editExcludeFilters' | 'setIncludeFilters' | 'setExcludeFilters' | 'setIsTouched' |
'setFolderId' | 'setIsChatlist'
'setFolderId' | 'setIsChatlist' | 'setColor'
);
export type FolderEditDispatch = Dispatch<FoldersState, FoldersActions>;
@ -248,6 +248,15 @@ const foldersReducer: StateReducer<FoldersState, FoldersActions> = (
isChatList: action.payload,
},
};
case 'setColor':
return {
...state,
folder: {
...state.folder,
color: action.payload,
},
isTouched: true,
};
case 'reset':
return INITIAL_STATE;
default:

View File

@ -24829,6 +24829,11 @@ namespace Api {
}, Bool> {
order: int[];
}
export class ToggleDialogFilterTags extends Request<{
enabled: Bool;
}, Bool> {
enabled: Bool;
}
export class GetOldFeaturedStickers extends Request<{
offset: int;
limit: int;

View File

@ -1616,6 +1616,7 @@ messages.getDialogFilters#efd48c89 = messages.DialogFilters;
messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>;
messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool;
messages.updateDialogFiltersOrder#c563c1e4 order:Vector<int> = Bool;
messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool;
messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages;
messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage;
messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool;
@ -1671,6 +1672,7 @@ messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate;
messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies;
messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector<int> hash:long = messages.Messages;
messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector<int> random_id:Vector<long> = Updates;
messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool;
messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects;
messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>;
messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult;

View File

@ -163,6 +163,7 @@
"messages.getSuggestedDialogFilters",
"messages.updateDialogFilter",
"messages.updateDialogFiltersOrder",
"messages.toggleDialogFilterTags",
"messages.getReplies",
"messages.getDiscussionMessage",
"messages.readDiscussion",

View File

@ -2311,6 +2311,7 @@ messages.getDialogFilters#efd48c89 = messages.DialogFilters;
messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>;
messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool;
messages.updateDialogFiltersOrder#c563c1e4 order:Vector<int> = Bool;
messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool;
messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers;
messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages;
messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage;

View File

@ -320,6 +320,10 @@ export interface LangPair {
'BlockedUsersBlockUser': undefined;
'FilterChatTypes': undefined;
'FilterChats': undefined;
'FilterColorTitle': undefined;
'FilterColorHint': undefined;
'ShowFolderTags': undefined;
'ShowFolderTagsHint': undefined;
'FilterIncludeInfo': undefined;
'FilterNameHint': undefined;
'FilterInclude': undefined;

View File

@ -175,6 +175,10 @@ export default {
FilterChannels: 'Channels',
FilterBots: 'Bots',
FilterChats: 'Chats',
FilterColorTitle: 'Folder Color',
FilterColorHint: 'This color will be used for the folder\'s tag in the chat list',
ShowFolderTags: 'Show Folder Tags',
ShowFolderTagsHint: 'Display folder names for each chat in the chat list.',
AccDescrChannel: 'Channel',
AccDescrGroup: 'Group',
Bot: 'bot',

View File

@ -177,6 +177,10 @@ export function getOrderKey(chatId: string, isForSaved?: boolean) {
return isForSaved ? summary.orderInSaved : summary.orderInAll;
}
export function getChatFolderIds(chatId: string) {
return prepared.folderIdsByChatId[chatId];
}
/* Callback managers */
export function addOrderedIdsCallback(folderId: number, callback: (orderedIds: string[]) => void) {