Management: Topics (#2245)

This commit is contained in:
Alexander Zinchuk 2023-01-07 00:04:34 +01:00
parent 849bd6f13f
commit c9ed4b16c1
29 changed files with 823 additions and 96 deletions

View File

@ -1032,7 +1032,7 @@ function buildAction(
text = action.closed ? 'TopicWasClosedAction' : 'TopicWasReopenedAction'; text = action.closed ? 'TopicWasClosedAction' : 'TopicWasReopenedAction';
translationValues.push('%action_origin%', '%action_topic%'); translationValues.push('%action_origin%', '%action_topic%');
} else if (action.hidden !== undefined) { } else if (action.hidden !== undefined) {
text = action.hidden ? 'TopicHidden2' : 'TopicWasUnhiddenAction'; text = action.hidden ? 'TopicHidden2' : 'TopicShown';
} else if (action.title) { } else if (action.title) {
text = 'TopicRenamedTo'; text = 'TopicRenamedTo';
translationValues.push('%action_origin%', action.title); translationValues.push('%action_origin%', action.title);

View File

@ -51,7 +51,9 @@ import {
isMessageWithMedia, isMessageWithMedia,
buildChatBannedRights, buildChatBannedRights,
buildChatAdminRights, buildChatAdminRights,
buildInputChatReactions, buildInputPhoto, buildInputChatReactions,
buildInputPhoto,
generateRandomBigInt,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers'; import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -1333,6 +1335,36 @@ export function toggleForum({
}), true); }), true);
} }
export async function createTopic({
chat, title, iconColor, iconEmojiId, sendAs,
}: {
chat: ApiChat;
title: string;
iconColor?: number;
iconEmojiId?: string;
sendAs?: ApiUser | ApiChat;
}) {
const { id, accessHash } = chat;
const updates = await invokeRequest(new GramJs.channels.CreateForumTopic({
channel: buildInputPeer(id, accessHash),
title,
iconColor,
iconEmojiId: iconEmojiId ? BigInt(iconEmojiId) : undefined,
sendAs: sendAs ? buildInputPeer(sendAs.id, sendAs.accessHash) : undefined,
randomId: generateRandomBigInt(),
}));
if (!(updates instanceof GramJs.Updates) || !updates.updates.length) {
return undefined;
}
// Finding topic id in updates
return updates.updates?.find((update): update is GramJs.UpdateMessageID => (
update instanceof GramJs.UpdateMessageID
))?.id;
}
export async function fetchTopics({ export async function fetchTopics({
chat, query, offsetTopicId, offsetId, offsetDate, limit = TOPICS_SLICE, chat, query, offsetTopicId, offsetId, offsetDate, limit = TOPICS_SLICE,
}: { }: {

View File

@ -20,7 +20,7 @@ export {
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected,
getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic, getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic,
editTopic, toggleForum, fetchTopicById, editTopic, toggleForum, fetchTopicById, createTopic,
} from './chats'; } from './chats';
export { export {

View File

@ -19,12 +19,12 @@
z-index: 1; z-index: 1;
color: #ffffff; color: #ffffff;
font-weight: 500; font-weight: 500;
font-size: 0.75rem; font-size: 0.75em;
position: relative; position: relative;
bottom: 0.0625rem; bottom: 0.0625rem;
:global(.emoji) { :global(.emoji) {
width: 0.75rem; width: 1em;
height: 0.75rem; height: 1em;
} }
} }

View File

@ -16,6 +16,7 @@ type OwnProps = {
topicId: number; topicId: number;
iconColor?: number; iconColor?: number;
title: string; title: string;
onClick?: NoneToVoidFunction;
}; };
const TopicDefaultIcon: FC<OwnProps> = ({ const TopicDefaultIcon: FC<OwnProps> = ({
@ -24,15 +25,18 @@ const TopicDefaultIcon: FC<OwnProps> = ({
topicId, topicId,
iconColor, iconColor,
title, title,
onClick,
}) => { }) => {
const iconSrc = getTopicDefaultIcon(iconColor); const iconSrc = getTopicDefaultIcon(iconColor);
if (topicId === GENERAL_TOPIC_ID) { if (topicId === GENERAL_TOPIC_ID) {
return <i className={buildClassName(styles.root, className, 'icon-hashtag', 'general-forum-icon')} />; return (
<i className={buildClassName(styles.root, className, 'icon-hashtag', 'general-forum-icon')} onClick={onClick} />
);
} }
return ( return (
<div className={buildClassName(styles.root, className)}> <div className={buildClassName(styles.root, className)} onClick={onClick}>
<img className={styles.icon} src={iconSrc} alt="" /> <img className={styles.icon} src={iconSrc} alt="" draggable={false} />
<div className={buildClassName(styles.title, letterClassName, 'topic-icon-letter')}> <div className={buildClassName(styles.title, letterClassName, 'topic-icon-letter')}>
{renderText(getFirstLetters(title, 1))} {renderText(getFirstLetters(title, 1))}
</div> </div>

View File

@ -8,12 +8,13 @@ import CustomEmoji from './CustomEmoji';
import TopicDefaultIcon from './TopicDefaultIcon'; import TopicDefaultIcon from './TopicDefaultIcon';
type OwnProps = { type OwnProps = {
topic: ApiTopic; topic: Pick<ApiTopic, 'iconEmojiId' | 'iconColor' | 'title' | 'id'>;
className?: string; className?: string;
letterClassName?: string; letterClassName?: string;
size?: number; size?: number;
noLoopLimit?: true; noLoopLimit?: true;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onClick?: NoneToVoidFunction;
}; };
const LOOP_LIMIT = 2; const LOOP_LIMIT = 2;
@ -25,6 +26,7 @@ const TopicIcon: FC<OwnProps> = ({
size, size,
noLoopLimit, noLoopLimit,
observeIntersection, observeIntersection,
onClick,
}) => { }) => {
if (topic.iconEmojiId) { if (topic.iconEmojiId) {
return ( return (
@ -34,6 +36,7 @@ const TopicIcon: FC<OwnProps> = ({
size={size} size={size}
observeIntersectionForPlaying={observeIntersection} observeIntersectionForPlaying={observeIntersection}
loopLimit={!noLoopLimit ? LOOP_LIMIT : undefined} loopLimit={!noLoopLimit ? LOOP_LIMIT : undefined}
onClick={onClick}
/> />
); );
} }
@ -45,6 +48,7 @@ const TopicIcon: FC<OwnProps> = ({
topicId={topic.id} topicId={topic.id}
className={className} className={className}
letterClassName={letterClassName} letterClassName={letterClassName}
onClick={onClick}
/> />
); );
}; };

View File

@ -54,3 +54,7 @@
} }
} }
} }
.centered {
text-align: center;
}

View File

@ -0,0 +1,57 @@
.root {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 1rem;
:global(.Button.pill) {
max-width: 100%;
margin-top: 0.625rem;
font-weight: 500;
padding-inline-start: 0.75rem;
unicode-bidi: plaintext;
justify-content: start;
.button-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
i {
margin-inline-end: 0.625rem;
font-size: 1.5rem;
}
}
}
.sticker {
height: 7rem;
margin-bottom: 1.875rem;
}
.title {
font-size: 1.25rem;
margin-bottom: 0.125rem;
word-break: break-word;
text-align: center;
max-width: 100%;
}
.description {
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
body.is-ios &,
body.is-macos & {
color: var(--color-text-secondary-apple);
}
}

View File

@ -0,0 +1,74 @@
import React, { memo, useCallback } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import { selectAnimatedEmoji, selectChat } from '../../../global/selectors';
import { getHasAdminRight } from '../../../global/helpers';
import { REM } from '../../common/helpers/mediaDimensions';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import styles from './EmptyForum.module.scss';
type OwnProps = {
chatId: string;
};
type StateProps = {
animatedEmoji?: ApiSticker;
canManageTopics?: boolean;
};
const ICON_SIZE = 7 * REM;
const EmptyForum: FC<OwnProps & StateProps> = ({
chatId, animatedEmoji, canManageTopics,
}) => {
const { openCreateTopicPanel } = getActions();
const lang = useLang();
const handleCreateTopic = useCallback(() => {
openCreateTopicPanel({ chatId });
}, [chatId, openCreateTopicPanel]);
return (
<div className={styles.root}>
<div className={styles.sticker}>
{animatedEmoji && <AnimatedIconFromSticker sticker={animatedEmoji} size={ICON_SIZE} />}
</div>
<h3 className={styles.title} dir="auto">{lang('ChatList.EmptyTopicsTitle')}</h3>
<p className={buildClassName(styles.description, styles.centered)} dir="auto">
{lang('ChatList.EmptyTopicsDescription')}
</p>
{canManageTopics && (
<Button
ripple={!IS_SINGLE_COLUMN_LAYOUT}
fluid
onClick={handleCreateTopic}
size="smaller"
isRtl={lang.isRtl}
>
<div className={styles.buttonText}>
{lang('ChatList.EmptyTopicsCreate')}
</div>
</Button>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>((global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
const canManageTopics = chat && (chat.isCreator || getHasAdminRight(chat, 'manageTopics'));
return {
animatedEmoji: selectAnimatedEmoji(global, '🐣'),
canManageTopics,
};
})(EmptyForum));

View File

@ -1,58 +0,0 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiSticker } from '../../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import { selectAnimatedEmoji } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker';
import styles from './EmptyFolder.module.scss';
type StateProps = {
animatedEmoji?: ApiSticker;
};
const ICON_SIZE = 96;
// TODO[forums] Open create topic screen if has permission
const EmptyTopic: FC<StateProps> = ({
animatedEmoji,
}) => {
const lang = useLang();
const handleCreateTopic = useCallback(() => {
}, []);
return (
<div className={styles.root}>
<div className={styles.sticker}>
{animatedEmoji && <AnimatedIconFromSticker sticker={animatedEmoji} size={ICON_SIZE} />}
</div>
<h3 className={styles.title} dir="auto">{lang('ChatList.EmptyTopicsTitle')}</h3>
<Button
ripple={!IS_SINGLE_COLUMN_LAYOUT}
fluid
pill
onClick={handleCreateTopic}
size="smaller"
isRtl={lang.isRtl}
>
<i className="icon-add" />
<div className={styles.buttonText}>
{lang('ChatList.EmptyTopicsCreate')}
</div>
</Button>
</div>
);
};
export default memo(withGlobal((global): StateProps => {
return {
animatedEmoji: selectAnimatedEmoji(global, '👀'),
};
})(EmptyTopic));

View File

@ -8,6 +8,7 @@ import type { ApiChat } from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { import {
GENERAL_TOPIC_ID,
TOPICS_SLICE, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA, TOPICS_SLICE, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA,
} from '../../../config'; } from '../../../config';
import { selectChat, selectCurrentMessageList } from '../../../global/selectors'; import { selectChat, selectCurrentMessageList } from '../../../global/selectors';
@ -32,7 +33,7 @@ import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import HeaderActions from '../../middle/HeaderActions'; import HeaderActions from '../../middle/HeaderActions';
import GroupCallTopPane from '../../calls/group/GroupCallTopPane'; import GroupCallTopPane from '../../calls/group/GroupCallTopPane';
import EmptyTopic from './EmptyTopic'; import EmptyForum from './EmptyForum';
import styles from './ForumPanel.module.scss'; import styles from './ForumPanel.module.scss';
@ -224,14 +225,16 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
sensitiveArea={TOPIC_LIST_SENSITIVE_AREA} sensitiveArea={TOPIC_LIST_SENSITIVE_AREA}
beforeChildren={<div ref={scrollTopHandlerRef} className={styles.scrollTopHandler} />} beforeChildren={<div ref={scrollTopHandlerRef} className={styles.scrollTopHandler} />}
> >
{viewportIds?.length ? ( {viewportIds?.length && (
renderTopics() renderTopics()
) : !isLoading ? ( )}
<EmptyTopic /> {isLoading && !viewportIds?.length && (
) : (
<Loading key="loading" /> <Loading key="loading" />
)} )}
</InfiniteScroll> </InfiniteScroll>
{!isLoading && viewportIds?.length === 1 && viewportIds[0] === GENERAL_TOPIC_ID && (
<EmptyForum chatId={chat.id} />
)}
</div> </div>
); );
}; };

