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';
translationValues.push('%action_origin%', '%action_topic%');
} else if (action.hidden !== undefined) {
text = action.hidden ? 'TopicHidden2' : 'TopicWasUnhiddenAction';
text = action.hidden ? 'TopicHidden2' : 'TopicShown';
} else if (action.title) {
text = 'TopicRenamedTo';
translationValues.push('%action_origin%', action.title);

View File

@ -51,7 +51,9 @@ import {
isMessageWithMedia,
buildChatBannedRights,
buildChatAdminRights,
buildInputChatReactions, buildInputPhoto,
buildInputChatReactions,
buildInputPhoto,
generateRandomBigInt,
} from '../gramjsBuilders';
import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
@ -1333,6 +1335,36 @@ export function toggleForum({
}), 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({
chat, query, offsetTopicId, offsetId, offsetDate, limit = TOPICS_SLICE,
}: {

View File

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

View File

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

View File

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

View File

@ -8,12 +8,13 @@ import CustomEmoji from './CustomEmoji';
import TopicDefaultIcon from './TopicDefaultIcon';
type OwnProps = {
topic: ApiTopic;
topic: Pick<ApiTopic, 'iconEmojiId' | 'iconColor' | 'title' | 'id'>;
className?: string;
letterClassName?: string;
size?: number;
noLoopLimit?: true;
observeIntersection?: ObserveFn;
onClick?: NoneToVoidFunction;
};
const LOOP_LIMIT = 2;
@ -25,6 +26,7 @@ const TopicIcon: FC<OwnProps> = ({
size,
noLoopLimit,
observeIntersection,
onClick,
}) => {
if (topic.iconEmojiId) {
return (
@ -34,6 +36,7 @@ const TopicIcon: FC<OwnProps> = ({
size={size}
observeIntersectionForPlaying={observeIntersection}
loopLimit={!noLoopLimit ? LOOP_LIMIT : undefined}
onClick={onClick}
/>
);
}
@ -45,6 +48,7 @@ const TopicIcon: FC<OwnProps> = ({
topicId={topic.id}
className={className}
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 {
GENERAL_TOPIC_ID,
TOPICS_SLICE, TOPIC_HEIGHT_PX, TOPIC_LIST_SENSITIVE_AREA,
} from '../../../config';
import { selectChat, selectCurrentMessageList } from '../../../global/selectors';
@ -32,7 +33,7 @@ import InfiniteScroll from '../../ui/InfiniteScroll';
import Loading from '../../ui/Loading';
import HeaderActions from '../../middle/HeaderActions';
import GroupCallTopPane from '../../calls/group/GroupCallTopPane';
import EmptyTopic from './EmptyTopic';
import EmptyForum from './EmptyForum';
import styles from './ForumPanel.module.scss';
@ -224,14 +225,16 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
sensitiveArea={TOPIC_LIST_SENSITIVE_AREA}
beforeChildren={<div ref={scrollTopHandlerRef} className={styles.scrollTopHandler} />}
>
{viewportIds?.length ? (
{viewportIds?.length && (
renderTopics()
) : !isLoading ? (
<EmptyTopic />
) : (
)}
{isLoading && !viewportIds?.length && (
<Loading key="loading" />
)}
</InfiniteScroll>
{!isLoading && viewportIds?.length === 1 && viewportIds[0] === GENERAL_TOPIC_ID && (
<EmptyForum chatId={chat.id} />
)}
</div>
);
};

View File

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

View File

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

View File

@ -10,6 +10,13 @@
margin: 0 auto 1rem;
}
.topic-icon {
--custom-emoji-size: 3rem;
width: 3rem;
height: 3rem;
font-size: 2rem;
}
.wrapper {
display: inline-flex;
flex-direction: column;
@ -40,6 +47,11 @@
unicode-bidi: plaintext;
}
.topic-description {
text-align: center;
padding: 0 1rem;
}
.list-checkmarks {
font-size: 0.9375rem;
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 type { FC } from '../../lib/teact/teact';
import type { MessageListType } from '../../global/types';
import type { ApiTopic } from '../../api/types';
import type { LangFn } from '../../hooks/useLang';
import { REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import TopicIcon from '../common/TopicIcon';
import './NoMessages.scss';
const ICON_SIZE = 3 * REM;
type OwnProps = {
chatId: string;
isChatWithSelf?: boolean;
type: MessageListType;
isGroupChatJustCreated?: boolean;
topic?: ApiTopic;
};
const NoMessages: FC<OwnProps> = ({
isChatWithSelf, type, isGroupChatJustCreated,
isChatWithSelf,
type,
isGroupChatJustCreated,
topic,
}) => {
const lang = useLang();
@ -32,11 +43,27 @@ const NoMessages: FC<OwnProps> = ({
return renderGroup(lang);
}
if (topic) {
return renderTopic(lang, topic);
}
return (
<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) {
return (
<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 PollResults from './PollResults.async';
import AddChatMembers from './AddChatMembers';
import CreateTopic from './CreateTopic';
import EditTopic from './EditTopic';
import './RightColumn.scss';
@ -80,6 +82,8 @@ const RightColumn: FC<StateProps> = ({
toggleMessageStatistics,
setOpenedInviteInfo,
requestNextManagementScreen,
closeCreateTopicPanel,
closeEditTopicPanel,
} = getActions();
const { width: windowWidth } = useWindowSize();
@ -99,6 +103,8 @@ const RightColumn: FC<StateProps> = ({
const isGifSearch = contentKey === RightColumnContent.GifSearch;
const isPollResults = contentKey === RightColumnContent.PollResults;
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 [shouldSkipTransition, setShouldSkipTransition] = useState(!isOpen);
@ -181,11 +187,18 @@ const RightColumn: FC<StateProps> = ({
case RightColumnContent.PollResults:
closePollResults();
break;
case RightColumnContent.CreateTopic:
closeCreateTopicPanel();
break;
case RightColumnContent.EditTopic:
closeEditTopicPanel();
break;
}
}, [
contentKey, isScrolledDown, toggleChatInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId, setOpenedInviteInfo, toggleStatistics, toggleMessageStatistics,
closeCreateTopicPanel, closeEditTopicPanel,
]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => {
@ -232,11 +245,12 @@ const RightColumn: FC<StateProps> = ({
isActive: isChatSelected && (
contentKey === RightColumnContent.ChatInfo
|| contentKey === RightColumnContent.Management
|| contentKey === RightColumnContent.AddingMembers),
|| contentKey === RightColumnContent.AddingMembers
|| contentKey === RightColumnContent.CreateTopic
|| contentKey === RightColumnContent.EditTopic),
onBack: () => close(false),
});
// eslint-disable-next-line consistent-return
function renderContent(isActive: boolean) {
if (renderingContentKey === -1) {
return undefined;
@ -290,7 +304,13 @@ const RightColumn: FC<StateProps> = ({
return <GifSearch onClose={close} isActive={isOpen && isActive} />;
case RightColumnContent.PollResults:
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 (
@ -314,6 +334,8 @@ const RightColumn: FC<StateProps> = ({
isStickerSearch={isStickerSearch}
isGifSearch={isGifSearch}
isPollResults={isPollResults}
isCreatingTopic={isCreatingTopic}
isEditingTopic={isEditingTopic}
isAddingChatMembers={isAddingChatMembers}
profileState={profileState}
managementScreen={managementScreen}

View File

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

View File

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

View File

@ -12,7 +12,9 @@ import {
import type { NotifyException, NotifySettings } from '../../types';
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 { getUserFirstOrLastName } from './users';
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;
}
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) {
return Boolean(
(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 {
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
if (!chat?.isForum) return MAIN_THREAD_ID;
if (!isTopicReply) return GENERAL_TOPIC_ID;
@ -729,6 +735,16 @@ export function selectIsPollResultsOpen(global: GlobalState) {
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) {
const { forwardMessages } = global;
return Boolean(forwardMessages.isModalShown);

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import yellow from '../assets/icons/forumTopic/yellow.svg';
// eslint-disable-next-line max-len
// 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'],
0xFFD67E: [yellow, 'yellow'],
0xCB86DB: [violet, 'violet'],
@ -17,6 +17,10 @@ const TOPIC_MAPPING = {
0xFB6F5F: [red, 'red'],
};
export function getTopicColors() {
return Object.keys(TOPIC_MAPPING).map((key) => parseInt(key, 10));
}
export function getTopicDefaultIcon(iconColor?: number) {
return (iconColor && TOPIC_MAPPING[iconColor as keyof typeof TOPIC_MAPPING][0]) || grey;
}