Management: Topics (#2245)
This commit is contained in:
parent
849bd6f13f
commit
c9ed4b16c1
@ -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);
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -54,3 +54,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
57
src/components/left/main/EmptyForum.module.scss
Normal file
57
src/components/left/main/EmptyForum.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/components/left/main/EmptyForum.tsx
Normal file
74
src/components/left/main/EmptyForum.tsx
Normal 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));
|
||||
@ -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));
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 }),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
130
src/components/right/CreateTopic.tsx
Normal file
130
src/components/right/CreateTopic.tsx
Normal 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));
|
||||
139
src/components/right/EditTopic.tsx
Normal file
139
src/components/right/EditTopic.tsx
Normal 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));
|
||||
35
src/components/right/ManageTopic.module.scss
Normal file
35
src/components/right/ManageTopic.module.scss
Normal 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);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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+)?$/;
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -253,6 +253,8 @@ export enum RightColumnContent {
|
||||
GifSearch,
|
||||
PollResults,
|
||||
AddingMembers,
|
||||
CreateTopic,
|
||||
EditTopic,
|
||||
}
|
||||
|
||||
export enum MediaViewerOrigin {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user