View File

@ -21,7 +21,14 @@ import {
selectCurrentMessageList, selectCurrentMessageList,
} from '../../global/selectors'; } from '../../global/selectors';
import { import {
isUserId, getCanDeleteChat, selectIsChatMuted, getCanAddContact, isChatChannel, isChatGroup, isUserId,
getCanDeleteChat,
selectIsChatMuted,
getCanAddContact,
isChatChannel,
isChatGroup,
getHasAdminRight,
getCanManageTopic,
} 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';
@ -30,6 +37,7 @@ import useLang from '../../hooks/useLang';
import Portal from '../ui/Portal'; import Portal from '../ui/Portal';
import Menu from '../ui/Menu'; import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import MenuSeparator from '../ui/MenuSeparator';
import DeleteChatModal from '../common/DeleteChatModal'; import DeleteChatModal from '../common/DeleteChatModal';
import ReportModal from '../common/ReportModal'; import ReportModal from '../common/ReportModal';
@ -87,6 +95,8 @@ type StateProps = {
canReportChat?: boolean; canReportChat?: boolean;
canDeleteChat?: boolean; canDeleteChat?: boolean;
canGiftPremium?: boolean; canGiftPremium?: boolean;
canCreateTopic?: boolean;
canEditTopic?: boolean;
hasLinkedChat?: boolean; hasLinkedChat?: boolean;
isChatInfoShown?: boolean; isChatInfoShown?: boolean;
}; };
@ -123,6 +133,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
canGiftPremium, canGiftPremium,
hasLinkedChat, hasLinkedChat,
canAddContact, canAddContact,
canCreateTopic,
canEditTopic,
onJoinRequestsClick, onJoinRequestsClick,
onSubscribeChannel, onSubscribeChannel,
onSearchClick, onSearchClick,
@ -143,6 +155,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
toggleStatistics, toggleStatistics,
openGiftPremiumModal, openGiftPremiumModal,
openChatWithInfo, openChatWithInfo,
openCreateTopicPanel,
openEditTopicPanel,
} = getActions(); } = getActions();
const [isMenuOpen, setIsMenuOpen] = useState(true); const [isMenuOpen, setIsMenuOpen] = useState(true);
@ -198,6 +212,16 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu(); closeMenu();
}, [chatId, closeMenu, isMuted, updateChatMutedState]); }, [chatId, closeMenu, isMuted, updateChatMutedState]);
const handleCreateTopicClick = useCallback(() => {
openCreateTopicPanel({ chatId });
closeMenu();
}, [openCreateTopicPanel, chatId, closeMenu]);
const handleEditTopicClick = useCallback(() => {
openEditTopicPanel({ chatId, topicId: threadId });
closeMenu();
}, [openEditTopicPanel, chatId, threadId, closeMenu]);
const handleEnterVoiceChatClick = useCallback(() => { const handleEnterVoiceChatClick = useCallback(() => {
if (canCreateVoiceChat) { if (canCreateVoiceChat) {
// TODO show popup to schedule // TODO show popup to schedule
@ -301,6 +325,17 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
style={`left: ${x}px;top: ${y}px;`} style={`left: ${x}px;top: ${y}px;`}
onClose={closeMenu} onClose={closeMenu}
> >
{withForumActions && canCreateTopic && (
<>
<MenuItem
icon="comments"
onClick={handleCreateTopicClick}
>
{lang('lng_forum_create_topic')}
</MenuItem>
<MenuSeparator />
</>
)}
{isViewGroupInfoShown && ( {isViewGroupInfoShown && (
<MenuItem <MenuItem
icon="info" icon="info"
@ -309,6 +344,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{isTopic ? lang('lng_context_view_topic') : lang('lng_context_view_group')} {isTopic ? lang('lng_context_view_topic') : lang('lng_context_view_group')}
</MenuItem> </MenuItem>
)} )}
{canEditTopic && (
<MenuItem
icon="edit"
onClick={handleEditTopicClick}
>
{lang('lng_forum_topic_edit')}
</MenuItem>
)}
{withForumActions && Boolean(pendingJoinRequests) && ( {withForumActions && Boolean(pendingJoinRequests) && (
<MenuItem <MenuItem
icon="user" icon="user"
@ -440,15 +483,18 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
</MenuItem> </MenuItem>
)} )}
{canLeave && ( {canLeave && (
<MenuItem <>
destructive <MenuSeparator />
icon="delete" <MenuItem
onClick={handleDelete} destructive
> icon="delete"
{lang(isPrivate onClick={handleDelete}
? 'DeleteChatUser' >
: (canDeleteChat ? 'GroupInfo.DeleteAndExit' : (isChannel ? 'LeaveChannel' : 'Group.LeaveGroup')))} {lang(isPrivate
</MenuItem> ? 'DeleteChatUser'
: (canDeleteChat ? 'GroupInfo.DeleteAndExit' : (isChannel ? 'LeaveChannel' : 'Group.LeaveGroup')))}
</MenuItem>
</>
)} )}
</Menu> </Menu>
{chat && ( {chat && (
@ -491,6 +537,10 @@ export default memo(withGlobal<OwnProps>(
&& !selectIsPremiumPurchaseBlocked(global), && !selectIsPremiumPurchaseBlocked(global),
); );
const topic = chat?.topics?.[threadId];
const canCreateTopic = chat.isForum && (chat.isCreator || getHasAdminRight(chat, 'manageTopics'));
const canEditTopic = topic && getCanManageTopic(chat, topic);
return { return {
chat, chat,
isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)), isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)),
@ -503,6 +553,8 @@ export default memo(withGlobal<OwnProps>(
hasLinkedChat: Boolean(chat?.fullInfo?.linkedChatId), hasLinkedChat: Boolean(chat?.fullInfo?.linkedChatId),
botCommands: chatBot?.fullInfo?.botInfo?.commands, botCommands: chatBot?.fullInfo?.botInfo?.commands,
isChatInfoShown: global.isChatInfoShown && currentChatId === chatId && currentThreadId === threadId, isChatInfoShown: global.isChatInfoShown && currentChatId === chatId && currentThreadId === threadId,
canCreateTopic,
canEditTopic,
}; };
}, },
)(HeaderMenuContainer)); )(HeaderMenuContainer));

View File

@ -4,7 +4,9 @@ import React, {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiBotInfo, ApiMessage, ApiRestrictionReason } from '../../api/types'; import type {
ApiBotInfo, ApiMessage, ApiRestrictionReason, ApiTopic,
} from '../../api/types';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { AnimationLevel } from '../../types'; import type { AnimationLevel } from '../../types';
@ -107,6 +109,7 @@ type StateProps = {
threadFirstMessageId?: number; threadFirstMessageId?: number;
hasLinkedChat?: boolean; hasLinkedChat?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
topic?: ApiTopic;
}; };
const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000; const MESSAGE_REACTIONS_POLLING_INTERVAL = 15 * 1000;
@ -155,6 +158,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
lastSyncTime, lastSyncTime,
withBottomShift, withBottomShift,
withDefaultBg, withDefaultBg,
topic,
}) => { }) => {
const { const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
@ -505,6 +509,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
const isGroupChatJustCreated = isGroupChat && isCreator const isGroupChatJustCreated = isGroupChat && isCreator
&& messageIds?.length === 1 && messagesById?.[messageIds[0]]?.content.action?.type === 'chatCreate'; && messageIds?.length === 1 && messagesById?.[messageIds[0]]?.content.action?.type === 'chatCreate';
const isEmptyTopic = messageIds?.length === 1
&& messagesById?.[messageIds[0]]?.content.action?.type === 'topicCreate';
const className = buildClassName( const className = buildClassName(
'MessageList custom-scroll', 'MessageList custom-scroll',
@ -577,9 +583,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
</div> </div>
) : shouldRenderGreeting ? ( ) : shouldRenderGreeting ? (
<ContactGreeting userId={chatId} /> <ContactGreeting userId={chatId} />
) : messageIds && (!messageGroups || isGroupChatJustCreated) ? ( ) : messageIds && (!messageGroups || isGroupChatJustCreated || isEmptyTopic) ? (
<NoMessages <NoMessages
chatId={chatId} chatId={chatId}
topic={topic}
type={type} type={type}
isChatWithSelf={isChatWithSelf} isChatWithSelf={isChatWithSelf}
isGroupChatJustCreated={isGroupChatJustCreated} isGroupChatJustCreated={isGroupChatJustCreated}
@ -656,6 +663,8 @@ export default memo(withGlobal<OwnProps>(
} }
} }
const topic = chat.topics?.[threadId];
return { return {
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
isChatLoaded: true, isChatLoaded: true,
@ -681,6 +690,7 @@ export default memo(withGlobal<OwnProps>(
? Boolean(chat.fullInfo.linkedChatId) ? Boolean(chat.fullInfo.linkedChatId)
: undefined, : undefined,
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
topic,
...(withLastMessageWhenPreloading && { lastMessage }), ...(withLastMessageWhenPreloading && { lastMessage }),
}; };
}, },

View File

@ -10,6 +10,13 @@
margin: 0 auto 1rem; margin: 0 auto 1rem;
} }
.topic-icon {
--custom-emoji-size: 3rem;
width: 3rem;
height: 3rem;
font-size: 2rem;
}
.wrapper { .wrapper {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
@ -40,6 +47,11 @@
unicode-bidi: plaintext; unicode-bidi: plaintext;
} }
.topic-description {
text-align: center;
padding: 0 1rem;
}
.list-checkmarks { .list-checkmarks {
font-size: 0.9375rem; font-size: 0.9375rem;
margin: 0.25rem 0 0; margin: 0.25rem 0 0;

View File

@ -1,22 +1,33 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import type { MessageListType } from '../../global/types'; import type { MessageListType } from '../../global/types';
import type { ApiTopic } from '../../api/types';
import type { LangFn } from '../../hooks/useLang'; import type { LangFn } from '../../hooks/useLang';
import { REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import TopicIcon from '../common/TopicIcon';
import './NoMessages.scss'; import './NoMessages.scss';
const ICON_SIZE = 3 * REM;
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
isChatWithSelf?: boolean; isChatWithSelf?: boolean;
type: MessageListType; type: MessageListType;
isGroupChatJustCreated?: boolean; isGroupChatJustCreated?: boolean;
topic?: ApiTopic;
}; };
const NoMessages: FC<OwnProps> = ({ const NoMessages: FC<OwnProps> = ({
isChatWithSelf, type, isGroupChatJustCreated, isChatWithSelf,
type,
isGroupChatJustCreated,
topic,
}) => { }) => {
const lang = useLang(); const lang = useLang();
@ -32,11 +43,27 @@ const NoMessages: FC<OwnProps> = ({
return renderGroup(lang); return renderGroup(lang);
} }
if (topic) {
return renderTopic(lang, topic);
}
return ( return (
<div className="empty"><span>{lang('NoMessages')}</span></div> <div className="empty"><span>{lang('NoMessages')}</span></div>
); );
}; };
function renderTopic(lang: LangFn, topic: ApiTopic) {
return (
<div className="NoMessages">
<div className="wrapper">
<TopicIcon topic={topic} size={ICON_SIZE} className="icon topic-icon" />
<h3 className="title">{lang('Chat.EmptyTopicPlaceholder.Title')}</h3>
<p className="description topic-description">{renderText(lang('Chat.EmptyTopicPlaceholder.Text'), ['br'])}</p>
</div>
</div>
);
}
function renderScheduled(lang: LangFn) { function renderScheduled(lang: LangFn) {
return ( return (
<div className="empty"><span>{lang('ScheduledMessages.EmptyPlaceholder')}</span></div> <div className="empty"><span>{lang('ScheduledMessages.EmptyPlaceholder')}</span></div>

View File

@ -0,0 +1,130 @@
import React, {
memo, useCallback, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiChat } from '../../api/types';
import type { GlobalState } from '../../global/types';
import { selectChat } from '../../global/selectors';
import { getTopicColors } from '../../util/forumColors';
import cycleRestrict from '../../util/cycleRestrict';
import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions';
import useLang from '../../hooks/useLang';
import useHistoryBack from '../../hooks/useHistoryBack';
import TopicIcon from '../common/TopicIcon';
import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
import styles from './ManageTopic.module.scss';
const ICON_SIZE = 5 * REM;
type OwnProps = {
isActive: boolean;
onClose: NoneToVoidFunction;
};
type StateProps = {
chat?: ApiChat;
createTopicPanel?: GlobalState['createTopicPanel'];
};
const CreateTopic: FC<OwnProps & StateProps> = ({
isActive,
chat,
createTopicPanel,
onClose,
}) => {
const { createTopic, closeCreateTopicPanel } = getActions();
const [title, setTitle] = useState('');
const [iconColorIndex, setIconColorIndex] = useState(0);
const lang = useLang();
const isTouched = Boolean(title);
const isLoading = Boolean(createTopicPanel?.isLoading);
useHistoryBack({
isActive,
onBack: onClose,
});
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
}, []);
const handleIconClick = useCallback(() => {
setIconColorIndex((prev) => cycleRestrict(getTopicColors().length, prev + 1));
}, []);
const handleCreateTopic = useCallback(() => {
createTopic({
chatId: chat!.id,
title,
iconColor: getTopicColors()[iconColorIndex],
});
closeCreateTopicPanel();
}, [chat, closeCreateTopicPanel, createTopic, iconColorIndex, title]);
const dummyTopic = useMemo(() => {
return {
id: 0,
title,
iconColor: getTopicColors()[iconColorIndex],
};
}, [iconColorIndex, title]);
if (!chat?.isForum) {
return undefined;
}
return (
<div className={styles.root}>
<div className="custom-scroll">
<div className={buildClassName(styles.top, 'section')}>
<span className={styles.heading}>{lang('CreateTopicTitle')}</span>
<TopicIcon
topic={dummyTopic}
className={buildClassName(styles.icon, styles.clickable)}
onClick={handleIconClick}
size={ICON_SIZE}
/>
<InputText
value={title}
onChange={handleTitleChange}
label={lang('lng_forum_topic_title')}
disabled={isLoading}
teactExperimentControlled
/>
</div>
</div>
<FloatingActionButton
isShown={isTouched}
disabled={isLoading}
onClick={handleCreateTopic}
ariaLabel={lang('Save')}
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon-check" />
)}
</FloatingActionButton>
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
const { createTopicPanel } = global;
return {
chat: createTopicPanel?.chatId ? selectChat(global, createTopicPanel.chatId) : undefined,
createTopicPanel,
};
},
)(CreateTopic));

