From c9ed4b16c12baa2babb5f0326c386d7fd52068a8 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 7 Jan 2023 00:04:34 +0100 Subject: [PATCH] Management: Topics (#2245) --- src/api/gramjs/apiBuilders/messages.ts | 2 +- src/api/gramjs/methods/chats.ts | 34 ++++- src/api/gramjs/methods/index.ts | 2 +- .../common/TopicDefaultIcon.module.scss | 6 +- src/components/common/TopicDefaultIcon.tsx | 10 +- src/components/common/TopicIcon.tsx | 6 +- .../left/main/EmptyFolder.module.scss | 4 + .../left/main/EmptyForum.module.scss | 57 +++++++ src/components/left/main/EmptyForum.tsx | 74 ++++++++++ src/components/left/main/EmptyTopic.tsx | 58 -------- src/components/left/main/ForumPanel.tsx | 13 +- src/components/middle/HeaderMenuContainer.tsx | 72 +++++++-- src/components/middle/MessageList.tsx | 14 +- src/components/middle/NoMessages.scss | 12 ++ src/components/middle/NoMessages.tsx | 33 ++++- src/components/right/CreateTopic.tsx | 130 ++++++++++++++++ src/components/right/EditTopic.tsx | 139 ++++++++++++++++++ src/components/right/ManageTopic.module.scss | 35 +++++ src/components/right/RightColumn.tsx | 26 +++- src/components/right/RightHeader.tsx | 39 ++++- .../right/management/ManageGroup.tsx | 25 ++++ src/global/actions/api/chats.ts | 19 +++ src/global/actions/ui/misc.ts | 37 +++++ src/global/helpers/chats.ts | 9 +- src/global/selectors/messages.ts | 18 ++- src/global/selectors/ui.ts | 10 +- src/global/types.ts | 27 ++++ src/types/index.ts | 2 + src/util/forumColors.ts | 6 +- 29 files changed, 823 insertions(+), 96 deletions(-) create mode 100644 src/components/left/main/EmptyForum.module.scss create mode 100644 src/components/left/main/EmptyForum.tsx delete mode 100644 src/components/left/main/EmptyTopic.tsx create mode 100644 src/components/right/CreateTopic.tsx create mode 100644 src/components/right/EditTopic.tsx create mode 100644 src/components/right/ManageTopic.module.scss diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 21565d6ca..1103120a5 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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); diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index d3a48f0f4..b50532d1c 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -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, }: { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 6f5590e74..585d1e683 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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 { diff --git a/src/components/common/TopicDefaultIcon.module.scss b/src/components/common/TopicDefaultIcon.module.scss index f35a53e42..9893851c1 100644 --- a/src/components/common/TopicDefaultIcon.module.scss +++ b/src/components/common/TopicDefaultIcon.module.scss @@ -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; } } diff --git a/src/components/common/TopicDefaultIcon.tsx b/src/components/common/TopicDefaultIcon.tsx index 02bc43246..42a5d4508 100644 --- a/src/components/common/TopicDefaultIcon.tsx +++ b/src/components/common/TopicDefaultIcon.tsx @@ -16,6 +16,7 @@ type OwnProps = { topicId: number; iconColor?: number; title: string; + onClick?: NoneToVoidFunction; }; const TopicDefaultIcon: FC = ({ @@ -24,15 +25,18 @@ const TopicDefaultIcon: FC = ({ topicId, iconColor, title, + onClick, }) => { const iconSrc = getTopicDefaultIcon(iconColor); if (topicId === GENERAL_TOPIC_ID) { - return ; + return ( + + ); } return ( -
- +
+
{renderText(getFirstLetters(title, 1))}
diff --git a/src/components/common/TopicIcon.tsx b/src/components/common/TopicIcon.tsx index 36cb96b14..4c967512d 100644 --- a/src/components/common/TopicIcon.tsx +++ b/src/components/common/TopicIcon.tsx @@ -8,12 +8,13 @@ import CustomEmoji from './CustomEmoji'; import TopicDefaultIcon from './TopicDefaultIcon'; type OwnProps = { - topic: ApiTopic; + topic: Pick; className?: string; letterClassName?: string; size?: number; noLoopLimit?: true; observeIntersection?: ObserveFn; + onClick?: NoneToVoidFunction; }; const LOOP_LIMIT = 2; @@ -25,6 +26,7 @@ const TopicIcon: FC = ({ size, noLoopLimit, observeIntersection, + onClick, }) => { if (topic.iconEmojiId) { return ( @@ -34,6 +36,7 @@ const TopicIcon: FC = ({ size={size} observeIntersectionForPlaying={observeIntersection} loopLimit={!noLoopLimit ? LOOP_LIMIT : undefined} + onClick={onClick} /> ); } @@ -45,6 +48,7 @@ const TopicIcon: FC = ({ topicId={topic.id} className={className} letterClassName={letterClassName} + onClick={onClick} /> ); }; diff --git a/src/components/left/main/EmptyFolder.module.scss b/src/components/left/main/EmptyFolder.module.scss index adbf02b60..f15821377 100644 --- a/src/components/left/main/EmptyFolder.module.scss +++ b/src/components/left/main/EmptyFolder.module.scss @@ -54,3 +54,7 @@ } } } + +.centered { + text-align: center; +} diff --git a/src/components/left/main/EmptyForum.module.scss b/src/components/left/main/EmptyForum.module.scss new file mode 100644 index 000000000..c264f801c --- /dev/null +++ b/src/components/left/main/EmptyForum.module.scss @@ -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); + } +} diff --git a/src/components/left/main/EmptyForum.tsx b/src/components/left/main/EmptyForum.tsx new file mode 100644 index 000000000..bd155ca91 --- /dev/null +++ b/src/components/left/main/EmptyForum.tsx @@ -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 = ({ + chatId, animatedEmoji, canManageTopics, +}) => { + const { openCreateTopicPanel } = getActions(); + const lang = useLang(); + + const handleCreateTopic = useCallback(() => { + openCreateTopicPanel({ chatId }); + }, [chatId, openCreateTopicPanel]); + + return ( +
+
+ {animatedEmoji && } +
+

{lang('ChatList.EmptyTopicsTitle')}

+

+ {lang('ChatList.EmptyTopicsDescription')} +

+ {canManageTopics && ( + + )} +
+ ); +}; + +export default memo(withGlobal((global, { chatId }): StateProps => { + const chat = selectChat(global, chatId); + const canManageTopics = chat && (chat.isCreator || getHasAdminRight(chat, 'manageTopics')); + + return { + animatedEmoji: selectAnimatedEmoji(global, '🐣'), + canManageTopics, + }; +})(EmptyForum)); diff --git a/src/components/left/main/EmptyTopic.tsx b/src/components/left/main/EmptyTopic.tsx deleted file mode 100644 index f09924432..000000000 --- a/src/components/left/main/EmptyTopic.tsx +++ /dev/null @@ -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 = ({ - animatedEmoji, -}) => { - const lang = useLang(); - - const handleCreateTopic = useCallback(() => { - }, []); - - return ( -
-
- {animatedEmoji && } -
-

{lang('ChatList.EmptyTopicsTitle')}

- -
- ); -}; - -export default memo(withGlobal((global): StateProps => { - return { - animatedEmoji: selectAnimatedEmoji(global, '👀'), - }; -})(EmptyTopic)); diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 52427e24b..84b68bee1 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -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 = ({ sensitiveArea={TOPIC_LIST_SENSITIVE_AREA} beforeChildren={
} > - {viewportIds?.length ? ( + {viewportIds?.length && ( renderTopics() - ) : !isLoading ? ( - - ) : ( + )} + {isLoading && !viewportIds?.length && ( )} + {!isLoading && viewportIds?.length === 1 && viewportIds[0] === GENERAL_TOPIC_ID && ( + + )}
); }; diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 996386f37..e99cb5e63 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -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 = ({ canGiftPremium, hasLinkedChat, canAddContact, + canCreateTopic, + canEditTopic, onJoinRequestsClick, onSubscribeChannel, onSearchClick, @@ -143,6 +155,8 @@ const HeaderMenuContainer: FC = ({ toggleStatistics, openGiftPremiumModal, openChatWithInfo, + openCreateTopicPanel, + openEditTopicPanel, } = getActions(); const [isMenuOpen, setIsMenuOpen] = useState(true); @@ -198,6 +212,16 @@ const HeaderMenuContainer: FC = ({ 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 = ({ style={`left: ${x}px;top: ${y}px;`} onClose={closeMenu} > + {withForumActions && canCreateTopic && ( + <> + + {lang('lng_forum_create_topic')} + + + + )} {isViewGroupInfoShown && ( = ({ {isTopic ? lang('lng_context_view_topic') : lang('lng_context_view_group')} )} + {canEditTopic && ( + + {lang('lng_forum_topic_edit')} + + )} {withForumActions && Boolean(pendingJoinRequests) && ( = ({ )} {canLeave && ( - - {lang(isPrivate - ? 'DeleteChatUser' - : (canDeleteChat ? 'GroupInfo.DeleteAndExit' : (isChannel ? 'LeaveChannel' : 'Group.LeaveGroup')))} - + <> + + + {lang(isPrivate + ? 'DeleteChatUser' + : (canDeleteChat ? 'GroupInfo.DeleteAndExit' : (isChannel ? 'LeaveChannel' : 'Group.LeaveGroup')))} + + )} {chat && ( @@ -491,6 +537,10 @@ export default memo(withGlobal( && !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( hasLinkedChat: Boolean(chat?.fullInfo?.linkedChatId), botCommands: chatBot?.fullInfo?.botInfo?.commands, isChatInfoShown: global.isChatInfoShown && currentChatId === chatId && currentThreadId === threadId, + canCreateTopic, + canEditTopic, }; }, )(HeaderMenuContainer)); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 474dbe8ee..228341fb3 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -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 = ({ lastSyncTime, withBottomShift, withDefaultBg, + topic, }) => { const { loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, @@ -505,6 +509,8 @@ const MessageList: FC = ({ 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 = ({
) : shouldRenderGreeting ? ( - ) : messageIds && (!messageGroups || isGroupChatJustCreated) ? ( + ) : messageIds && (!messageGroups || isGroupChatJustCreated || isEmptyTopic) ? ( ( } } + const topic = chat.topics?.[threadId]; + return { isCurrentUserPremium: selectIsCurrentUserPremium(global), isChatLoaded: true, @@ -681,6 +690,7 @@ export default memo(withGlobal( ? Boolean(chat.fullInfo.linkedChatId) : undefined, lastSyncTime: global.lastSyncTime, + topic, ...(withLastMessageWhenPreloading && { lastMessage }), }; }, diff --git a/src/components/middle/NoMessages.scss b/src/components/middle/NoMessages.scss index 7c02c154d..3eba8c145 100644 --- a/src/components/middle/NoMessages.scss +++ b/src/components/middle/NoMessages.scss @@ -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; diff --git a/src/components/middle/NoMessages.tsx b/src/components/middle/NoMessages.tsx index 00ae868c2..525d9e240 100644 --- a/src/components/middle/NoMessages.tsx +++ b/src/components/middle/NoMessages.tsx @@ -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 = ({ - isChatWithSelf, type, isGroupChatJustCreated, + isChatWithSelf, + type, + isGroupChatJustCreated, + topic, }) => { const lang = useLang(); @@ -32,11 +43,27 @@ const NoMessages: FC = ({ return renderGroup(lang); } + if (topic) { + return renderTopic(lang, topic); + } + return (
{lang('NoMessages')}
); }; +function renderTopic(lang: LangFn, topic: ApiTopic) { + return ( +
+
+ +

{lang('Chat.EmptyTopicPlaceholder.Title')}

+

{renderText(lang('Chat.EmptyTopicPlaceholder.Text'), ['br'])}

+
+
+ ); +} + function renderScheduled(lang: LangFn) { return (
{lang('ScheduledMessages.EmptyPlaceholder')}
diff --git a/src/components/right/CreateTopic.tsx b/src/components/right/CreateTopic.tsx new file mode 100644 index 000000000..190f2152a --- /dev/null +++ b/src/components/right/CreateTopic.tsx @@ -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 = ({ + 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) => { + 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 ( +
+
+
+ {lang('CreateTopicTitle')} + + +
+
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { createTopicPanel } = global; + return { + chat: createTopicPanel?.chatId ? selectChat(global, createTopicPanel.chatId) : undefined, + createTopicPanel, + }; + }, +)(CreateTopic)); diff --git a/src/components/right/EditTopic.tsx b/src/components/right/EditTopic.tsx new file mode 100644 index 000000000..fe4b065e8 --- /dev/null +++ b/src/components/right/EditTopic.tsx @@ -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 = ({ + 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) => { + 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 ( +
+
+ {!topic && } + {topic && ( +
+ {lang('CreateTopicTitle')} + + +
+ )} +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +}; + +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)); diff --git a/src/components/right/ManageTopic.module.scss b/src/components/right/ManageTopic.module.scss new file mode 100644 index 000000000..93d603fdf --- /dev/null +++ b/src/components/right/ManageTopic.module.scss @@ -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); +} diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index 89939836e..9e2d62d88 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -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 = ({ toggleMessageStatistics, setOpenedInviteInfo, requestNextManagementScreen, + closeCreateTopicPanel, + closeEditTopicPanel, } = getActions(); const { width: windowWidth } = useWindowSize(); @@ -99,6 +103,8 @@ const RightColumn: FC = ({ 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 = ({ 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 = ({ 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 = ({ return ; case RightColumnContent.PollResults: return ; + case RightColumnContent.CreateTopic: + return ; + case RightColumnContent.EditTopic: + return ; } + + return undefined; // Unreachable } return ( @@ -314,6 +334,8 @@ const RightColumn: FC = ({ isStickerSearch={isStickerSearch} isGifSearch={isGifSearch} isPollResults={isPollResults} + isCreatingTopic={isCreatingTopic} + isEditingTopic={isEditingTopic} isAddingChatMembers={isAddingChatMembers} profileState={profileState} managementScreen={managementScreen} diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index 82088ed45..6ebdddd3e 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -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 = ({ chatId, + threadId, isColumnOpen, isProfile, isSearch, @@ -118,6 +124,8 @@ const RightHeader: FC = ({ isStickerSearch, isGifSearch, isPollResults, + isCreatingTopic, + isEditingTopic, isAddingChatMembers, profileState, managementScreen, @@ -136,6 +144,7 @@ const RightHeader: FC = ({ shouldSkipHistoryAnimations, isBot, isInsideTopic, + canEditTopic, }) => { const { setLocalTextSearchQuery, @@ -148,6 +157,7 @@ const RightHeader: FC = ({ toggleStatistics, setEditingExportedInvite, deleteExportedChatInvite, + openEditTopicPanel, } = getActions(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); @@ -183,6 +193,11 @@ const RightHeader: FC = ({ 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 = ({ 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 = ({ return

{lang('GroupMembers')}

; case HeaderContent.ManageReactions: return

{lang('Reactions')}

; + case HeaderContent.CreateTopic: + return

{lang('NewTopic')}

; + case HeaderContent.EditTopic: + return

{lang('EditTopic')}

; default: return ( <> @@ -438,6 +461,17 @@ const RightHeader: FC = ({ )} + {canEditTopic && ( + + )} {canViewStatistics && (
@@ -444,6 +467,7 @@ export default memo(withGlobal( 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( lastSyncTime: global.lastSyncTime, isChannelsPremiumLimitReached: global.limitReachedModal?.limit === 'channels', availableReactions: global.availableReactions, + canEditForum, }; }, )(ManageGroup)); diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index cdadb0321..bc4c94a44 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -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); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index f69ab595a..5decea4d4 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -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+)?$/; diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index a8ada3d4b..ccc6ea661 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -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]) diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 46f717adb..d0d4a244e 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -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); diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 58cd32fe9..f7823795d 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -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 diff --git a/src/global/types.ts b/src/global/types.ts index 84574d47c..bdbadf005 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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 = ( diff --git a/src/types/index.ts b/src/types/index.ts index 790f073a5..9d45569d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -253,6 +253,8 @@ export enum RightColumnContent { GifSearch, PollResults, AddingMembers, + CreateTopic, + EditTopic, } export enum MediaViewerOrigin { diff --git a/src/util/forumColors.ts b/src/util/forumColors.ts index 05044ec21..b14150d8d 100644 --- a/src/util/forumColors.ts +++ b/src/util/forumColors.ts @@ -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 = { 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; }