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

View File

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

View File

@ -1076,7 +1076,7 @@ export async function fetchChatFolders() {
if (!result) { if (!result) {
return undefined; return undefined;
} }
const { filters } = result; const { filters, tagsEnabled: areTagsEnabled } = result;
const defaultFolderPosition = filters.findIndex((folder) => folder instanceof GramJs.DialogFilterDefault); const defaultFolderPosition = filters.findIndex((folder) => folder instanceof GramJs.DialogFilterDefault);
const dialogFilters = filters.filter(isChatFolder); const dialogFilters = filters.filter(isChatFolder);
@ -1090,6 +1090,7 @@ export async function fetchChatFolders() {
.map(buildApiChatFolder), 'id', .map(buildApiChatFolder), 'id',
) as Record<number, ApiChatFolder>, ) as Record<number, ApiChatFolder>,
orderedIds, 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({ export async function toggleDialogUnread({
chat, hasUnreadMark, chat, hasUnreadMark,
}: { }: {

View File

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

View File

@ -364,6 +364,10 @@
"BlockedUsersBlockUser" = "Block User..."; "BlockedUsersBlockUser" = "Block User...";
"FilterChatTypes" = "Chat types"; "FilterChatTypes" = "Chat types";
"FilterChats" = "Chats"; "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."; "FilterIncludeInfo" = "Choose chats or types of chats that will appear in this folder.";
"FilterNameHint" = "Folder name"; "FilterNameHint" = "Folder name";
"FilterInclude" = "Included Chats"; "FilterInclude" = "Included Chats";

View File

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

View File

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

View File

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

View File

@ -54,6 +54,7 @@ type OwnProps = {
isAccountFrozen?: boolean; isAccountFrozen?: boolean;
isMainList?: boolean; isMainList?: boolean;
foldersDispatch?: FolderEditDispatch; foldersDispatch?: FolderEditDispatch;
withTags?: boolean;
}; };
const INTERSECTION_THROTTLE = 200; const INTERSECTION_THROTTLE = 200;
@ -72,6 +73,7 @@ const ChatList: FC<OwnProps> = ({
isAccountFrozen, isAccountFrozen,
isMainList, isMainList,
foldersDispatch, foldersDispatch,
withTags,
}) => { }) => {
const { const {
openChat, openChat,
@ -238,6 +240,7 @@ const ChatList: FC<OwnProps> = ({
offsetTop={offsetTop} offsetTop={offsetTop}
observeIntersection={observe} observeIntersection={observe}
onDragEnter={handleDragEnter} 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, isTopic,
isSavedDialog, isSavedDialog,
isPreview, isPreview,
noForumTitle,
}: { }: {
chat?: ApiChat; chat?: ApiChat;
topics?: Record<number, ApiTopic>; topics?: Record<number, ApiTopic>;
@ -70,6 +71,7 @@ export default function useChatListEntry({
animationType: ChatAnimationTypes; animationType: ChatAnimationTypes;
orderDiff: number; orderDiff: number;
withInterfaceAnimations?: boolean; withInterfaceAnimations?: boolean;
noForumTitle?: boolean;
}) { }) {
const oldLang = useOldLang(); const oldLang = useOldLang();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
@ -153,6 +155,7 @@ export default function useChatListEntry({
renderLastMessage={renderLastMessageOrTyping} renderLastMessage={renderLastMessageOrTyping}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
topics={topics} topics={topics}
noForumTitle={noForumTitle}
/> />
); );
} }

View File

@ -116,3 +116,82 @@
flex-direction: column; flex-direction: column;
height: 100%; 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'; } from '../../../../hooks/reducers/useFoldersReducer';
import { STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config'; 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 { selectCurrentLimit } from '../../../../global/selectors/limits';
import buildClassName from '../../../../util/buildClassName';
import { isUserId } from '../../../../util/entities/ids'; import { isUserId } from '../../../../util/entities/ids';
import { findIntersectionWithSet } from '../../../../util/iteratees'; import { findIntersectionWithSet } from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { CUSTOM_PEER_EXCLUDED_CHAT_TYPES, CUSTOM_PEER_INCLUDED_CHAT_TYPES } from '../../../../util/objects/customPeer'; import { CUSTOM_PEER_EXCLUDED_CHAT_TYPES, CUSTOM_PEER_INCLUDED_CHAT_TYPES } from '../../../../util/objects/customPeer';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; 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 { selectChatFilters } from '../../../../hooks/reducers/useFoldersReducer';
import useHistoryBack from '../../../../hooks/useHistoryBack'; import useHistoryBack from '../../../../hooks/useHistoryBack';
@ -55,12 +58,15 @@ type StateProps = {
maxInviteLinks: number; maxInviteLinks: number;
maxChatLists: number; maxChatLists: number;
chatListCount: number; chatListCount: number;
isCurrentUserPremium: boolean;
}; };
const SUBMIT_TIMEOUT = 500; const SUBMIT_TIMEOUT = 500;
const INITIAL_CHATS_LIMIT = 5; 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_TITLE = 'Please provide a title for this folder.';
export const ERROR_NO_CHATS = 'ChatList.Filter.Error.Empty'; export const ERROR_NO_CHATS = 'ChatList.Filter.Error.Empty';
@ -83,11 +89,13 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
maxChatLists, maxChatLists,
chatListCount, chatListCount,
onSaveFolder, onSaveFolder,
isCurrentUserPremium,
}) => { }) => {
const { const {
loadChatlistInvites, loadChatlistInvites,
openLimitReachedModal, openLimitReachedModal,
showNotification, showNotification,
openPremiumModal,
} = getActions(); } = getActions();
const isCreating = state.mode === 'create'; const isCreating = state.mode === 'create';
@ -346,6 +354,73 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
</div> </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"> <div className="settings-item pt-3">
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}> <h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('FolderLinkScreen.Title')} {lang('FolderLinkScreen.Title')}
@ -401,6 +476,8 @@ export default memo(withGlobal<OwnProps>(
const { byId, invites } = global.chatFolders; const { byId, invites } = global.chatFolders;
const chatListCount = Object.values(byId).reduce((acc, el) => acc + (el.isChatList ? 1 : 0), 0); const chatListCount = Object.values(byId).reduce((acc, el) => acc + (el.isChatList ? 1 : 0), 0);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return { return {
loadedActiveChatIds: listIds.active, loadedActiveChatIds: listIds.active,
loadedArchivedChatIds: listIds.archived, loadedArchivedChatIds: listIds.archived,
@ -409,6 +486,7 @@ export default memo(withGlobal<OwnProps>(
maxInviteLinks: selectCurrentLimit(global, 'chatlistInvites'), maxInviteLinks: selectCurrentLimit(global, 'chatlistInvites'),
maxChatLists: selectCurrentLimit(global, 'chatlistJoined'), maxChatLists: selectCurrentLimit(global, 'chatlistJoined'),
chatListCount, chatListCount,
isCurrentUserPremium,
}; };
}, },
)(SettingsFoldersEdit)); )(SettingsFoldersEdit));