View File

@ -0,0 +1,139 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiChat, ApiTopic } from '../../api/types';
import type { GlobalState } from '../../global/types';
import { selectChat } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions';
import useLang from '../../hooks/useLang';
import useHistoryBack from '../../hooks/useHistoryBack';
import TopicIcon from '../common/TopicIcon';
import InputText from '../ui/InputText';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
import Loading from '../ui/Loading';
import styles from './ManageTopic.module.scss';
const ICON_SIZE = 5 * REM;
type OwnProps = {
isActive: boolean;
onClose: NoneToVoidFunction;
};
type StateProps = {
chat?: ApiChat;
topic?: ApiTopic;
editTopicPanel?: GlobalState['editTopicPanel'];
};
const EditTopic: FC<OwnProps & StateProps> = ({
isActive,
chat,
topic,
editTopicPanel,
onClose,
}) => {
const { editTopic, closeEditTopicPanel } = getActions();
const [title, setTitle] = useState('');
const [isTouched, setIsTouched] = useState(false);
const lang = useLang();
const isLoading = Boolean(editTopicPanel?.isLoading);
useHistoryBack({
isActive,
onBack: onClose,
});
useEffect(() => {
if (topic?.title) {
setTitle(topic.title);
setIsTouched(false);
}
}, [topic?.title]);
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newTitle = e.target.value;
setTitle(newTitle);
setIsTouched(newTitle !== topic?.title);
}, [topic?.title]);
const handleEditTopic = useCallback(() => {
editTopic({
chatId: chat!.id,
title,
topicId: topic!.id,
});
closeEditTopicPanel();
}, [chat, closeEditTopicPanel, editTopic, title, topic]);
const dummyTopic = useMemo(() => {
return {
...topic!,
title,
};
}, [title, topic]);
if (!chat?.isForum) {
return undefined;
}
return (
<div className={styles.root}>
<div className="custom-scroll">
{!topic && <Loading />}
{topic && (
<div className={buildClassName(styles.top, 'section')}>
<span className={styles.heading}>{lang('CreateTopicTitle')}</span>
<TopicIcon
topic={dummyTopic}
className={styles.icon}
size={ICON_SIZE}
/>
<InputText
value={title}
onChange={handleTitleChange}
label={lang('lng_forum_topic_title')}
disabled={isLoading}
teactExperimentControlled
/>
</div>
)}
</div>
<FloatingActionButton
isShown={isTouched}
disabled={isLoading}
onClick={handleEditTopic}
ariaLabel={lang('Save')}
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon-check" />
)}
</FloatingActionButton>
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
const { editTopicPanel } = global;
const chat = editTopicPanel?.chatId ? selectChat(global, editTopicPanel.chatId) : undefined;
const topic = editTopicPanel?.topicId ? chat?.topics?.[editTopicPanel?.topicId] : undefined;
return {
chat,
topic,
editTopicPanel,
};
},
)(EditTopic));

