Topic management: Display icon picker (#2247)

This commit is contained in:
Alexander Zinchuk 2023-01-10 02:07:51 +01:00
parent 9e4ec8286c
commit b4f283d207
29 changed files with 474 additions and 154 deletions

View File

@ -1516,7 +1516,7 @@ export function editTopic({
channel: buildInputPeer(id, accessHash), channel: buildInputPeer(id, accessHash),
topicId, topicId,
title, title,
iconEmojiId: iconEmojiId ? BigInt(iconEmojiId) : undefined, iconEmojiId: BigInt(iconEmojiId || '0'),
closed: isClosed, closed: isClosed,
hidden: isHidden, hidden: isHidden,
}), true); }), true);

View File

@ -43,7 +43,7 @@ export {
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet, faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets, removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets,
fetchFeaturedEmojiStickers, fetchGenericEmojiEffects, fetchFeaturedEmojiStickers, fetchGenericEmojiEffects, fetchDefaultTopicIcons,
} from './symbols'; } from './symbols';
export { export {

View File

@ -248,6 +248,21 @@ export async function fetchPremiumGifts() {
}; };
} }
export async function fetchDefaultTopicIcons() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetEmojiDefaultTopicIcons(),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) { export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) {
const result = await invokeRequest(new GramJs.messages.SearchStickerSets({ const result = await invokeRequest(new GramJs.messages.SearchStickerSets({
q: query, q: query,

View File

@ -76,6 +76,8 @@ export { default as GifSearch } from '../components/right/GifSearch';
export { default as Statistics } from '../components/right/statistics/Statistics'; export { default as Statistics } from '../components/right/statistics/Statistics';
export { default as MessageStatistics } from '../components/right/statistics/MessageStatistics'; export { default as MessageStatistics } from '../components/right/statistics/MessageStatistics';
export { default as PollResults } from '../components/right/PollResults'; export { default as PollResults } from '../components/right/PollResults';
export { default as CreateTopic } from '../components/right/CreateTopic';
export { default as EditTopic } from '../components/right/EditTopic';
export { default as Management } from '../components/right/management/Management'; export { default as Management } from '../components/right/management/Management';

View File

@ -20,7 +20,7 @@ import {
isChatSuperGroup, isChatSuperGroup,
} from '../../global/helpers'; } from '../../global/helpers';
import { import {
selectChat, selectChatMessages, selectChatOnlineCount, selectThreadInfo, selectChat, selectChatMessages, selectChatOnlineCount, selectThreadInfo, selectThreadMessagesCount,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
@ -61,6 +61,7 @@ type StateProps =
onlineCount?: number; onlineCount?: number;
areMessagesLoaded: boolean; areMessagesLoaded: boolean;
animationLevel: AnimationLevel; animationLevel: AnimationLevel;
messagesCount?: number;
} }
& Pick<GlobalState, 'lastSyncTime'>; & Pick<GlobalState, 'lastSyncTime'>;
@ -85,6 +86,7 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
animationLevel, animationLevel,
lastSyncTime, lastSyncTime,
topic, topic,
messagesCount,
onClick, onClick,
}) => { }) => {
const { const {
@ -148,7 +150,7 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
if (isTopic) { if (isTopic) {
return ( return (
<span className="status" dir="auto"> <span className="status" dir="auto">
{threadInfo?.messagesCount ? lang('messages', threadInfo.messagesCount, 'i') : renderText(chat.title)} {messagesCount ? lang('messages', messagesCount, 'i') : renderText(chat.title)}
</span> </span>
); );
} }
@ -227,6 +229,7 @@ export default memo(withGlobal<OwnProps>(
const onlineCount = chat ? selectChatOnlineCount(global, chat) : undefined; const onlineCount = chat ? selectChatOnlineCount(global, chat) : undefined;
const areMessagesLoaded = Boolean(selectChatMessages(global, chatId)); const areMessagesLoaded = Boolean(selectChatMessages(global, chatId));
const topic = threadId ? chat?.topics?.[threadId] : undefined; const topic = threadId ? chat?.topics?.[threadId] : undefined;
const messagesCount = topic && selectThreadMessagesCount(global, chatId, threadId!);
return { return {
lastSyncTime, lastSyncTime,
@ -236,6 +239,7 @@ export default memo(withGlobal<OwnProps>(
topic, topic,
areMessagesLoaded, areMessagesLoaded,
animationLevel: global.settings.byKey.animationLevel, animationLevel: global.settings.byKey.animationLevel,
messagesCount,
}; };
}, },
)(GroupChatInfo)); )(GroupChatInfo));

View File

@ -11,11 +11,10 @@ import type { GlobalState } from '../../global/types';
import type { AnimationLevel } from '../../types'; import type { AnimationLevel } from '../../types';
import { MediaViewerOrigin } from '../../types'; import { MediaViewerOrigin } from '../../types';
import { GENERAL_TOPIC_ID } from '../../config';
import { IS_TOUCH_ENV } from '../../util/environment'; import { IS_TOUCH_ENV } from '../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { import {
selectChat, selectCurrentMessageList, selectThreadInfo, selectUser, selectUserStatus, selectChat, selectCurrentMessageList, selectThreadMessagesCount, selectUser, selectUserStatus,
} from '../../global/selectors'; } from '../../global/selectors';
import { getUserStatus, isChatChannel, isUserOnline } from '../../global/helpers'; import { getUserStatus, isChatChannel, isUserOnline } from '../../global/helpers';
import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { captureEvents, SwipeDirection } from '../../util/captureEvents';
@ -187,9 +186,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
/> />
<h3 className={styles.topicTitle} dir={lang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3> <h3 className={styles.topicTitle} dir={lang.isRtl ? 'rtl' : undefined}>{renderText(topic!.title)}</h3>
<p className={styles.topicMessagesCounter}> <p className={styles.topicMessagesCounter}>
{messagesCount && messagesCount > 1 {messagesCount ? lang('Chat.Title.Topic', messagesCount, 'i') : lang('lng_forum_no_messages')}
? lang('Chat.Title.Topic', messagesCount + (topic!.id === GENERAL_TOPIC_ID ? 1 : -1), 'i')
: lang('lng_forum_no_messages')}
</p> </p>
</div> </div>
); );
@ -308,7 +305,6 @@ export default memo(withGlobal<OwnProps>(
const { mediaId, avatarOwnerId } = global.mediaViewer; const { mediaId, avatarOwnerId } = global.mediaViewer;
const isForum = chat?.isForum; const isForum = chat?.isForum;
const { threadId: currentTopicId } = selectCurrentMessageList(global) || {}; const { threadId: currentTopicId } = selectCurrentMessageList(global) || {};
const threadInfo = currentTopicId ? selectThreadInfo(global, userId, currentTopicId) : undefined;
const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined; const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined;
return { return {
@ -323,7 +319,7 @@ export default memo(withGlobal<OwnProps>(
avatarOwnerId, avatarOwnerId,
...(topic && { ...(topic && {
topic, topic,
messagesCount: threadInfo?.messagesCount, messagesCount: selectThreadMessagesCount(global, userId, currentTopicId!),
}), }),
}; };
}, },

View File

@ -44,12 +44,16 @@
right: 0; right: 0;
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 50%; border-radius: 50%;
color: white; color: white;
background: var(--premium-gradient); background: var(--premium-gradient);
z-index: 1;
} }
&.interactive { &.interactive {

View File

@ -124,7 +124,6 @@ const Chat: FC<OwnProps & StateProps> = ({
}) => { }) => {
const { const {
openChat, openChat,
openForumPanel,
focusLastMessage, focusLastMessage,
loadTopics, loadTopics,
} = getActions(); } = getActions();
@ -156,19 +155,12 @@ const Chat: FC<OwnProps & StateProps> = ({
}); });
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (isForum) {
openForumPanel({ chatId });
return;
}
openChat({ id: chatId, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true }); openChat({ id: chatId, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true });
if (isSelected && canScrollDown) { if (isSelected && canScrollDown) {
focusLastMessage(); focusLastMessage();
} }
}, [ }, [openChat, chatId, isSelected, canScrollDown, focusLastMessage]);
isForum, openChat, chatId, isSelected, canScrollDown, openForumPanel, focusLastMessage,
]);
const handleDragEnter = useCallback((e) => { const handleDragEnter = useCallback((e) => {
e.preventDefault(); e.preventDefault();

View File

@ -180,6 +180,7 @@ const Main: FC<StateProps> = ({
loadAvailableReactions, loadAvailableReactions,
loadStickerSets, loadStickerSets,
loadPremiumGifts, loadPremiumGifts,
loadDefaultTopicIcons,
loadAddedStickers, loadAddedStickers,
loadFavoriteStickers, loadFavoriteStickers,
ensureTimeFormat, ensureTimeFormat,
@ -222,12 +223,13 @@ const Main: FC<StateProps> = ({
loadAttachBots(); loadAttachBots();
loadContactList(); loadContactList();
loadPremiumGifts(); loadPremiumGifts();
loadDefaultTopicIcons();
checkAppVersion(); checkAppVersion();
} }
}, [ }, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings, lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList, loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons,
]); ]);
// Language-based API calls // Language-based API calls

View File

@ -27,8 +27,8 @@ import {
getCanAddContact, getCanAddContact,
isChatChannel, isChatChannel,
isChatGroup, isChatGroup,
getHasAdminRight,
getCanManageTopic, getCanManageTopic,
isUserRightBanned,
} from '../../global/helpers'; } from '../../global/helpers';
import useShowTransition from '../../hooks/useShowTransition'; import useShowTransition from '../../hooks/useShowTransition';
import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation'; import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation';
@ -91,6 +91,7 @@ type StateProps = {
isPrivate?: boolean; isPrivate?: boolean;
isMuted?: boolean; isMuted?: boolean;
isTopic?: boolean; isTopic?: boolean;
isForum?: boolean;
canAddContact?: boolean; canAddContact?: boolean;
canReportChat?: boolean; canReportChat?: boolean;
canDeleteChat?: boolean; canDeleteChat?: boolean;
@ -113,6 +114,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
botCommands, botCommands,
withForumActions, withForumActions,
isTopic, isTopic,
isForum,
isChatInfoShown, isChatInfoShown,
canStartBot, canStartBot,
canRestartBot, canRestartBot,
@ -157,6 +159,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
openChatWithInfo, openChatWithInfo,
openCreateTopicPanel, openCreateTopicPanel,
openEditTopicPanel, openEditTopicPanel,
openChat,
} = getActions(); } = getActions();
const [isMenuOpen, setIsMenuOpen] = useState(true); const [isMenuOpen, setIsMenuOpen] = useState(true);
@ -166,7 +169,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const isViewGroupInfoShown = usePrevDuringAnimation( const isViewGroupInfoShown = usePrevDuringAnimation(
(!isChatInfoShown && (withForumActions || isTopic)) ? true : undefined, CLOSE_MENU_ANIMATION_DURATION, (!isChatInfoShown && isForum) ? true : undefined, CLOSE_MENU_ANIMATION_DURATION,
); );
const handleReport = useCallback(() => { const handleReport = useCallback(() => {
@ -222,6 +225,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu(); closeMenu();
}, [openEditTopicPanel, chatId, threadId, closeMenu]); }, [openEditTopicPanel, chatId, threadId, closeMenu]);
const handleViewAsTopicsClick = useCallback(() => {
openChat({ id: undefined });
closeMenu();
}, [closeMenu, openChat]);
const handleEnterVoiceChatClick = useCallback(() => { const handleEnterVoiceChatClick = useCallback(() => {
if (canCreateVoiceChat) { if (canCreateVoiceChat) {
// TODO show popup to schedule // TODO show popup to schedule
@ -352,6 +360,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{lang('lng_forum_topic_edit')} {lang('lng_forum_topic_edit')}
</MenuItem> </MenuItem>
)} )}
{IS_SINGLE_COLUMN_LAYOUT && !withForumActions && isForum && !isTopic && (
<MenuItem
icon="forums"
onClick={handleViewAsTopicsClick}
>
{lang('Chat.ContextViewAsTopics')}
</MenuItem>
)}
{withForumActions && Boolean(pendingJoinRequests) && ( {withForumActions && Boolean(pendingJoinRequests) && (
<MenuItem <MenuItem
icon="user" icon="user"
@ -538,7 +554,7 @@ export default memo(withGlobal<OwnProps>(
); );
const topic = chat?.topics?.[threadId]; const topic = chat?.topics?.[threadId];
const canCreateTopic = chat.isForum && (chat.isCreator || getHasAdminRight(chat, 'manageTopics')); const canCreateTopic = chat.isForum && !isUserRightBanned(chat, 'manageTopics');
const canEditTopic = topic && getCanManageTopic(chat, topic); const canEditTopic = topic && getCanManageTopic(chat, topic);
return { return {
@ -546,6 +562,7 @@ export default memo(withGlobal<OwnProps>(
isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)), isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)),
isPrivate, isPrivate,
isTopic: chat?.isForum && !isMainThread, isTopic: chat?.isForum && !isMainThread,
isForum: chat?.isForum,
canAddContact, canAddContact,
canReportChat, canReportChat,
canDeleteChat: getCanDeleteChat(chat), canDeleteChat: getCanDeleteChat(chat),

View File

@ -4,7 +4,7 @@ import React, {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getGlobal, withGlobal } from '../../../global'; import { getGlobal, withGlobal } from '../../../global';
import type { ApiStickerSet, ApiSticker, ApiChat } from '../../../api/types'; import type { ApiStickerSet, ApiSticker } from '../../../api/types';
import type { StickerSetOrRecent } from '../../../types'; import type { StickerSetOrRecent } from '../../../types';
import { import {
@ -41,17 +41,18 @@ import StickerSetCover from './StickerSetCover';
import './StickerPicker.scss'; import './StickerPicker.scss';
type OwnProps = { type OwnProps = {
chatId: string; chatId?: string;
className: string; className?: string;
loadAndPlay: boolean; loadAndPlay: boolean;
withDefaultTopicIcons?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void; onCustomEmojiSelect: (sticker: ApiSticker) => void;
}; };
type StateProps = { type StateProps = {
chat?: ApiChat;
stickerSetsById: Record<string, ApiStickerSet>; stickerSetsById: Record<string, ApiStickerSet>;
addedCustomEmojiIds?: string[]; addedCustomEmojiIds?: string[];
recentCustomEmoji: ApiSticker[]; recentCustomEmoji: ApiSticker[];
defaultTopicIconsId?: string;
featuredCustomEmojiIds?: string[]; featuredCustomEmojiIds?: string[];
canAnimate?: boolean; canAnimate?: boolean;
isSavedMessages?: boolean; isSavedMessages?: boolean;
@ -74,6 +75,8 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
canAnimate, canAnimate,
isSavedMessages, isSavedMessages,
isCurrentUserPremium, isCurrentUserPremium,
withDefaultTopicIcons,
defaultTopicIconsId,
onCustomEmojiSelect, onCustomEmojiSelect,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -124,7 +127,16 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
const defaultSets = []; const defaultSets = [];
if (recentCustomEmoji.length) { if (withDefaultTopicIcons) {
const defaultTopicIconsPack = stickerSetsById[defaultTopicIconsId!];
if (defaultTopicIconsPack.stickers?.length) {
defaultSets.push({
...defaultTopicIconsPack,
id: RECENT_SYMBOL_SET_ID,
title: lang('RecentStickers'),
});
}
} else if (recentCustomEmoji.length) {
defaultSets.push({ defaultSets.push({
id: RECENT_SYMBOL_SET_ID, id: RECENT_SYMBOL_SET_ID,
title: lang('RecentStickers'), title: lang('RecentStickers'),
@ -144,7 +156,10 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
...existingAddedSetIds, ...existingAddedSetIds,
...featuredSetIds, ...featuredSetIds,
]; ];
}, [addedCustomEmojiIds, featuredCustomEmojiIds, lang, recentCustomEmoji, stickerSetsById]); }, [
addedCustomEmojiIds, defaultTopicIconsId, featuredCustomEmojiIds, lang, recentCustomEmoji, stickerSetsById,
withDefaultTopicIcons,
]);
const noPopulatedSets = useMemo(() => ( const noPopulatedSets = useMemo(() => (
areAddedLoaded areAddedLoaded
@ -280,6 +295,8 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
shouldHideRecentHeader={withDefaultTopicIcons}
withDefaultTopicIcon={withDefaultTopicIcons}
isCustomEmojiPicker isCustomEmojiPicker
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
onStickerSelect={handleEmojiSelect} onStickerSelect={handleEmojiSelect}
@ -296,7 +313,7 @@ export default memo(withGlobal<OwnProps>(
setsById, setsById,
} = global.stickers; } = global.stickers;
const isSavedMessages = selectIsChatWithSelf(global, chatId); const isSavedMessages = Boolean(chatId && selectIsChatWithSelf(global, chatId));
const recentCustomEmoji = Object.values(pickTruthy(global.customEmojis.byId, global.recentCustomEmojis)); const recentCustomEmoji = Object.values(pickTruthy(global.customEmojis.byId, global.recentCustomEmojis));
@ -308,6 +325,7 @@ export default memo(withGlobal<OwnProps>(
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
recentCustomEmoji, recentCustomEmoji,
featuredCustomEmojiIds: global.customEmojis.featuredIds, featuredCustomEmojiIds: global.customEmojis.featuredIds,
defaultTopicIconsId: global.defaultTopicIconsId,
}; };
}, },
)(CustomEmojiPicker)); )(CustomEmojiPicker));

View File

@ -5,11 +5,7 @@
position: relative; position: relative;
height: calc(100% - 3rem); height: calc(100% - 3rem);
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding: 0.5rem 0.25rem;
@media (max-width: 600px) {
padding: 0.5rem 0.25rem;
}
} }
&-header { &-header {
@ -92,12 +88,8 @@
} }
.symbol-set-container { .symbol-set-container {
width: 25rem; width: 100%;
line-height: 0; line-height: 0;
@media (max-width: 600px) {
width: 100%;
}
} }
.sticker-set-button { .sticker-set-button {

View File

@ -1,5 +1,5 @@
import React, { import React, {
memo, useCallback, useMemo, useRef, memo, useCallback, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global'; import { getActions, getGlobal } from '../../../global';
@ -10,21 +10,24 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useOnIntersect } from '../../../hooks/useIntersectionObserver'; import { useOnIntersect } from '../../../hooks/useIntersectionObserver';
import { import {
DEFAULT_TOPIC_ICON_STICKER_ID,
EMOJI_SIZE_PICKER, FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER, EMOJI_SIZE_PICKER, FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER,
} from '../../../config'; } from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import windowSize from '../../../util/windowSize';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { selectIsAlwaysHighPriorityEmoji, selectIsSetPremium } from '../../../global/selectors'; import { selectIsAlwaysHighPriorityEmoji, selectIsSetPremium } from '../../../global/selectors';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useMediaTransition from '../../../hooks/useMediaTransition'; import useMediaTransition from '../../../hooks/useMediaTransition';
import { useResizeObserver } from '../../../hooks/useResizeObserver';
import StickerButton from '../../common/StickerButton'; import StickerButton from '../../common/StickerButton';
import ConfirmDialog from '../../ui/ConfirmDialog'; import ConfirmDialog from '../../ui/ConfirmDialog';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import grey from '../../../assets/icons/forumTopic/grey.svg';
type OwnProps = { type OwnProps = {
stickerSet: StickerSetOrRecent; stickerSet: StickerSetOrRecent;
loadAndPlay: boolean; loadAndPlay: boolean;
@ -34,6 +37,8 @@ type OwnProps = {
isSavedMessages?: boolean; isSavedMessages?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
isCustomEmojiPicker?: boolean; isCustomEmojiPicker?: boolean;
shouldHideRecentHeader?: boolean;
withDefaultTopicIcon?: boolean;
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave?: (sticker: ApiSticker) => void; onStickerUnfave?: (sticker: ApiSticker) => void;
@ -41,11 +46,11 @@ type OwnProps = {
onStickerRemoveRecent?: (sticker: ApiSticker) => void; onStickerRemoveRecent?: (sticker: ApiSticker) => void;
}; };
const STICKERS_PER_ROW_ON_DESKTOP = 5;
const EMOJI_PER_ROW_ON_DESKTOP = 8;
const STICKER_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 16; const STICKER_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 16;
const EMOJI_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 10; const EMOJI_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 10;
const MOBILE_CONTAINER_PADDING = 8; const CONTAINER_PADDING = 8;
const ITEMS_PER_ROW_FALLBACK = 8;
const StickerSet: FC<OwnProps> = ({ const StickerSet: FC<OwnProps> = ({
stickerSet, stickerSet,
@ -56,6 +61,8 @@ const StickerSet: FC<OwnProps> = ({
isSavedMessages, isSavedMessages,
isCurrentUserPremium, isCurrentUserPremium,
isCustomEmojiPicker, isCustomEmojiPicker,
shouldHideRecentHeader,
withDefaultTopicIcon,
observeIntersection, observeIntersection,
onStickerSelect, onStickerSelect,
onStickerUnfave, onStickerUnfave,
@ -75,11 +82,13 @@ const StickerSet: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null); const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const sharedCanvas2Ref = useRef<HTMLCanvasElement>(null); const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const lang = useLang(); const lang = useLang();
const [itemsPerRow, setItemsPerRow] = useState(ITEMS_PER_ROW_FALLBACK);
useOnIntersect(ref, observeIntersection); useOnIntersect(ref, observeIntersection);
const transitionClassNames = useMediaTransition(shouldRender); const transitionClassNames = useMediaTransition(shouldRender);
@ -110,21 +119,48 @@ const StickerSet: FC<OwnProps> = ({
} }
}, [isCurrentUserPremium, isPremiumSet, openPremiumModal, stickerSet, toggleStickerSet]); }, [isCurrentUserPremium, isPremiumSet, openPremiumModal, stickerSet, toggleStickerSet]);
const isLocked = !isSavedMessages && !isRecent && isEmoji && !isCurrentUserPremium const handleDefaultTopicIconClick = useCallback(() => {
&& stickerSet.stickers?.some(({ isFree }) => !isFree); onStickerSelect?.({
id: DEFAULT_TOPIC_ICON_STICKER_ID,
isLottie: false,
isVideo: false,
stickerSetInfo: {
shortName: 'dummy',
},
} satisfies ApiSticker);
}, [onStickerSelect]);
const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER; const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER;
const itemsPerRow = isEmoji ? EMOJI_PER_ROW_ON_DESKTOP : STICKERS_PER_ROW_ON_DESKTOP;
const margin = isEmoji ? EMOJI_MARGIN : STICKER_MARGIN; const margin = isEmoji ? EMOJI_MARGIN : STICKER_MARGIN;
const stickersPerRow = IS_SINGLE_COLUMN_LAYOUT const calculateItemsPerRow = useCallback((width: number) => {
? Math.floor((windowSize.get().width - MOBILE_CONTAINER_PADDING) / (itemSize + margin)) if (!width) return ITEMS_PER_ROW_FALLBACK;
: itemsPerRow;
return Math.floor((width - CONTAINER_PADDING) / (itemSize + margin));
}, [itemSize, margin]);
const handleResize = useCallback((entry: ResizeObserverEntry) => {
setItemsPerRow(calculateItemsPerRow(entry.contentRect.width));
}, [calculateItemsPerRow]);
useResizeObserver(ref, handleResize);
useLayoutEffect(() => {
if (!ref.current) return;
setItemsPerRow(calculateItemsPerRow(ref.current.clientWidth));
}, [calculateItemsPerRow]);
const isLocked = !isSavedMessages && !isRecent && isEmoji && !isCurrentUserPremium
&& stickerSet.stickers?.some(({ isFree }) => !isFree);
const canCut = !stickerSet.installedDate && stickerSet.id !== RECENT_SYMBOL_SET_ID; const canCut = !stickerSet.installedDate && stickerSet.id !== RECENT_SYMBOL_SET_ID;
const [isCut, , expand] = useFlag(canCut); const [isCut, , expand] = useFlag(canCut);
const itemsBeforeCutout = stickersPerRow * 3 - 1; const itemsBeforeCutout = itemsPerRow * 3 - 1;
const heightWhenCut = Math.ceil(Math.min(itemsBeforeCutout, stickerSet.count) / stickersPerRow) * (itemSize + margin); const totalItemsCount = withDefaultTopicIcon ? stickerSet.count + 1 : stickerSet.count;
const height = isCut ? heightWhenCut : Math.ceil(stickerSet.count / stickersPerRow) * (itemSize + margin);
const heightWhenCut = Math.ceil(Math.min(itemsBeforeCutout, totalItemsCount) / itemsPerRow) * (itemSize + margin);
const height = isCut ? heightWhenCut : Math.ceil(totalItemsCount / itemsPerRow) * (itemSize + margin);
const shouldHideHeader = isRecent && shouldHideRecentHeader;
const favoriteStickerIdsSet = useMemo(() => ( const favoriteStickerIdsSet = useMemo(() => (
favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined
@ -139,27 +175,29 @@ const StickerSet: FC<OwnProps> = ({
buildClassName('symbol-set', isLocked && 'symbol-set-locked') buildClassName('symbol-set', isLocked && 'symbol-set-locked')
} }
> >
<div className="symbol-set-header"> {!shouldHideHeader && (
<p className="symbol-set-name"> <div className="symbol-set-header">
{isLocked && <i className="symbol-set-locked-icon icon-lock-badge" />} <p className="symbol-set-name">
{stickerSet.title} {isLocked && <i className="symbol-set-locked-icon icon-lock-badge" />}
</p> {stickerSet.title}
{isRecent && ( </p>
<i className="symbol-set-remove icon-close" onClick={openConfirmModal} /> {isRecent && (
)} <i className="symbol-set-remove icon-close" onClick={openConfirmModal} />
{!isRecent && isEmoji && !stickerSet.installedDate && ( )}
<Button {!isRecent && isEmoji && !stickerSet.installedDate && (
className="symbol-set-add-button" <Button
withPremiumGradient={isPremiumSet && !isCurrentUserPremium} className="symbol-set-add-button"
onClick={handleAddClick} withPremiumGradient={isPremiumSet && !isCurrentUserPremium}
pill onClick={handleAddClick}
size="tiny" pill
fluid size="tiny"
> fluid
{isPremiumSet && isLocked ? lang('Unlock') : lang('Add')} >
</Button> {isPremiumSet && isLocked ? lang('Unlock') : lang('Add')}
)} </Button>
</div> )}
</div>
)}
<div <div
className={buildClassName('symbol-set-container shared-canvas-container', transitionClassNames)} className={buildClassName('symbol-set-container shared-canvas-container', transitionClassNames)}
style={`height: ${height}px;`} style={`height: ${height}px;`}
@ -169,14 +207,24 @@ const StickerSet: FC<OwnProps> = ({
className="shared-canvas" className="shared-canvas"
style={canCut ? `height: ${heightWhenCut}px;` : undefined} style={canCut ? `height: ${heightWhenCut}px;` : undefined}
/> />
{(isRecent || isFavorite || canCut) && <canvas ref={sharedCanvas2Ref} className="shared-canvas" />} {(isRecent || isFavorite || canCut) && <canvas ref={sharedCanvasHqRef} className="shared-canvas" />}
{withDefaultTopicIcon && (
<Button
className="StickerButton custom-emoji"
color="translucent"
onClick={handleDefaultTopicIconClick}
key="default-topic-icon"
>
<img src={grey} alt="Reset" />
</Button>
)}
{shouldRender && stickerSet.stickers && stickerSet.stickers {shouldRender && stickerSet.stickers && stickerSet.stickers
.slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length) .slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length)
.map((sticker, i) => { .map((sticker, i) => {
const isHqEmoji = (isRecent || isFavorite) const isHqEmoji = (isRecent || isFavorite)
&& selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo); && selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo);
const canvasRef = (canCut && i >= itemsBeforeCutout) || isHqEmoji const canvasRef = (canCut && i >= itemsBeforeCutout) || isHqEmoji
? sharedCanvas2Ref ? sharedCanvasHqRef
: sharedCanvasRef; : sharedCanvasRef;
return ( return (
@ -198,9 +246,15 @@ const StickerSet: FC<OwnProps> = ({
/> />
); );
})} })}
{isCut && stickerSet.count > itemsBeforeCutout && ( {isCut && totalItemsCount > itemsBeforeCutout && (
<Button className="StickerButton custom-emoji set-expand" round color="translucent" onClick={expand}> <Button
+{stickerSet.count - itemsBeforeCutout} className="StickerButton custom-emoji set-expand"
round
color="translucent"
onClick={expand}
key="more"
>
+{totalItemsCount - itemsBeforeCutout}
</Button> </Button>
)} )}
</div> </div>

View File

@ -0,0 +1,16 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { OwnProps } from './CreateTopic';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
import Loading from '../ui/Loading';
const CreateTopicAsync: FC<OwnProps> = (props) => {
const CreateTopic = useModuleLoader(Bundles.Extra, 'CreateTopic');
// eslint-disable-next-line react/jsx-props-no-spreading
return CreateTopic ? <CreateTopic {...props} /> : <Loading />;
};
export default memo(CreateTopicAsync);

View File

@ -4,10 +4,11 @@ import React, {
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import type { ApiChat } from '../../api/types'; import type { ApiChat, ApiSticker } from '../../api/types';
import type { GlobalState } from '../../global/types'; import type { GlobalState } from '../../global/types';
import { selectChat } from '../../global/selectors'; import { DEFAULT_TOPIC_ICON_STICKER_ID } from '../../config';
import { selectChat, selectIsCurrentUserPremium } from '../../global/selectors';
import { getTopicColors } from '../../util/forumColors'; import { getTopicColors } from '../../util/forumColors';
import cycleRestrict from '../../util/cycleRestrict'; import cycleRestrict from '../../util/cycleRestrict';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -20,12 +21,14 @@ import TopicIcon from '../common/TopicIcon';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton'; import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import CustomEmojiPicker from '../middle/composer/CustomEmojiPicker';
import Transition from '../ui/Transition';
import styles from './ManageTopic.module.scss'; import styles from './ManageTopic.module.scss';
const ICON_SIZE = 5 * REM; const ICON_SIZE = 5 * REM;
type OwnProps = { export type OwnProps = {
isActive: boolean; isActive: boolean;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
}; };
@ -33,17 +36,20 @@ type OwnProps = {
type StateProps = { type StateProps = {
chat?: ApiChat; chat?: ApiChat;
createTopicPanel?: GlobalState['createTopicPanel']; createTopicPanel?: GlobalState['createTopicPanel'];
isCurrentUserPremium?: boolean;
}; };
const CreateTopic: FC<OwnProps & StateProps> = ({ const CreateTopic: FC<OwnProps & StateProps> = ({
isActive, isActive,
chat, chat,
createTopicPanel, createTopicPanel,
isCurrentUserPremium,
onClose, onClose,
}) => { }) => {
const { createTopic, closeCreateTopicPanel } = getActions(); const { createTopic, openPremiumModal } = getActions();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [iconColorIndex, setIconColorIndex] = useState(0); const [iconColorIndex, setIconColorIndex] = useState(0);
const [iconEmojiId, setIconEmojiId] = useState<string | undefined>(undefined);
const lang = useLang(); const lang = useLang();
const isTouched = Boolean(title); const isTouched = Boolean(title);
@ -67,17 +73,32 @@ const CreateTopic: FC<OwnProps & StateProps> = ({
chatId: chat!.id, chatId: chat!.id,
title, title,
iconColor: getTopicColors()[iconColorIndex], iconColor: getTopicColors()[iconColorIndex],
iconEmojiId,
}); });
closeCreateTopicPanel(); }, [chat, createTopic, iconColorIndex, iconEmojiId, title]);
}, [chat, closeCreateTopicPanel, createTopic, iconColorIndex, title]);
const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => {
if (!emoji.isFree && !isCurrentUserPremium) {
openPremiumModal({ initialSection: 'animated_emoji' });
return;
}
if (emoji.id === DEFAULT_TOPIC_ICON_STICKER_ID) {
setIconEmojiId(undefined);
return;
}
setIconEmojiId(emoji.id);
}, [isCurrentUserPremium, openPremiumModal]);
const dummyTopic = useMemo(() => { const dummyTopic = useMemo(() => {
return { return {
id: 0, id: 0,
title, title,
iconColor: getTopicColors()[iconColorIndex], iconColor: getTopicColors()[iconColorIndex],
iconEmojiId,
}; };
}, [iconColorIndex, title]); }, [iconColorIndex, iconEmojiId, title]);
if (!chat?.isForum) { if (!chat?.isForum) {
return undefined; return undefined;
@ -85,15 +106,24 @@ const CreateTopic: FC<OwnProps & StateProps> = ({
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className="custom-scroll"> <div className={buildClassName(styles.content, 'custom-scroll')}>
<div className={buildClassName(styles.top, 'section')}> <div className={buildClassName(styles.section, styles.top)}>
<span className={styles.heading}>{lang('CreateTopicTitle')}</span> <span className={styles.heading}>{lang('CreateTopicTitle')}</span>
<TopicIcon <Transition
topic={dummyTopic} name="zoom-fade"
className={buildClassName(styles.icon, styles.clickable)} activeKey={Number(dummyTopic.iconEmojiId) || 0}
onClick={handleIconClick} shouldCleanup
size={ICON_SIZE} direction={1}
/> className={styles.iconWrapper}
>
<TopicIcon
topic={dummyTopic}
className={buildClassName(styles.icon, styles.clickable)}
onClick={handleIconClick}
size={ICON_SIZE}
noLoopLimit
/>
</Transition>
<InputText <InputText
value={title} value={title}
onChange={handleTitleChange} onChange={handleTitleChange}
@ -102,6 +132,14 @@ const CreateTopic: FC<OwnProps & StateProps> = ({
teactExperimentControlled teactExperimentControlled
/> />
</div> </div>
<div className={buildClassName(styles.section, styles.bottom)}>
<CustomEmojiPicker
loadAndPlay={isActive}
onCustomEmojiSelect={handleCustomEmojiSelect}
className={styles.iconPicker}
withDefaultTopicIcons
/>
</div>
</div> </div>
<FloatingActionButton <FloatingActionButton
isShown={isTouched} isShown={isTouched}
@ -125,6 +163,7 @@ export default memo(withGlobal(
return { return {
chat: createTopicPanel?.chatId ? selectChat(global, createTopicPanel.chatId) : undefined, chat: createTopicPanel?.chatId ? selectChat(global, createTopicPanel.chatId) : undefined,
createTopicPanel, createTopicPanel,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
}; };
}, },
)(CreateTopic)); )(CreateTopic));

View File

@ -0,0 +1,16 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { OwnProps } from './EditTopic';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
import Loading from '../ui/Loading';
const EditTopicAsync: FC<OwnProps> = (props) => {
const EditTopic = useModuleLoader(Bundles.Extra, 'EditTopic');
// eslint-disable-next-line react/jsx-props-no-spreading
return EditTopic ? <EditTopic {...props} /> : <Loading />;
};
export default memo(EditTopicAsync);

View File

@ -4,10 +4,11 @@ import React, {
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import type { ApiChat, ApiTopic } from '../../api/types'; import type { ApiChat, ApiSticker, ApiTopic } from '../../api/types';
import type { GlobalState } from '../../global/types'; import type { GlobalState } from '../../global/types';
import { selectChat } from '../../global/selectors'; import { DEFAULT_TOPIC_ICON_STICKER_ID, GENERAL_TOPIC_ID } from '../../config';
import { selectChat, selectIsCurrentUserPremium } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions'; import { REM } from '../common/helpers/mediaDimensions';
@ -19,12 +20,14 @@ import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton'; import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import CustomEmojiPicker from '../middle/composer/CustomEmojiPicker';
import Transition from '../ui/Transition';
import styles from './ManageTopic.module.scss'; import styles from './ManageTopic.module.scss';
const ICON_SIZE = 5 * REM; const ICON_SIZE = 5 * REM;
type OwnProps = { export type OwnProps = {
isActive: boolean; isActive: boolean;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
}; };
@ -33,6 +36,7 @@ type StateProps = {
chat?: ApiChat; chat?: ApiChat;
topic?: ApiTopic; topic?: ApiTopic;
editTopicPanel?: GlobalState['editTopicPanel']; editTopicPanel?: GlobalState['editTopicPanel'];
isCurrentUserPremium?: boolean;
}; };
const EditTopic: FC<OwnProps & StateProps> = ({ const EditTopic: FC<OwnProps & StateProps> = ({
@ -40,14 +44,16 @@ const EditTopic: FC<OwnProps & StateProps> = ({
chat, chat,
topic, topic,
editTopicPanel, editTopicPanel,
isCurrentUserPremium,
onClose, onClose,
}) => { }) => {
const { editTopic, closeEditTopicPanel } = getActions(); const { editTopic, openPremiumModal } = getActions();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [isTouched, setIsTouched] = useState(false); const [iconEmojiId, setIconEmojiId] = useState<string | undefined>(undefined);
const lang = useLang(); const lang = useLang();
const isLoading = Boolean(editTopicPanel?.isLoading); const isLoading = Boolean(editTopicPanel?.isLoading);
const isGeneral = topic?.id === GENERAL_TOPIC_ID;
useHistoryBack({ useHistoryBack({
isActive, isActive,
@ -55,33 +61,51 @@ const EditTopic: FC<OwnProps & StateProps> = ({
}); });
useEffect(() => { useEffect(() => {
if (topic?.title) { if (topic?.title || topic?.iconEmojiId) {
setTitle(topic.title); setTitle(topic.title);
setIsTouched(false); setIconEmojiId(topic.iconEmojiId);
} }
}, [topic?.title]); }, [topic]);
const isTouched = useMemo(() => {
return title !== topic?.title || iconEmojiId !== topic?.iconEmojiId;
}, [iconEmojiId, title, topic?.iconEmojiId, topic?.title]);
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newTitle = e.target.value; const newTitle = e.target.value;
setTitle(newTitle); setTitle(newTitle);
setIsTouched(newTitle !== topic?.title); }, []);
}, [topic?.title]);
const handleEditTopic = useCallback(() => { const handleEditTopic = useCallback(() => {
editTopic({ editTopic({
chatId: chat!.id, chatId: chat!.id,
title, title,
topicId: topic!.id, topicId: topic!.id,
iconEmojiId,
}); });
closeEditTopicPanel(); }, [chat, editTopic, iconEmojiId, title, topic]);
}, [chat, closeEditTopicPanel, editTopic, title, topic]);
const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => {
if (!emoji.isFree && !isCurrentUserPremium) {
openPremiumModal({ initialSection: 'animated_emoji' });
return;
}
if (emoji.id === DEFAULT_TOPIC_ICON_STICKER_ID) {
setIconEmojiId(undefined);
return;
}
setIconEmojiId(emoji.id);
}, [isCurrentUserPremium, openPremiumModal]);
const dummyTopic = useMemo(() => { const dummyTopic = useMemo(() => {
return { return {
...topic!, ...topic!,
title, title,
iconEmojiId,
}; };
}, [title, topic]); }, [iconEmojiId, title, topic]);
if (!chat?.isForum) { if (!chat?.isForum) {
return undefined; return undefined;
@ -89,24 +113,45 @@ const EditTopic: FC<OwnProps & StateProps> = ({
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className="custom-scroll"> <div className={buildClassName(styles.content, 'custom-scroll')}>
{!topic && <Loading />} {!topic && <Loading />}
{topic && ( {topic && (
<div className={buildClassName(styles.top, 'section')}> <>
<span className={styles.heading}>{lang('CreateTopicTitle')}</span> <div className={buildClassName(styles.section, styles.top)}>
<TopicIcon <span className={styles.heading}>{lang('CreateTopicTitle')}</span>
topic={dummyTopic} <Transition
className={styles.icon} name="zoom-fade"
size={ICON_SIZE} activeKey={Number(dummyTopic.iconEmojiId) || 0}
/> shouldCleanup
<InputText direction={1}
value={title} className={styles.iconWrapper}
onChange={handleTitleChange} >
label={lang('lng_forum_topic_title')} <TopicIcon
disabled={isLoading} topic={dummyTopic}
teactExperimentControlled className={styles.icon}
/> size={ICON_SIZE}
</div> noLoopLimit
/>
</Transition>
<InputText
value={title}
onChange={handleTitleChange}
label={lang('lng_forum_topic_title')}
disabled={isLoading}
teactExperimentControlled
/>
</div>
{!isGeneral && (
<div className={buildClassName(styles.section, styles.bottom)}>
<CustomEmojiPicker
loadAndPlay={isActive}
onCustomEmojiSelect={handleCustomEmojiSelect}
className={styles.iconPicker}
withDefaultTopicIcons
/>
</div>
)}
</>
)} )}
</div> </div>
<FloatingActionButton <FloatingActionButton
@ -134,6 +179,7 @@ export default memo(withGlobal(
chat, chat,
topic, topic,
editTopicPanel, editTopicPanel,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
}; };
}, },
)(EditTopic)); )(EditTopic));

View File

@ -2,26 +2,48 @@
position: relative; position: relative;
height: 100%; height: 100%;
background-color: var(--color-background-secondary); background-color: var(--color-background-secondary);
--topic-icon-size: 5rem;
} }
.top { .content {
display: flex;
flex-direction: column;
height: 100%;
}
.section {
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
padding: 1rem 1.5rem;
background-color: var(--color-background); background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent); box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
} }
.icon { .top {
--custom-emoji-size: 5rem; padding: 1rem 1.5rem;
width: 5rem; }
height: 5rem;
font-size: 3rem; .bottom {
align-self: center; flex-grow: 1;
min-height: 0;
margin-bottom: 0;
}
.iconWrapper {
margin: 1.5rem 0; margin: 1.5rem 0;
align-self: center;
width: var(--topic-icon-size);
height: var(--topic-icon-size);
--custom-emoji-size: var(--topic-icon-size);
font-size: calc(var(--topic-icon-size) - 2rem);
}
.icon {
width: 100%;
height: 100%;
} }
.clickable { .clickable {
@ -33,3 +55,7 @@
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.icon-picker {
min-height: 10rem;
}

View File

@ -33,8 +33,8 @@ import StickerSearch from './StickerSearch.async';
import GifSearch from './GifSearch.async'; import GifSearch from './GifSearch.async';
import PollResults from './PollResults.async'; import PollResults from './PollResults.async';
import AddChatMembers from './AddChatMembers'; import AddChatMembers from './AddChatMembers';
import CreateTopic from './CreateTopic'; import CreateTopic from './CreateTopic.async';
import EditTopic from './EditTopic'; import EditTopic from './EditTopic.async';
import './RightColumn.scss'; import './RightColumn.scss';

View File

@ -171,6 +171,7 @@ export const RECENT_SYMBOL_SET_ID = 'recent';
export const FAVORITE_SYMBOL_SET_ID = 'favorite'; export const FAVORITE_SYMBOL_SET_ID = 'favorite';
export const CHAT_STICKER_SET_ID = 'chatStickers'; export const CHAT_STICKER_SET_ID = 'chatStickers';
export const PREMIUM_STICKER_SET_ID = 'premium'; export const PREMIUM_STICKER_SET_ID = 'premium';
export const DEFAULT_TOPIC_ICON_STICKER_ID = 'topic-default-icon';
export const EMOJI_IMG_REGEX = /<img[^>]+alt="([^"]+)"(?![^>]*data-document-id)[^>]*>/gm; export const EMOJI_IMG_REGEX = /<img[^>]+alt="([^"]+)"(?![^>]*data-document-id)[^>]*>/gm;
export const BASE_EMOJI_KEYWORD_LANG = 'en'; export const BASE_EMOJI_KEYWORD_LANG = 'en';

View File

@ -1373,13 +1373,16 @@ addActionHandler('loadTopics', async (global, actions, payload) => {
}); });
addActionHandler('loadTopicById', async (global, actions, payload) => { addActionHandler('loadTopicById', async (global, actions, payload) => {
const { chatId, topicId } = payload; const { chatId, topicId, shouldCloseChatOnError } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return; if (!chat) return;
const result = await callApi('fetchTopicById', { chat, topicId }); const result = await callApi('fetchTopicById', { chat, topicId });
if (!result) { if (!result) {
if (shouldCloseChatOnError) {
actions.openChat({ id: undefined });
}
return; return;
} }
@ -1413,7 +1416,9 @@ addActionHandler('toggleForum', async (global, actions, payload) => {
}); });
addActionHandler('createTopic', async (global, actions, payload) => { addActionHandler('createTopic', async (global, actions, payload) => {
const { chatId, title, iconColor } = payload; const {
chatId, title, iconColor, iconEmojiId,
} = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
if (!chat) return; if (!chat) return;
@ -1425,10 +1430,13 @@ addActionHandler('createTopic', async (global, actions, payload) => {
}, },
}); });
const topicId = await callApi('createTopic', { chat, title, iconColor }); const topicId = await callApi('createTopic', {
chat, title, iconColor, iconEmojiId,
});
if (topicId) { if (topicId) {
actions.openChat({ id: chatId, threadId: topicId, shouldReplaceHistory: true }); actions.openChat({ id: chatId, threadId: topicId, shouldReplaceHistory: true });
} }
actions.closeCreateTopicPanel();
}); });
addActionHandler('deleteTopic', async (global, actions, payload) => { addActionHandler('deleteTopic', async (global, actions, payload) => {
@ -1451,12 +1459,23 @@ addActionHandler('editTopic', async (global, actions, payload) => {
const topic = chat?.topics?.[topicId]; const topic = chat?.topics?.[topicId];
if (!chat || !topic) return; if (!chat || !topic) return;
setGlobal({
...global,
editTopicPanel: {
chatId,
topicId,
isLoading: true,
},
});
const result = await callApi('editTopic', { chat, topicId, ...rest }); const result = await callApi('editTopic', { chat, topicId, ...rest });
if (!result) return; if (!result) return;
global = getGlobal(); global = getGlobal();
global = updateTopic(global, chatId, topicId, rest); global = updateTopic(global, chatId, topicId, rest);
setGlobal(global); setGlobal(global);
actions.closeEditTopicPanel();
}); });
addActionHandler('toggleTopicPinned', (global, actions, payload) => { addActionHandler('toggleTopicPinned', (global, actions, payload) => {

View File

@ -198,6 +198,24 @@ addActionHandler('loadPremiumGifts', async () => {
}); });
}); });
addActionHandler('loadDefaultTopicIcons', async (global) => {
const stickerSet = await callApi('fetchDefaultTopicIcons');
if (!stickerSet) {
return;
}
global = getGlobal();
const { set, stickers } = stickerSet;
const fullSet = { ...set, stickers };
global = updateStickerSet(global, fullSet.id, fullSet);
setGlobal({
...global,
defaultTopicIconsId: fullSet.id,
});
});
addActionHandler('loadStickers', (global, actions, payload) => { addActionHandler('loadStickers', (global, actions, payload) => {
const { stickerSetInfo } = payload; const { stickerSetInfo } = payload;
const cachedSet = selectStickerSet(global, stickerSetInfo); const cachedSet = selectStickerSet(global, stickerSetInfo);

View File

@ -104,9 +104,6 @@ async function loadAndReplaceMessages() {
const currentChat = activeCurrentChatId ? global.chats.byId[activeCurrentChatId] : undefined; const currentChat = activeCurrentChatId ? global.chats.byId[activeCurrentChatId] : undefined;
if (activeCurrentChatId && currentChat) { if (activeCurrentChatId && currentChat) {
if (currentChat.isForum) {
getActions().loadTopics({ chatId: activeCurrentChatId, force: true });
}
const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId); const result = await loadTopMessages(currentChat, activeThreadId, threadInfo?.lastReadInboxMessageId);
global = getGlobal(); global = getGlobal();
const { chatId: newCurrentChatId } = selectCurrentMessageList(global) || {}; const { chatId: newCurrentChatId } = selectCurrentMessageList(global) || {};
@ -162,6 +159,15 @@ async function loadAndReplaceMessages() {
setGlobal(global); setGlobal(global);
if (currentChat?.isForum) {
getActions().loadTopics({ chatId: activeCurrentChatId!, force: true });
if (currentThreadId && currentThreadId !== MAIN_THREAD_ID) {
getActions().loadTopicById({
chatId: activeCurrentChatId!, topicId: currentThreadId, shouldCloseChatOnError: true,
});
}
}
const { chatId: audioChatId, messageId: audioMessageId } = global.audioPlayer; const { chatId: audioChatId, messageId: audioMessageId } = global.audioPlayer;
if (audioChatId && audioMessageId && !selectChatMessage(global, audioChatId, audioMessageId)) { if (audioChatId && audioMessageId && !selectChatMessage(global, audioChatId, audioMessageId)) {
getActions().closeAudioPlayer(); getActions().closeAudioPlayer();

View File

@ -23,6 +23,7 @@ import {
deleteChatScheduledMessages, deleteChatScheduledMessages,
updateThreadUnreadFromForwardedMessage, updateThreadUnreadFromForwardedMessage,
updateTopic, updateTopic,
deleteTopic,
} from '../../reducers'; } from '../../reducers';
import { import {
selectChatMessage, selectChatMessage,
@ -376,7 +377,7 @@ addActionHandler('apiUpdate', (global, actions, update) => {
case 'deleteMessages': { case 'deleteMessages': {
const { ids, chatId } = update; const { ids, chatId } = update;
deleteMessages(chatId, ids, actions, global); deleteMessages(global, chatId, ids, actions);
break; break;
} }
@ -402,7 +403,7 @@ addActionHandler('apiUpdate', (global, actions, update) => {
if (chatMessages) { if (chatMessages) {
const ids = Object.keys(chatMessages.byId).map(Number); const ids = Object.keys(chatMessages.byId).map(Number);
deleteMessages(chatId, ids, actions, getGlobal()); deleteMessages(global, chatId, ids, actions);
} else { } else {
actions.requestChatUpdate({ chatId }); actions.requestChatUpdate({ chatId });
} }
@ -734,7 +735,7 @@ function updateListedAndViewportIds(global: GlobalState, actions: GlobalActions,
global = replaceThreadParam(global, chatId, threadInfo.threadId, 'threadInfo', { global = replaceThreadParam(global, chatId, threadInfo.threadId, 'threadInfo', {
...threadInfo, ...threadInfo,
lastMessageId: message.id, lastMessageId: message.id,
messagesCount: threadInfo.messagesCount + 1, messagesCount: (threadInfo.messagesCount || 0) + 1,
}); });
} }
@ -808,10 +809,13 @@ function findLastMessage(global: GlobalState, chatId: string) {
return undefined; return undefined;
} }
function deleteMessages(chatId: string | undefined, ids: number[], actions: GlobalActions, global: GlobalState) { function deleteMessages(global: GlobalState, chatId: string | undefined, ids: number[], actions: GlobalActions) {
// Channel update // Channel update
if (chatId) { if (chatId) {
const chat = selectChat(global, chatId);
if (!chat) return;
ids.forEach((id) => { ids.forEach((id) => {
global = updateChatMessage(global, chatId, id, { global = updateChatMessage(global, chatId, id, {
isDeleting: true, isDeleting: true,
@ -821,6 +825,10 @@ function deleteMessages(chatId: string | undefined, ids: number[], actions: Glob
if (newLastMessage) { if (newLastMessage) {
global = updateChatLastMessage(global, chatId, newLastMessage, true); global = updateChatLastMessage(global, chatId, newLastMessage, true);
} }
if (chat.topics?.[id]) {
global = deleteTopic(global, chatId, id);
}
}); });
actions.requestChatUpdate({ chatId }); actions.requestChatUpdate({ chatId });

View File

@ -5,13 +5,13 @@ import { MAIN_THREAD_ID } from '../../../api/types';
import { import {
exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList, exitMessageSelectMode, replaceThreadParam, updateCurrentMessageList,
} from '../../reducers'; } from '../../reducers';
import { selectCurrentMessageList } from '../../selectors'; import { selectChat, selectCurrentMessageList } from '../../selectors';
import { closeLocalTextSearch } from './localSearch'; import { closeLocalTextSearch } from './localSearch';
addActionHandler('openChat', (global, actions, payload) => { addActionHandler('openChat', (global, actions, payload) => {
const { const {
id, id,
threadId = MAIN_THREAD_ID, threadId,
type = 'thread', type = 'thread',
shouldReplaceHistory = false, shouldReplaceHistory = false,
} = payload; } = payload;
@ -35,7 +35,7 @@ addActionHandler('openChat', (global, actions, payload) => {
|| currentMessageList.type !== type || currentMessageList.type !== type
)) { )) {
if (id) { if (id) {
global = replaceThreadParam(global, id, threadId, 'replyStack', []); global = replaceThreadParam(global, id, threadId || MAIN_THREAD_ID, 'replyStack', []);
} }
global = exitMessageSelectMode(global); global = exitMessageSelectMode(global);
@ -54,10 +54,16 @@ addActionHandler('openChat', (global, actions, payload) => {
}; };
} }
if (id !== global.forumPanelChatId) { if (id && id !== global.forumPanelChatId) {
actions.closeForumPanel(); actions.closeForumPanel();
} }
if (id && !threadId) {
const chat = selectChat(global, id);
// Prevent chat opening on forum click
if (chat?.isForum) return global;
}
return updateCurrentMessageList(global, id, threadId, type, shouldReplaceHistory); return updateCurrentMessageList(global, id, threadId, type, shouldReplaceHistory);
}); });

View File

@ -1,11 +1,14 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import type { ApiError, ApiNotification } from '../../../api/types'; import type { ApiError, ApiNotification } from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT } from '../../../config'; import { APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT } from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment';
import getReadableErrorText from '../../../util/getReadableErrorText'; import getReadableErrorText from '../../../util/getReadableErrorText';
import { selectChatMessage, selectCurrentMessageList, selectIsTrustedBot } from '../../selectors'; import {
selectChatMessage, selectCurrentChat, selectCurrentMessageList, selectIsTrustedBot,
} from '../../selectors';
import generateIdFor from '../../../util/generateIdFor'; import generateIdFor from '../../../util/generateIdFor';
import { unique } from '../../../util/iteratees'; import { unique } from '../../../util/iteratees';
@ -443,6 +446,10 @@ addActionHandler('updateLastRenderedCustomEmojis', (global, actions, payload) =>
addActionHandler('openCreateTopicPanel', (global, actions, payload) => { addActionHandler('openCreateTopicPanel', (global, actions, payload) => {
const { chatId } = payload; const { chatId } = payload;
// Topic panel can be opened only if there is a selected chat
const currentChat = selectCurrentChat(global);
if (!currentChat) actions.openChat({ id: chatId, threadId: MAIN_THREAD_ID });
return { return {
...global, ...global,
createTopicPanel: { createTopicPanel: {
@ -461,6 +468,10 @@ addActionHandler('closeCreateTopicPanel', (global) => {
addActionHandler('openEditTopicPanel', (global, actions, payload) => { addActionHandler('openEditTopicPanel', (global, actions, payload) => {
const { chatId, topicId } = payload; const { chatId, topicId } = payload;
// Topic panel can be opened only if there is a selected chat
const currentChat = selectCurrentChat(global);
if (!currentChat) actions.openChat({ id: chatId });
return { return {
...global, ...global,
editTopicPanel: { editTopicPanel: {

View File

@ -250,8 +250,7 @@ export function deleteChatMessages(
global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds); global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'pinnedIds', mainPinnedIds);
if (threadInfo && newMessageCount !== undefined) { if (threadInfo && newMessageCount !== undefined) {
global = replaceThreadParam(global, chatId, threadId, 'threadInfo', { global = updateThreadInfo(global, chatId, threadId, {
...threadInfo,
messagesCount: newMessageCount, messagesCount: newMessageCount,
}); });
} }
@ -581,7 +580,7 @@ export function updateThreadUnreadFromForwardedMessage(
global = replaceThreadParam(global, chatId, channelPostId, 'threadInfo', { global = replaceThreadParam(global, chatId, channelPostId, 'threadInfo', {
...threadInfoOld, ...threadInfoOld,
lastMessageId, lastMessageId,
messagesCount: threadInfoOld.messagesCount + (isDeleting ? -1 : 1), messagesCount: (threadInfoOld.messagesCount || 0) + (isDeleting ? -1 : 1),
}); });
} }
} }

View File

@ -173,6 +173,15 @@ export function selectReplyStack(global: GlobalState, chatId: string, threadId:
return selectThreadParam(global, chatId, threadId, 'replyStack'); return selectThreadParam(global, chatId, threadId, 'replyStack');
} }
export function selectThreadMessagesCount(global: GlobalState, chatId: string, threadId: number) {
const chat = selectChat(global, chatId);
const threadInfo = selectThreadInfo(global, chatId, threadId);
if (!chat || !threadInfo || threadInfo.messagesCount === undefined) return undefined;
// In forum topics first message is ignored, but not in General
if (chat.isForum && threadId !== GENERAL_TOPIC_ID) return threadInfo.messagesCount - 1;
return threadInfo.messagesCount;
}
export function selectThreadOriginChat(global: GlobalState, chatId: string, threadId: number) { export function selectThreadOriginChat(global: GlobalState, chatId: string, threadId: number) {
if (threadId === MAIN_THREAD_ID) { if (threadId === MAIN_THREAD_ID) {
return selectChat(global, chatId); return selectChat(global, chatId);

View File

@ -349,6 +349,7 @@ export type GlobalState = {
animatedEmojiEffects?: ApiStickerSet; animatedEmojiEffects?: ApiStickerSet;
genericEmojiEffects?: ApiStickerSet; genericEmojiEffects?: ApiStickerSet;
premiumGifts?: ApiStickerSet; premiumGifts?: ApiStickerSet;
defaultTopicIconsId?: string;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>; emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
gifs: { gifs: {
@ -1305,6 +1306,7 @@ export interface ActionPayloads {
}; };
loadPremiumGifts: never; loadPremiumGifts: never;
loadDefaultTopicIcons: never;
loadPremiumStickers: { loadPremiumStickers: {
hash?: string; hash?: string;
}; };
@ -1335,6 +1337,7 @@ export interface ActionPayloads {
chatId: string; chatId: string;
title: string; title: string;
iconColor?: number; iconColor?: number;
iconEmojiId?: string;
}; };
loadTopics: { loadTopics: {
chatId: string; chatId: string;
@ -1343,6 +1346,7 @@ export interface ActionPayloads {
loadTopicById: { loadTopicById: {
chatId: string; chatId: string;
topicId: number; topicId: number;
shouldCloseChatOnError?: boolean;
}; };
deleteTopic: { deleteTopic: {