View File

@ -10,10 +10,12 @@ import { ALL_FOLDER_ID, STICKER_SIZE_FOLDER_SETTINGS } from '../../../../config'
import { getFolderDescriptionText } from '../../../../global/helpers'; import { getFolderDescriptionText } from '../../../../global/helpers';
import { selectIsCurrentUserPremium } from '../../../../global/selectors'; import { selectIsCurrentUserPremium } from '../../../../global/selectors';
import { selectCurrentLimit } from '../../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../../global/selectors/limits';
import buildClassName from '../../../../util/buildClassName';
import { isBetween } from '../../../../util/math'; import { isBetween } from '../../../../util/math';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { throttle } from '../../../../util/schedulers'; import { throttle } from '../../../../util/schedulers';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { getApiPeerColorClass } from '../../../common/helpers/peerColor';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import { useFolderManagerForChatsCount } from '../../../../hooks/useFolderManager'; import { useFolderManagerForChatsCount } from '../../../../hooks/useFolderManager';
@ -24,6 +26,7 @@ import usePreviousDeprecated from '../../../../hooks/usePreviousDeprecated';
import AnimatedIconWithPreview from '../../../common/AnimatedIconWithPreview'; import AnimatedIconWithPreview from '../../../common/AnimatedIconWithPreview';
import Icon from '../../../common/icons/Icon'; import Icon from '../../../common/icons/Icon';
import Button from '../../../ui/Button'; import Button from '../../../ui/Button';
import Checkbox from '../../../ui/Checkbox';
import Draggable from '../../../ui/Draggable'; import Draggable from '../../../ui/Draggable';
import ListItem from '../../../ui/ListItem'; import ListItem from '../../../ui/ListItem';
import Loading from '../../../ui/Loading'; import Loading from '../../../ui/Loading';
@ -41,6 +44,7 @@ type StateProps = {
recommendedChatFolders?: ApiChatFolder[]; recommendedChatFolders?: ApiChatFolder[];
maxFolders: number; maxFolders: number;
isPremium?: boolean; isPremium?: boolean;
areTagsEnabled?: boolean;
}; };
type SortState = { type SortState = {
@ -62,6 +66,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
isPremium, isPremium,
recommendedChatFolders, recommendedChatFolders,
maxFolders, maxFolders,
areTagsEnabled,
}) => { }) => {
const { const {
loadRecommendedChatFolders, loadRecommendedChatFolders,
@ -69,6 +74,8 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
openLimitReachedModal, openLimitReachedModal,
openDeleteChatFolderModal, openDeleteChatFolderModal,
sortChatFolders, sortChatFolders,
toggleDialogFilterTags,
openPremiumModal,
} = getActions(); } = getActions();
const [state, setState] = useState<SortState>({ const [state, setState] = useState<SortState>({
@ -145,6 +152,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
title: folder.title, title: folder.title,
subtitle: getFolderDescriptionText(lang, folder, chatsCountByFolderId[folder.id]), subtitle: getFolderDescriptionText(lang, folder, chatsCountByFolderId[folder.id]),
isChatList: folder.isChatList, isChatList: folder.isChatList,
color: folder.color,
noTitleAnimations: folder.noTitleAnimations, noTitleAnimations: folder.noTitleAnimations,
}; };
}); });
@ -162,6 +170,14 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
addChatFolder({ folder }); addChatFolder({ folder });
}, [foldersById, maxFolders, addChatFolder, openLimitReachedModal]); }, [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 handleDrag = useCallback((translation: { x: number; y: number }, id: string | number) => {
const delta = Math.round(translation.y / FOLDER_HEIGHT_PX); const delta = Math.round(translation.y / FOLDER_HEIGHT_PX);
const index = state.orderedFolderIds?.indexOf(id as number) || 0; 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({ {renderTextWithEntities({
text: folder.title.text, text: folder.title.text,
entities: folder.title.entities, entities: folder.title.entities,
@ -365,6 +386,23 @@ const SettingsFoldersMain: FC<OwnProps & StateProps> = ({
))} ))}
</div> </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> </div>
); );
}; };
@ -375,6 +413,7 @@ export default memo(withGlobal<OwnProps>(
orderedIds: folderIds, orderedIds: folderIds,
byId: foldersById, byId: foldersById,
recommended: recommendedChatFolders, recommended: recommendedChatFolders,
areTagsEnabled,
} = global.chatFolders; } = global.chatFolders;
return { return {
@ -383,6 +422,7 @@ export default memo(withGlobal<OwnProps>(
isPremium: selectIsCurrentUserPremium(global), isPremium: selectIsCurrentUserPremium(global),
recommendedChatFolders, recommendedChatFolders,
maxFolders: selectCurrentLimit(global, 'dialogFilters'), maxFolders: selectCurrentLimit(global, 'dialogFilters'),
areTagsEnabled,
}; };
}, },
)(SettingsFoldersMain)); )(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 => { addActionHandler('editChatFolders', (global, actions, payload): ActionReturnType => {
const { const {
chatId, idsToRemove, idsToAdd, tabId = getCurrentTabId(), chatId, idsToRemove, idsToAdd, tabId = getCurrentTabId(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1616,6 +1616,7 @@ messages.getDialogFilters#efd48c89 = messages.DialogFilters;
messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>; messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>;
messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool;
messages.updateDialogFiltersOrder#c563c1e4 order:Vector<int> = 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.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.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage;
messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; 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.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies;
messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector<int> hash:long = messages.Messages; 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.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.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects;
messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>; 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; 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.getSuggestedDialogFilters",
"messages.updateDialogFilter", "messages.updateDialogFilter",
"messages.updateDialogFiltersOrder", "messages.updateDialogFiltersOrder",
"messages.toggleDialogFilterTags",
"messages.getReplies", "messages.getReplies",
"messages.getDiscussionMessage", "messages.getDiscussionMessage",
"messages.readDiscussion", "messages.readDiscussion",

View File

@ -2311,6 +2311,7 @@ messages.getDialogFilters#efd48c89 = messages.DialogFilters;
messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>; messages.getSuggestedDialogFilters#a29cd42c = Vector<DialogFilterSuggested>;
messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool;
messages.updateDialogFiltersOrder#c563c1e4 order:Vector<int> = 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.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.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.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage;

View File

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

View File

@ -175,6 +175,10 @@ export default {
FilterChannels: 'Channels', FilterChannels: 'Channels',
FilterBots: 'Bots', FilterBots: 'Bots',
FilterChats: 'Chats', 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', AccDescrChannel: 'Channel',
AccDescrGroup: 'Group', AccDescrGroup: 'Group',
Bot: 'bot', Bot: 'bot',

View File

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