View File

@ -0,0 +1,35 @@
.root {
position: relative;
height: 100%;
background-color: var(--color-background-secondary);
}
.top {
display: flex;
justify-content: center;
flex-direction: column;
padding: 1rem 1.5rem;
background-color: var(--color-background);
box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent);
margin-bottom: 0.625rem;
}
.icon {
--custom-emoji-size: 5rem;
width: 5rem;
height: 5rem;
font-size: 3rem;
align-self: center;
margin: 1.5rem 0;
}
.clickable {
cursor: pointer;
}
.heading {
font-weight: 500;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}

View File

@ -33,6 +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 EditTopic from './EditTopic';
import './RightColumn.scss'; import './RightColumn.scss';
@ -80,6 +82,8 @@ const RightColumn: FC<StateProps> = ({
toggleMessageStatistics, toggleMessageStatistics,
setOpenedInviteInfo, setOpenedInviteInfo,
requestNextManagementScreen, requestNextManagementScreen,
closeCreateTopicPanel,
closeEditTopicPanel,
} = getActions(); } = getActions();
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
@ -99,6 +103,8 @@ const RightColumn: FC<StateProps> = ({
const isGifSearch = contentKey === RightColumnContent.GifSearch; const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults; const isPollResults = contentKey === RightColumnContent.PollResults;
const isAddingChatMembers = contentKey === RightColumnContent.AddingMembers; const isAddingChatMembers = contentKey === RightColumnContent.AddingMembers;
const isCreatingTopic = contentKey === RightColumnContent.CreateTopic;
const isEditingTopic = contentKey === RightColumnContent.EditTopic;
const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN; const isOverlaying = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN;
const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen); const [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen);
@ -181,11 +187,18 @@ const RightColumn: FC<StateProps> = ({
case RightColumnContent.PollResults: case RightColumnContent.PollResults:
closePollResults(); closePollResults();
break; break;
case RightColumnContent.CreateTopic:
closeCreateTopicPanel();
break;
case RightColumnContent.EditTopic:
closeEditTopicPanel();
break;
} }
}, [ }, [
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState, contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, toggleMessageStatistics, setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, toggleMessageStatistics,
closeCreateTopicPanel, closeEditTopicPanel,
]); ]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => { const handleSelectChatMember = useCallback((memberId, isPromoted) => {
@ -232,11 +245,12 @@ const RightColumn: FC<StateProps> = ({
isActive: isChatSelected && ( isActive: isChatSelected && (
contentKey === RightColumnContent.ChatInfo contentKey === RightColumnContent.ChatInfo
|| contentKey === RightColumnContent.Management || contentKey === RightColumnContent.Management
|| contentKey === RightColumnContent.AddingMembers), || contentKey === RightColumnContent.AddingMembers
|| contentKey === RightColumnContent.CreateTopic
|| contentKey === RightColumnContent.EditTopic),
onBack: () => close(false), onBack: () => close(false),
}); });
// eslint-disable-next-line consistent-return
function renderContent(isActive: boolean) { function renderContent(isActive: boolean) {
if (renderingContentKey === -1) { if (renderingContentKey === -1) {
return undefined; return undefined;
@ -290,7 +304,13 @@ const RightColumn: FC<StateProps> = ({
return <GifSearch onClose={close} isActive={isOpen && isActive} />; return <GifSearch onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.PollResults: case RightColumnContent.PollResults:
return <PollResults onClose={close} isActive={isOpen && isActive} />; return <PollResults onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.CreateTopic:
return <CreateTopic onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.EditTopic:
return <EditTopic onClose={close} isActive={isOpen && isActive} />;
} }
return undefined; // Unreachable
} }
return ( return (
@ -314,6 +334,8 @@ const RightColumn: FC<StateProps> = ({
isStickerSearch={isStickerSearch} isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch} isGifSearch={isGifSearch}
isPollResults={isPollResults} isPollResults={isPollResults}
isCreatingTopic={isCreatingTopic}
isEditingTopic={isEditingTopic}
isAddingChatMembers={isAddingChatMembers} isAddingChatMembers={isAddingChatMembers}
profileState={profileState} profileState={profileState}
managementScreen={managementScreen} managementScreen={managementScreen}

View File

@ -21,7 +21,7 @@ import {
selectUser, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import { import {
getCanAddContact, isChatAdmin, isChatChannel, isUserBot, isUserId, getCanAddContact, getCanManageTopic, isChatAdmin, isChatChannel, isUserBot, isUserId,
} from '../../global/helpers'; } from '../../global/helpers';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -47,6 +47,8 @@ type OwnProps = {
isStickerSearch?: boolean; isStickerSearch?: boolean;
isGifSearch?: boolean; isGifSearch?: boolean;
isPollResults?: boolean; isPollResults?: boolean;
isCreatingTopic?: boolean;
isEditingTopic?: boolean;
isAddingChatMembers?: boolean; isAddingChatMembers?: boolean;
profileState?: ProfileState; profileState?: ProfileState;
managementScreen?: ManagementScreens; managementScreen?: ManagementScreens;
@ -68,6 +70,7 @@ type StateProps = {
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
isBot?: boolean; isBot?: boolean;
isInsideTopic?: boolean; isInsideTopic?: boolean;
canEditTopic?: boolean;
}; };
const COLUMN_ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; const COLUMN_ANIMATION_DURATION = 450 + ANIMATION_END_DELAY;
@ -105,10 +108,13 @@ enum HeaderContent {
ManageReactions, ManageReactions,
ManageInviteInfo, ManageInviteInfo,
ManageJoinRequests, ManageJoinRequests,
CreateTopic,
EditTopic,
} }
const RightHeader: FC<OwnProps & StateProps> = ({ const RightHeader: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId,
isColumnOpen, isColumnOpen,
isProfile, isProfile,
isSearch, isSearch,
@ -118,6 +124,8 @@ const RightHeader: FC<OwnProps & StateProps> = ({
isStickerSearch, isStickerSearch,
isGifSearch, isGifSearch,
isPollResults, isPollResults,
isCreatingTopic,
isEditingTopic,
isAddingChatMembers, isAddingChatMembers,
profileState, profileState,
managementScreen, managementScreen,
@ -136,6 +144,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
isBot, isBot,
isInsideTopic, isInsideTopic,
canEditTopic,
}) => { }) => {
const { const {
setLocalTextSearchQuery, setLocalTextSearchQuery,
@ -148,6 +157,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
toggleStatistics, toggleStatistics,
setEditingExportedInvite, setEditingExportedInvite,
deleteExportedChatInvite, deleteExportedChatInvite,
openEditTopicPanel,
} = getActions(); } = getActions();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
@ -183,6 +193,11 @@ const RightHeader: FC<OwnProps & StateProps> = ({
openAddContactDialog({ userId }); openAddContactDialog({ userId });
}, [openAddContactDialog, userId]); }, [openAddContactDialog, userId]);
const toggleEditTopic = useCallback(() => {
if (!chatId || !threadId) return;
openEditTopicPanel({ chatId, topicId: threadId });
}, [chatId, openEditTopicPanel, threadId]);
const [shouldSkipTransition, setShouldSkipTransition] = useState(!isColumnOpen); const [shouldSkipTransition, setShouldSkipTransition] = useState(!isColumnOpen);
useEffect(() => { useEffect(() => {
@ -256,6 +271,10 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.Statistics HeaderContent.Statistics
) : isMessageStatistics ? ( ) : isMessageStatistics ? (
HeaderContent.MessageStatistics HeaderContent.MessageStatistics
) : isCreatingTopic ? (
HeaderContent.CreateTopic
) : isEditingTopic ? (
HeaderContent.EditTopic
) : undefined; // When column is closed ) : undefined; // When column is closed
const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1; const renderingContentKey = useCurrentOrPrev(contentKey, true) ?? -1;
@ -410,6 +429,10 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return <h3>{lang('GroupMembers')}</h3>; return <h3>{lang('GroupMembers')}</h3>;
case HeaderContent.ManageReactions: case HeaderContent.ManageReactions:
return <h3>{lang('Reactions')}</h3>; return <h3>{lang('Reactions')}</h3>;
case HeaderContent.CreateTopic:
return <h3>{lang('NewTopic')}</h3>;
case HeaderContent.EditTopic:
return <h3>{lang('EditTopic')}</h3>;
default: default:
return ( return (
<> <>
@ -438,6 +461,17 @@ const RightHeader: FC<OwnProps & StateProps> = ({
<i className="icon-edit" /> <i className="icon-edit" />
</Button> </Button>
)} )}
{canEditTopic && (
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('EditTopic')}
onClick={toggleEditTopic}
>
<i className="icon-edit" />
</Button>
)}
{canViewStatistics && ( {canViewStatistics && (
<Button <Button
round round
@ -503,6 +537,8 @@ export default memo(withGlobal<OwnProps>(
const user = isProfile && chatId && isUserId(chatId) ? selectUser(global, chatId) : undefined; const user = isProfile && chatId && isUserId(chatId) ? selectUser(global, chatId) : undefined;
const isChannel = chat && isChatChannel(chat); const isChannel = chat && isChatChannel(chat);
const isInsideTopic = chat?.isForum && Boolean(threadId && threadId !== MAIN_THREAD_ID); const isInsideTopic = chat?.isForum && Boolean(threadId && threadId !== MAIN_THREAD_ID);
const topic = isInsideTopic ? chat.topics?.[threadId!] : undefined;
const canEditTopic = isInsideTopic && topic && getCanManageTopic(chat, topic);
const isBot = user && isUserBot(user); const isBot = user && isUserBot(user);
const canAddContact = user && getCanAddContact(user); const canAddContact = user && getCanAddContact(user);
@ -526,6 +562,7 @@ export default memo(withGlobal<OwnProps>(
isChannel, isChannel,
isBot, isBot,
isInsideTopic, isInsideTopic,
canEditTopic,
userId: user?.id, userId: user?.id,
messageSearchQuery, messageSearchQuery,
stickerSearchQuery, stickerSearchQuery,

View File

@ -16,6 +16,7 @@ import {
getHasAdminRight, getHasAdminRight,
isChatBasicGroup, isChatBasicGroup,
isChatPublic, isChatPublic,
isChatSuperGroup,
} from '../../../global/helpers'; } from '../../../global/helpers';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -33,6 +34,7 @@ import Spinner from '../../ui/Spinner';
import FloatingActionButton from '../../ui/FloatingActionButton'; import FloatingActionButton from '../../ui/FloatingActionButton';
import ConfirmDialog from '../../ui/ConfirmDialog'; import ConfirmDialog from '../../ui/ConfirmDialog';
import TextArea from '../../ui/TextArea'; import TextArea from '../../ui/TextArea';
import Switcher from '../../ui/Switcher';
import './Management.scss'; import './Management.scss';
@ -51,6 +53,7 @@ type StateProps = {
canChangeInfo?: boolean; canChangeInfo?: boolean;
canBanUsers?: boolean; canBanUsers?: boolean;
canInvite?: boolean; canInvite?: boolean;
canEditForum?: boolean;
exportedInvites?: ApiExportedInvite[]; exportedInvites?: ApiExportedInvite[];
lastSyncTime?: number; lastSyncTime?: number;
isChannelsPremiumLimitReached: boolean; isChannelsPremiumLimitReached: boolean;
@ -73,6 +76,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
canChangeInfo, canChangeInfo,
canBanUsers, canBanUsers,
canInvite, canInvite,
canEditForum,
isActive, isActive,
exportedInvites, exportedInvites,
lastSyncTime, lastSyncTime,
@ -91,6 +95,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
openChat, openChat,
loadExportedChatInvites, loadExportedChatInvites,
loadChatJoinRequests, loadChatJoinRequests,
toggleForum,
} = getActions(); } = getActions();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
@ -203,6 +208,10 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
togglePreHistoryHidden({ chatId: chat.id, isEnabled: !isPreHistoryHidden }); togglePreHistoryHidden({ chatId: chat.id, isEnabled: !isPreHistoryHidden });
}, [chat, togglePreHistoryHidden]); }, [chat, togglePreHistoryHidden]);
const handleForumToggle = useCallback(() => {
toggleForum({ chatId, isEnabled: !chat.isForum });
}, [chat.isForum, chatId, toggleForum]);
useEffect(() => { useEffect(() => {
if (!isChannelsPremiumLimitReached) { if (!isChannelsPremiumLimitReached) {
return; return;
@ -384,6 +393,20 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
</span> </span>
</ListItem> </ListItem>
)} )}
{canEditForum && (
<>
<ListItem icon="forums" ripple onClick={handleForumToggle}>
<span>{lang('ChannelTopics')}</span>
<Switcher
id="group-notifications"
label={lang('ChannelTopics')}
checked={chat.isForum}
inactive
/>
</ListItem>
<div className="section-info section-info_push">{lang('ForumToggleDescription')}</div>
</>
)}
</div> </div>
<div className="section"> <div className="section">
<ListItem icon="group" multiline onClick={handleClickMembers}> <ListItem icon="group" multiline onClick={handleClickMembers}>
@ -444,6 +467,7 @@ export default memo(withGlobal<OwnProps>(
const hasLinkedChannel = Boolean(chat.fullInfo?.linkedChatId); const hasLinkedChannel = Boolean(chat.fullInfo?.linkedChatId);
const isBasicGroup = isChatBasicGroup(chat); const isBasicGroup = isChatBasicGroup(chat);
const { invites } = global.management.byChatId[chatId] || {}; const { invites } = global.management.byChatId[chatId] || {};
const canEditForum = !hasLinkedChannel && isChatSuperGroup(chat) && getHasAdminRight(chat, 'changeInfo');
return { return {
chat, chat,
@ -457,6 +481,7 @@ export default memo(withGlobal<OwnProps>(
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
isChannelsPremiumLimitReached: global.limitReachedModal?.limit === 'channels', isChannelsPremiumLimitReached: global.limitReachedModal?.limit === 'channels',
availableReactions: global.availableReactions, availableReactions: global.availableReactions,
canEditForum,
}; };
}, },
)(ManageGroup)); )(ManageGroup));

View File

@ -1405,6 +1405,25 @@ addActionHandler('toggleForum', async (global, actions, payload) => {
} }
}); });
addActionHandler('createTopic', async (global, actions, payload) => {
const { chatId, title, iconColor } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
setGlobal({
...global,
createTopicPanel: {
chatId,
isLoading: true,
},
});
const topicId = await callApi('createTopic', { chat, title, iconColor });
if (topicId) {
actions.openChat({ id: chatId, threadId: topicId, shouldReplaceHistory: true });
}
});
addActionHandler('deleteTopic', async (global, actions, payload) => { addActionHandler('deleteTopic', async (global, actions, payload) => {
const { chatId, topicId } = payload; const { chatId, topicId } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);

View File

@ -440,6 +440,43 @@ addActionHandler('updateLastRenderedCustomEmojis', (global, actions, payload) =>
}; };
}); });
addActionHandler('openCreateTopicPanel', (global, actions, payload) => {
const { chatId } = payload;
return {
...global,
createTopicPanel: {
chatId,
},
};
});
addActionHandler('closeCreateTopicPanel', (global) => {
return {
...global,
createTopicPanel: undefined,
};
});
addActionHandler('openEditTopicPanel', (global, actions, payload) => {
const { chatId, topicId } = payload;
return {
...global,
editTopicPanel: {
chatId,
topicId,
},
};
});
addActionHandler('closeEditTopicPanel', (global) => {
return {
...global,
editTopicPanel: undefined,
};
});
addActionHandler('checkAppVersion', () => { addActionHandler('checkAppVersion', () => {
const APP_VERSION_REGEX = /^\d+\.\d+(\.\d+)?$/; const APP_VERSION_REGEX = /^\d+\.\d+(\.\d+)?$/;

View File

@ -12,7 +12,9 @@ import {
import type { NotifyException, NotifySettings } from '../../types'; import type { NotifyException, NotifySettings } from '../../types';
import type { LangFn } from '../../hooks/useLang'; import type { LangFn } from '../../hooks/useLang';
import { ARCHIVED_FOLDER_ID, REPLIES_USER_ID, TME_LINK_PREFIX } from '../../config'; import {
ARCHIVED_FOLDER_ID, GENERAL_TOPIC_ID, REPLIES_USER_ID, TME_LINK_PREFIX,
} from '../../config';
import { orderBy } from '../../util/iteratees'; import { orderBy } from '../../util/iteratees';
import { getUserFirstOrLastName } from './users'; import { getUserFirstOrLastName } from './users';
import { formatDateToString, formatTime } from '../../util/dateFormat'; import { formatDateToString, formatTime } from '../../util/dateFormat';
@ -155,6 +157,11 @@ export function getHasAdminRight(chat: ApiChat, key: keyof ApiChatAdminRights) {
return chat.adminRights ? chat.adminRights[key] : false; return chat.adminRights ? chat.adminRights[key] : false;
} }
export function getCanManageTopic(chat: ApiChat, topic: ApiTopic) {
if (topic.id === GENERAL_TOPIC_ID) return chat.isCreator;
return chat.isCreator || getHasAdminRight(chat, 'manageTopics') || topic.isOwner;
}
export function isUserRightBanned(chat: ApiChat, key: keyof ApiChatBannedRights) { export function isUserRightBanned(chat: ApiChat, key: keyof ApiChatBannedRights) {
return Boolean( return Boolean(
(chat.currentUserBannedRights?.[key]) (chat.currentUserBannedRights?.[key])

View File

@ -409,7 +409,13 @@ export function selectCanDeleteTopic(global: GlobalState, chatId: string, topicI
export function selectThreadIdFromMessage(global: GlobalState, message: ApiMessage): number { export function selectThreadIdFromMessage(global: GlobalState, message: ApiMessage): number {
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
const { replyToMessageId, replyToTopMessageId, isTopicReply } = message; const {
replyToMessageId, replyToTopMessageId, isTopicReply, content,
} = message;
if ('action' in content && content.action?.type === 'topicCreate') {
return message.id;
}
// TODO ignore only basic group if reply threads are added // TODO ignore only basic group if reply threads are added
if (!chat?.isForum) return MAIN_THREAD_ID; if (!chat?.isForum) return MAIN_THREAD_ID;
if (!isTopicReply) return GENERAL_TOPIC_ID; if (!isTopicReply) return GENERAL_TOPIC_ID;
@ -729,6 +735,16 @@ export function selectIsPollResultsOpen(global: GlobalState) {
return Boolean(pollResults.messageId); return Boolean(pollResults.messageId);
} }
export function selectIsCreateTopicPanelOpen(global: GlobalState) {
const { createTopicPanel } = global;
return Boolean(createTopicPanel);
}
export function selectIsEditTopicPanelOpen(global: GlobalState) {
const { editTopicPanel } = global;
return Boolean(editTopicPanel);
}
export function selectIsForwardModalOpen(global: GlobalState) { export function selectIsForwardModalOpen(global: GlobalState) {
const { forwardMessages } = global; const { forwardMessages } = global;
return Boolean(forwardMessages.isModalShown); return Boolean(forwardMessages.isModalShown);

View File

@ -2,7 +2,9 @@ import type { GlobalState } from '../types';
import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { NewChatMembersProgress, RightColumnContent } from '../../types';
import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { getSystemTheme, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { selectCurrentMessageList, selectIsPollResultsOpen } from './messages'; import {
selectCurrentMessageList, selectIsCreateTopicPanelOpen, selectIsEditTopicPanelOpen, selectIsPollResultsOpen,
} from './messages';
import { selectCurrentTextSearch } from './localSearch'; import { selectCurrentTextSearch } from './localSearch';
import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols'; import { selectCurrentStickerSearch, selectCurrentGifSearch } from './symbols';
import { selectIsStatisticsShown, selectIsMessageStatisticsShown } from './statistics'; import { selectIsStatisticsShown, selectIsMessageStatisticsShown } from './statistics';
@ -14,7 +16,11 @@ export function selectIsMediaViewerOpen(global: GlobalState) {
} }
export function selectRightColumnContentKey(global: GlobalState) { export function selectRightColumnContentKey(global: GlobalState) {
return selectIsPollResultsOpen(global) ? ( return selectIsEditTopicPanelOpen(global) ? (
RightColumnContent.EditTopic
) : selectIsCreateTopicPanelOpen(global) ? (
RightColumnContent.CreateTopic
) : selectIsPollResultsOpen(global) ? (
RightColumnContent.PollResults RightColumnContent.PollResults
) : !IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global) ? ( ) : !IS_SINGLE_COLUMN_LAYOUT && selectCurrentTextSearch(global) ? (
RightColumnContent.Search RightColumnContent.Search

View File

@ -707,6 +707,17 @@ export type GlobalState = {
}; };
deleteFolderDialogModal?: number; deleteFolderDialogModal?: number;
createTopicPanel?: {
chatId: string;
isLoading?: boolean;
};
editTopicPanel?: {
chatId: string;
topicId: number;
isLoading?: boolean;
};
}; };
export type CallSound = ( export type CallSound = (
@ -1320,6 +1331,11 @@ export interface ActionPayloads {
chatId: string; chatId: string;
isEnabled: boolean; isEnabled: boolean;
}; };
createTopic: {
chatId: string;
title: string;
iconColor?: number;
};
loadTopics: { loadTopics: {
chatId: string; chatId: string;
force?: boolean; force?: boolean;
@ -1359,6 +1375,17 @@ export interface ActionPayloads {
topicId: number; topicId: number;
isMuted: boolean; isMuted: boolean;
}; };
openCreateTopicPanel: {
chatId: string;
};
closeCreateTopicPanel: never;
openEditTopicPanel: {
chatId: string;
topicId: number;
};
closeEditTopicPanel: never;
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (

View File

@ -253,6 +253,8 @@ export enum RightColumnContent {
GifSearch, GifSearch,
PollResults, PollResults,
AddingMembers, AddingMembers,
CreateTopic,
EditTopic,
} }
export enum MediaViewerOrigin { export enum MediaViewerOrigin {

View File

@ -8,7 +8,7 @@ import yellow from '../assets/icons/forumTopic/yellow.svg';
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
// https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L50 // https://github.com/telegramdesktop/tdesktop/blob/1aece79a471d99a8b63d826b1bce1f36a04d7293/Telegram/SourceFiles/data/data_forum_topic.cpp#L50
const TOPIC_MAPPING = { const TOPIC_MAPPING: Record<number, [string, string]> = {
0x6FB9F0: [blue, 'blue'], 0x6FB9F0: [blue, 'blue'],
0xFFD67E: [yellow, 'yellow'], 0xFFD67E: [yellow, 'yellow'],
0xCB86DB: [violet, 'violet'], 0xCB86DB: [violet, 'violet'],
@ -17,6 +17,10 @@ const TOPIC_MAPPING = {
0xFB6F5F: [red, 'red'], 0xFB6F5F: [red, 'red'],
}; };
export function getTopicColors() {
return Object.keys(TOPIC_MAPPING).map((key) => parseInt(key, 10));
}
export function getTopicDefaultIcon(iconColor?: number) { export function getTopicDefaultIcon(iconColor?: number) {
return (iconColor && TOPIC_MAPPING[iconColor as keyof typeof TOPIC_MAPPING][0]) || grey; return (iconColor && TOPIC_MAPPING[iconColor as keyof typeof TOPIC_MAPPING][0]) || grey;
} }