diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 5eafc874e..1837800b9 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -98,6 +98,7 @@ export interface GramJsAppConfig extends LimitsConfig { stars_stargift_resale_amount_max?: number; stars_stargift_resale_amount_min?: number; stars_stargift_resale_commission_permille?: number; + poll_answers_max?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -198,5 +199,6 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp starsStargiftResaleAmountMin: appConfig.stars_stargift_resale_amount_min, starsStargiftResaleAmountMax: appConfig.stars_stargift_resale_amount_max, starsStargiftResaleCommissionPermille: appConfig.stars_stargift_resale_commission_permille, + pollMaxAnswers: appConfig.poll_answers_max, }; } diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index d466f1ced..fe47ca4a0 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -110,6 +110,9 @@ function buildApiChatFieldsFromPeerEntity( isJoinToSend: channel?.joinToSend, isJoinRequest: channel?.joinRequest, isForum: channel?.forum, + isMonoforum: channel?.monoforum, + linkedMonoforumId: channel?.linkedMonoforumId && buildApiPeerId(channel.linkedMonoforumId, 'channel'), + areChannelMessagesAllowed: channel?.broadcastMessagesAllowed, areStoriesHidden, maxStoryId, hasStories: Boolean(maxStoryId) && !storiesUnavailable, diff --git a/src/api/gramjs/apiBuilders/messageActions.ts b/src/api/gramjs/apiBuilders/messageActions.ts index d77ca5774..d3b7a10f5 100644 --- a/src/api/gramjs/apiBuilders/messageActions.ts +++ b/src/api/gramjs/apiBuilders/messageActions.ts @@ -426,11 +426,12 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess } if (action instanceof GramJs.MessageActionPaidMessagesPrice) { const { - stars, + stars, broadcastMessagesAllowed, } = action; return { mediaType: 'action', type: 'paidMessagesPrice', + isAllowedInChannel: broadcastMessagesAllowed, stars: stars.toJSNumber(), }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 40b8baca1..38362f6bb 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -287,6 +287,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un replyToMsgId: replyTo.replyToMsgId, replyToTopId: replyTo.topMsgId, replyToPeerId: replyTo.replyToPeerId && getApiChatIdFromMtpPeer(replyTo.replyToPeerId), + monoforumPeerId: replyTo.monoforumPeerId && getApiChatIdFromMtpPeer(replyTo.monoforumPeerId), quoteText: replyTo.quoteText ? buildMessageTextContent(replyTo.quoteText, replyTo.quoteEntities) : undefined, quoteOffset: replyTo.quoteOffset, } satisfies ApiInputMessageReplyInfo : undefined; diff --git a/src/api/gramjs/apiBuilders/peers.ts b/src/api/gramjs/apiBuilders/peers.ts index ad0786c6d..24dac32ca 100644 --- a/src/api/gramjs/apiBuilders/peers.ts +++ b/src/api/gramjs/apiBuilders/peers.ts @@ -3,7 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiEmojiStatusType, ApiPeerColor } from '../../types'; -import { CHANNEL_ID_LENGTH } from '../../../config'; +import { CHANNEL_ID_BASE } from '../../../config'; import { numberToHexColor } from '../../../util/colors'; type TypePeerOrInput = GramJs.TypePeer | GramJs.TypeInputPeer | GramJs.TypeInputUser | GramJs.TypeInputChannel; @@ -26,13 +26,10 @@ export function buildApiPeerId(id: BigInt.BigInteger, type: 'user' | 'chat' | 'c } if (type === 'channel') { - // Simulates TDLib https://github.com/tdlib/td/blob/d7203eb719304866a7eb7033ef03d421459335b8/td/telegram/DialogId.cpp#L54 - // But using only string operations. Should be fine until channel ids reach 10^12 - // Example: 12345678 -> -1000012345678 - return `-1${id.toString().padStart(CHANNEL_ID_LENGTH - 2, '0')}`; + return id.add(CHANNEL_ID_BASE).negate().toString(); } - return `-${id.toString()}`; + return id.negate().toString(); } export function getApiChatIdFromMtpPeer(peer: TypePeerOrInput) { diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index a88f25dcf..26df5d344 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -37,23 +37,22 @@ import { ApiMessageEntityTypes, } from '../../types'; -import { CHANNEL_ID_LENGTH, DEFAULT_STATUS_ICON_ID } from '../../../config'; +import { CHANNEL_ID_BASE, DEFAULT_STATUS_ICON_ID } from '../../../config'; import { pick } from '../../../util/iteratees'; import { deserializeBytes } from '../helpers/misc'; import localDb from '../localDb'; -function checkIfChannelId(id: string) { - return id.length === CHANNEL_ID_LENGTH && id.startsWith('-1'); -} - -export function getEntityTypeById(chatOrUserId: string) { - if (!chatOrUserId.startsWith('-')) { +export function getEntityTypeById(peerId: string) { + const n = Number(peerId); + if (n > 0) { return 'user'; - } else if (checkIfChannelId(chatOrUserId)) { - return 'channel'; - } else { - return 'chat'; } + + if (n < -CHANNEL_ID_BASE) { + return 'channel'; + } + + return 'chat'; } export function buildPeer(chatOrUserId: string): GramJs.TypePeer { @@ -569,11 +568,13 @@ export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { return BigInt(id); } + const n = Number(id); + if (type === 'channel') { - return BigInt(id.slice(2)); // Slice "-1", zeroes are trimmed when converting to BigInt + return BigInt(-n - CHANNEL_ID_BASE); } - return BigInt(id.slice(1)); + return BigInt(n * -1); } export function buildInputGroupCall(groupCall: Partial) { @@ -843,12 +844,13 @@ export function buildInputReplyTo(replyInfo: ApiInputReplyInfo) { if (replyInfo.type === 'message') { const { - replyToMsgId, replyToTopId, replyToPeerId, quoteText, quoteOffset, + replyToMsgId, replyToTopId, replyToPeerId, quoteText, quoteOffset, monoforumPeerId, } = replyInfo; return new GramJs.InputReplyToMessage({ replyToMsgId, topMsgId: replyToTopId, replyToPeerId: replyToPeerId ? buildInputPeerFromLocalDb(replyToPeerId)! : undefined, + monoforumPeerId: monoforumPeerId ? buildInputPeerFromLocalDb(monoforumPeerId)! : undefined, quoteText: quoteText?.text, quoteEntities: quoteText?.entities?.map(buildMtpMessageEntity), quoteOffset, diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 124d00bcb..b294b9477 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -299,6 +299,10 @@ export async function fetchSavedChats({ const chats: ApiChat[] = []; dialogs.forEach((dialog) => { + if (dialog instanceof GramJs.MonoForumDialog) { + return; + } + const peerEntity = peersByKey[getPeerKey(dialog.peer)]; const chat = buildApiChatFromSavedDialog(dialog, peerEntity); const chatId = getApiChatIdFromMtpPeer(dialog.peer); @@ -596,7 +600,7 @@ async function getFullChatInfo(chatId: string): Promise { - const { id, adminRights } = chat; + const { id, adminRights, isMonoforum } = chat; const accessHash = chat.accessHash!; const result = await invokeRequest(new GramJs.channels.GetFullChannel({ channel: buildInputChannel(id, accessHash), @@ -653,12 +657,14 @@ async function getFullChannelInfo( ? exportedInvite.link : undefined; - const { members, userStatusesById } = (canViewParticipants && await fetchMembers({ chat })) || {}; + const canLoadParticipants = canViewParticipants && !isMonoforum; + + const { members, userStatusesById } = (canLoadParticipants && await fetchMembers({ chat })) || {}; const { members: kickedMembers, userStatusesById: bannedStatusesById } = ( - canViewParticipants && adminRights && await fetchMembers({ chat, memberFilter: 'kicked' }) + canLoadParticipants && adminRights && await fetchMembers({ chat, memberFilter: 'kicked' }) ) || {}; const { members: adminMembers, userStatusesById: adminStatusesById } = ( - canViewParticipants && await fetchMembers({ chat, memberFilter: 'admin' }) + canLoadParticipants && await fetchMembers({ chat, memberFilter: 'admin' }) ) || {}; const botCommands = botInfo ? buildApiChatBotCommands(botInfo) : undefined; const memberInfoRequest = !chat.isNotJoined && chat.type === 'chatTypeChannel' @@ -710,7 +716,7 @@ async function getFullChannelInfo( chatId: buildApiPeerId(migratedFromChatId, 'chat'), maxMessageId: migratedFromMaxId, } : undefined, - canViewMembers: canViewParticipants, + canViewMembers: canLoadParticipants, canViewStatistics: canViewStats, canViewMonetization, isPreHistoryHidden: hiddenPrehistory, diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 5be1ea104..68a70bb6f 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -980,8 +980,8 @@ export async function sendMessageAction({ }: { peer: ApiPeer; threadId?: ThreadId; action: ApiSendMessageAction; }) { - const gramAction = buildSendMessageAction(action); - if (!gramAction) { + const mtpAction = buildSendMessageAction(action); + if (!mtpAction) { if (DEBUG) { // eslint-disable-next-line no-console console.warn('Unsupported message action', action); @@ -993,7 +993,7 @@ export async function sendMessageAction({ const result = await invokeRequest(new GramJs.messages.SetTyping({ peer: buildInputPeer(peer.id, peer.accessHash), topMsgId: Number(threadId), - action: gramAction, + action: mtpAction, }), { shouldThrow: true, abortControllerChatId: peer.id, diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index d0ab69dac..eea3a5f3d 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -47,6 +47,9 @@ export interface ApiChat { emojiStatus?: ApiEmojiStatusType; isForum?: boolean; isForumAsMessages?: true; + isMonoforum?: boolean; + linkedMonoforumId?: string; + areChannelMessagesAllowed?: boolean; boostLevel?: number; botVerificationIconId?: string; hasAutoTranslation?: true; diff --git a/src/api/types/messageActions.ts b/src/api/types/messageActions.ts index 096117ab9..0a5bc4855 100644 --- a/src/api/types/messageActions.ts +++ b/src/api/types/messageActions.ts @@ -278,6 +278,7 @@ export interface ApiMessageActionPaidMessagesRefunded extends ActionMediaType { export interface ApiMessageActionPaidMessagesPrice extends ActionMediaType { type: 'paidMessagesPrice'; stars: number; + isAllowedInChannel?: boolean; } export interface ApiMessageActionUnsupported extends ActionMediaType { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index d6827c9a3..bdebd715f 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -379,6 +379,7 @@ export interface ApiInputMessageReplyInfo { replyToMsgId: number; replyToTopId?: number; replyToPeerId?: string; + monoforumPeerId?: string; quoteText?: ApiFormattedText; quoteOffset?: number; } diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 031c8cf45..d444d27af 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -250,6 +250,7 @@ export interface ApiAppConfig { starsStargiftResaleAmountMin?: number; starsStargiftResaleAmountMax?: number; starsStargiftResaleCommissionPermille?: number; + pollMaxAnswers?: number; } export interface ApiConfig { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index c5e8eda53..fbc9832b7 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1918,7 +1918,8 @@ "SectionDescriptionStarsForForMessages" = "You will receive {percent} of the selected fee (~{amount}) for each incoming message."; "SubtitlePrivacyAddUsers" = "Add Users"; "PrivacyPaidMessagesValue" = "Paid"; -"FirstMessageInPaidMessagesChat" = "**{user}** charges {amount} for each message."; +"MessagesPlaceholderPaidUser" = "**{peer}** charges {amount} for each message."; +"MessagesPlaceholderPaidChannel" = "**{peer}** charges {amount} per message to its admin."; "ButtonBuyStars" = "Buy Stars"; "ComposerPlaceholderPaidMessage" = "Message for {amount}"; "ComposerPlaceholderPaidReply" = "Reply for {amount}"; @@ -1965,8 +1966,12 @@ "FrozenAccountAppealSubtitle" = "Appeal via {botLink} before {date} or your account will be deleted."; "ButtonAppeal" = "Submit an Appeal"; "ButtonUnderstood" = "Understood"; -"ActionPaidMessageGroupPrice" = "Messages now cost **{stars}** in this group"; -"ActionPaidMessageGroupPriceFree" = "Messages are now free in this group"; +"ActionPaidMessagePrice" = "{peer} changed price to **{amount}** per message"; +"ActionPaidMessagePriceYou" = "You changed price to **{amount}** per message"; +"ActionPaidMessagePriceFree" = "{peer} made messages free"; +"ActionPaidMessagePriceFreeYou" = "You made messages free"; +"ActionMessageChannelFree" = "{peer} now accepts private messages"; +"ActionMessageChannelDisabled" = "{peer} no longer accepts private messages"; "ApiMessageActionPaidMessagesRefundedOutgoing" = "You refunded **{stars}** to {user}"; "ApiMessageActionPaidMessagesRefundedIncoming" = "{user} refunded **{stars}** to you"; "NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen"; @@ -2012,4 +2017,8 @@ "ValueGiftSortByNumber" = "Number"; "ResellGiftsNoFound" = "No gifts found"; "ResellGiftsClearFilters" = "Clear Filters"; +"MonoforumBadge" = "DIRECT"; +"MonoforumStatus" = "Channel messages"; +"MonoforumComposerPlaceholder" = "Choose a message to reply"; +"ChannelSendMessage" = "Direct Messages"; "AutomaticTranslation" = "Automatic Translation"; diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index 7993b4184..70e4f6f59 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -150,4 +150,13 @@ &.premium-gradient-bg > .inner { background-image: var(--premium-gradient); } + + &.message-bubble { + --radius: 0; + + mask-image: url("../../assets/icons/forumTopic/blue.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 100%; + } } diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 4f85ba21c..3321b8548 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -85,6 +85,7 @@ type OwnProps = { storyViewerMode?: 'full' | 'single-peer' | 'disabled'; loopIndefinitely?: boolean; noPersonalPhoto?: boolean; + asMessageBubble?: boolean; observeIntersection?: ObserveFn; onClick?: (e: ReactMouseEvent, hasMedia: boolean) => void; onContextMenu?: (e: React.MouseEvent) => void; @@ -111,6 +112,7 @@ const Avatar: FC = ({ storyViewerMode = 'single-peer', loopIndefinitely, noPersonalPhoto, + asMessageBubble, onClick, onContextMenu, }) => { @@ -261,6 +263,7 @@ const Avatar: FC = ({ isReplies && 'replies-bot-account', isPremiumGradient && 'premium-gradient-bg', isRoundedRect && 'forum', + asMessageBubble && 'message-bubble', (photo || webPhoto) && 'force-fit', ((withStory && realPeer?.hasStories) || forPremiumPromo) && 'with-story-circle', withStorySolid && realPeer?.hasStories && 'with-story-solid', diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 08f2947ff..530009245 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -300,6 +300,7 @@ type StateProps = isAccountFrozen?: boolean; isAppConfigLoaded?: boolean; insertingPeerIdMention?: string; + pollMaxAnswers?: number; }; enum MainButtonState { @@ -413,10 +414,6 @@ const Composer: FC = ({ shouldPlayEffect, maxMessageLength, isSilentPosting, - onDropHide, - onFocus, - onBlur, - onForward, isPaymentMessageConfirmDialogOpen, starsBalance, isStarsBalanceModalOpen, @@ -424,6 +421,11 @@ const Composer: FC = ({ isAccountFrozen, isAppConfigLoaded, insertingPeerIdMention, + pollMaxAnswers, + onDropHide, + onFocus, + onBlur, + onForward, }) => { const { sendMessage, @@ -481,9 +483,12 @@ const Composer: FC = ({ const canMediaBeReplaced = editingMessage && canEditMedia(editingMessage); + const isMonoforum = chat?.isMonoforum; const { emojiSet, members: groupChatMembers, botCommands: chatBotCommands } = chatFullInfo || {}; const chatEmojiSetId = emojiSet?.id; + const canSchedule = !paidMessagesStars && !isMonoforum; + const isSentStoryReactionHeart = sentStoryReaction && isSameReaction(sentStoryReaction, HEART_REACTION); useEffect(processMessageInputForCustomEmoji, [getHtml]); @@ -510,10 +515,10 @@ const Composer: FC = ({ }, [chatId]); useEffect(() => { - if (isAppConfigLoaded && chatId && isReady && !isInStoryViewer) { + if (isAppConfigLoaded && chatId && isReady && !isInStoryViewer && !isMonoforum) { loadScheduledHistory({ chatId }); } - }, [isReady, chatId, threadId, isInStoryViewer, isAppConfigLoaded]); + }, [isReady, chatId, threadId, isInStoryViewer, isAppConfigLoaded, isMonoforum]); useEffect(() => { const isChannelWithProfiles = isChannel && chat?.areProfilesShown; @@ -541,10 +546,11 @@ const Composer: FC = ({ () => getAllowedAttachmentOptions(chat, chatFullInfo, isChatWithBot, + isChatWithSelf, isInStoryViewer, paidMessagesStars, isInScheduledList), - [chat, chatFullInfo, isChatWithBot, isInStoryViewer, paidMessagesStars, isInScheduledList], + [chat, chatFullInfo, isChatWithBot, isChatWithSelf, isInStoryViewer, paidMessagesStars, isInScheduledList], ); const isNeedPremium = isContactRequirePremium && isInStoryViewer; @@ -804,7 +810,7 @@ const Composer: FC = ({ getHtml, setHtml, editedMessage: editingMessage, - isDisabled: isInStoryViewer || Boolean(requestedDraft), + isDisabled: isInStoryViewer || Boolean(requestedDraft) || isMonoforum, }); const resetComposer = useLastCallback((shouldPreserveInput = false) => { @@ -1847,8 +1853,8 @@ const Composer: FC = ({ shouldForceAsFile={shouldForceAsFile} isForCurrentMessageList={isForCurrentMessageList} isForMessage={isInMessageList} - shouldSchedule={!paidMessagesStars && isInScheduledList} - canSchedule={!paidMessagesStars} + shouldSchedule={canSchedule && isInScheduledList} + canSchedule={canSchedule} forceDarkTheme={isInStoryViewer} onCaptionUpdate={onCaptionUpdate} onSendSilent={handleSendSilentAttachments} @@ -1869,6 +1875,7 @@ const Composer: FC = ({ isOpen={pollModal.isOpen} isQuiz={pollModal.isQuiz} shouldBeAnonymous={isChannel} + maxOptionsCount={pollMaxAnswers} onClear={closePollModal} onSend={handlePollSend} /> @@ -2304,7 +2311,7 @@ const Composer: FC = ({ {canShowCustomSendMenu && ( ( (global, { chatId, threadId, storyId, messageListType, isMobile, type, }): StateProps => { + const appConfig = global.appConfig; const chat = selectChat(global, chatId); const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined; const isChatWithBot = Boolean(chatBot); @@ -2514,6 +2522,7 @@ export default memo(withGlobal( isAccountFrozen, isAppConfigLoaded, insertingPeerIdMention, + pollMaxAnswers: appConfig?.pollMaxAnswers, }; }, )(Composer)); diff --git a/src/components/common/DeleteMessageModal.tsx b/src/components/common/DeleteMessageModal.tsx index 08b3cf4de..a2af392ba 100644 --- a/src/components/common/DeleteMessageModal.tsx +++ b/src/components/common/DeleteMessageModal.tsx @@ -90,11 +90,11 @@ const DeleteMessageModal: FC = ({ contactName, willDeleteForCurrentUserOnly, willDeleteForAll, - onConfirm, chatBot, adminMembersById, canBanUsers, linkedChatId, + onConfirm, }) => { const { closeDeleteMessageModal, @@ -130,7 +130,11 @@ const DeleteMessageModal: FC = ({ const global = getGlobal(); const senderArray = getSendersFromSelectedMessages(global, chat.id, messageIds); return senderArray ? unique(senderArray) - .filter((peer) => peer?.id !== chat?.id && peer?.id !== linkedChatId) : MEMO_EMPTY_ARRAY; + .filter((peer) => ( + peer?.id !== chat?.id + && peer?.id !== linkedChatId + && peer?.id !== chat?.linkedMonoforumId + )) : MEMO_EMPTY_ARRAY; }, [chat, isChannel, linkedChatId, messageIds]); const buildNestedOptionListWithAvatars = useLastCallback(() => { @@ -144,16 +148,24 @@ const DeleteMessageModal: FC = ({ }); const peerListToDeleteAll = useMemo(() => { - return peerList.filter((peer) => peer.id !== linkedChatId && peer.id !== currentUserId); - }, [peerList, currentUserId, linkedChatId]); + return peerList.filter((peer) => ( + peer.id !== linkedChatId + && peer.id !== chat?.linkedMonoforumId + && peer.id !== currentUserId + )); + }, [peerList, currentUserId, linkedChatId, chat?.linkedMonoforumId]); const peerListToReportSpam = useMemo(() => { - return peerList.filter((peer) => peer.id !== currentUserId && peer.id !== linkedChatId); - }, [peerList, currentUserId, linkedChatId]); + return peerList.filter((peer) => ( + peer.id !== currentUserId + && peer.id !== linkedChatId + && peer.id !== chat?.linkedMonoforumId + )); + }, [peerList, currentUserId, linkedChatId, chat?.linkedMonoforumId]); const peerListToBan = useMemo(() => { const isCurrentUserInList = peerList.some((peer) => peer.id === currentUserId); - const shouldReturnEmpty = !canBanUsers || isCurrentUserInList; + const shouldReturnEmpty = !canBanUsers || isCurrentUserInList || chat?.isMonoforum; if (shouldReturnEmpty) { return MEMO_EMPTY_ARRAY; @@ -163,7 +175,7 @@ const DeleteMessageModal: FC = ({ const isAdmin = adminMembersById?.[peer.id]; return isCreator || !isAdmin; }); - }, [peerList, isCreator, currentUserId, canBanUsers, adminMembersById]); + }, [peerList, isCreator, currentUserId, canBanUsers, adminMembersById, chat?.isMonoforum]); const shouldShowAdditionalOptions = useMemo(() => { return Boolean(peerListToDeleteAll.length || peerListToReportSpam.length || peerListToBan.length); @@ -184,11 +196,12 @@ const DeleteMessageModal: FC = ({ label: oldLang('ReportSpamTitle'), nestedOptions: messageIds && peerList.length >= 2 ? [ ...buildNestedOptionListWithAvatars().filter((opt) => opt.value !== linkedChatId + && opt.value !== chat?.linkedMonoforumId && opt.value !== currentUserId), ] : undefined, }, ]; - }, [messageIds, peerList, oldLang, linkedChatId, currentUserId]); + }, [messageIds, peerList, oldLang, linkedChatId, chat?.linkedMonoforumId, currentUserId]); const ACTION_DELETE_OPTION: IRadioOption[] = useMemo(() => { return [ @@ -199,11 +212,12 @@ const DeleteMessageModal: FC = ({ : oldLang('DeleteAllFrom', Object.values(peerNames)[0]), nestedOptions: messageIds && peerList.length >= 2 ? [ ...buildNestedOptionListWithAvatars().filter((opt) => opt.value !== linkedChatId + && opt.value !== chat?.linkedMonoforumId && opt.value !== currentUserId), ] : undefined, }, ]; - }, [messageIds, peerList, oldLang, peerNames, linkedChatId, currentUserId]); + }, [messageIds, peerList, oldLang, peerNames, linkedChatId, chat?.linkedMonoforumId, currentUserId]); const ACTION_BAN_OPTION: IRadioOption[] = useMemo(() => { return [ @@ -279,7 +293,7 @@ const DeleteMessageModal: FC = ({ if (isSchedule) { deleteScheduledMessages({ messageIds }); } else if (shouldShowOption) { - if (peerIdsToReportSpam) { + if (peerIdsToReportSpam?.length) { const global = getGlobal(); const peerIdList = peerIdsToReportSpam.filter((option) => !Number.isNaN(Number(option))); const messageList = messageIds.reduce>((acc, msgId) => { @@ -296,24 +310,24 @@ const DeleteMessageModal: FC = ({ handleReportSpam(messageList); } - if (peerIdsToDeleteAll) { + if (peerIdsToDeleteAll?.length) { const peerIdList = peerIdsToDeleteAll.filter((option) => !Number.isNaN(Number(option))); handleDeleteAllPeerMessages(peerIdList); } - if (peerIdsToBan && !havePermissionChanged) { + if (peerIdsToBan?.length && !havePermissionChanged) { const peerIdList = peerIdsToBan.filter((option) => !Number.isNaN(Number(option))); handleDeleteMember(peerIdList); const filteredMessageIdList = filterMessageIdByPeerId(peerIdList, messageIds); handleDeleteMessages(filteredMessageIdList); } - if (peerIdsToBan && havePermissionChanged) { + if (peerIdsToBan?.length && havePermissionChanged) { const peerIdList = peerIdsToBan.filter((option) => !Number.isNaN(Number(option))); handleUpdateChatMemberBannedRights(peerIdList); } - if (!peerIdsToReportSpam || !peerIdsToDeleteAll || !peerIdsToBan) { + if (!peerIdsToReportSpam?.length || !peerIdsToDeleteAll?.length || !peerIdsToBan?.length) { deleteMessages({ messageIds, shouldDeleteForAll }); } } else { @@ -426,21 +440,19 @@ const DeleteMessageModal: FC = ({

{oldLang('DeleteAdditionalActions')}

{renderAdditionalActionOptions()} {renderPartiallyRestrictedUser()} - { - peerIdsToBan && canBanUsers ? ( - - {oldLang(isAdditionalOptionsVisible ? 'DeleteToggleBanUsers' : 'DeleteToggleRestrictUsers')} - - - ) : setIsAdditionalOptionsVisible(false) - } + {peerIdsToBan?.length && canBanUsers ? ( + + {oldLang(isAdditionalOptionsVisible ? 'DeleteToggleBanUsers' : 'DeleteToggleRestrictUsers')} + + + ) : setIsAdditionalOptionsVisible(false)} )} {(canDeleteForAll || chatBot || !shouldShowOption) && ( diff --git a/src/components/common/FullNameTitle.module.scss b/src/components/common/FullNameTitle.module.scss index 1a8ea9c9f..32c0ede4a 100644 --- a/src/components/common/FullNameTitle.module.scss +++ b/src/components/common/FullNameTitle.module.scss @@ -38,3 +38,15 @@ display: flex; align-items: center; } + +.monoforumBadge { + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + + font-size: 0.625rem; + font-weight: 500; + line-height: 1; + color: var(--color-white); + + background-color: var(--color-gray); +} diff --git a/src/components/common/FullNameTitle.tsx b/src/components/common/FullNameTitle.tsx index dfa2a9fbb..e9a68ec08 100644 --- a/src/components/common/FullNameTitle.tsx +++ b/src/components/common/FullNameTitle.tsx @@ -24,6 +24,7 @@ import { copyTextToClipboard } from '../../util/clipboard'; import stopEvent from '../../util/stopEvent'; import renderText from './helpers/renderText'; +import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; @@ -44,12 +45,14 @@ type OwnProps = { emojiStatusSize?: number; isSavedMessages?: boolean; isSavedDialog?: boolean; + isMonoforum?: boolean; + monoforumBadgeClassName?: string; noLoopLimit?: boolean; canCopyTitle?: boolean; iconElement?: React.ReactNode; + statusSparklesColor?: string; onEmojiStatusClick?: NoneToVoidFunction; observeIntersection?: ObserveFn; - statusSparklesColor?: string; }; const FullNameTitle: FC = ({ @@ -65,15 +68,20 @@ const FullNameTitle: FC = ({ canCopyTitle, iconElement, statusSparklesColor, + isMonoforum, + monoforumBadgeClassName, onEmojiStatusClick, observeIntersection, }) => { - const lang = useOldLang(); const { showNotification } = getActions(); + + const oldLang = useOldLang(); + const lang = useLang(); + const realPeer = 'id' in peer ? peer : undefined; const customPeer = 'isCustomPeer' in peer ? peer : undefined; const isUser = realPeer && isApiPeerUser(realPeer); - const title = realPeer && (isUser ? getUserFullName(realPeer) : getChatTitle(lang, realPeer)); + const title = realPeer && (isUser ? getUserFullName(realPeer) : getChatTitle(oldLang, realPeer)); const isPremium = (isUser && realPeer.isPremium) || customPeer?.isPremium; const canShowEmojiStatus = withEmojiStatus && !isSavedMessages; const emojiStatus = realPeer?.emojiStatus @@ -91,27 +99,27 @@ const FullNameTitle: FC = ({ const specialTitle = useMemo(() => { if (customPeer) { - return renderText(customPeer.title || lang(customPeer.titleKey!)); + return renderText(customPeer.title || oldLang(customPeer.titleKey!)); } if (isSavedMessages) { - return lang(isSavedDialog ? 'MyNotes' : 'SavedMessages'); + return oldLang(isSavedDialog ? 'MyNotes' : 'SavedMessages'); } if (isAnonymousForwardsChat(realPeer!.id)) { - return lang('AnonymousForward'); + return oldLang('AnonymousForward'); } if (isChatWithRepliesBot(realPeer!.id)) { - return lang('RepliesTitle'); + return oldLang('RepliesTitle'); } if (isChatWithVerificationCodesBot(realPeer!.id)) { - return lang('VerifyCodesNotifications'); + return oldLang('VerifyCodesNotifications'); } return undefined; - }, [customPeer, isSavedDialog, isSavedMessages, lang, realPeer]); + }, [customPeer, isSavedDialog, isSavedMessages, oldLang, realPeer]); const botVerificationIconId = realPeer?.botVerificationIconId; return ( @@ -164,6 +172,11 @@ const FullNameTitle: FC = ({ )} {canShowEmojiStatus && !emojiStatus && isPremium && } + {isMonoforum && ( +
+ {lang('MonoforumBadge')} +
+ )} )} {iconElement} diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index 841d24c74..f2f593d06 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -19,6 +19,7 @@ import { selectChat, selectChatMessages, selectChatOnlineCount, + selectMonoforumChannel, selectThreadInfo, selectThreadMessagesCount, selectTopic, @@ -28,6 +29,7 @@ import buildClassName from '../../util/buildClassName'; import { REM } from './helpers/mediaDimensions'; import renderText from './helpers/renderText'; +import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; @@ -63,20 +65,21 @@ type OwnProps = { withStory?: boolean; storyViewerOrigin?: StoryViewerOrigin; isSavedDialog?: boolean; + withMonoforumStatus?: boolean; onClick?: VoidFunction; onEmojiStatusClick?: NoneToVoidFunction; }; -type StateProps = - { - chat?: ApiChat; - threadInfo?: ApiThreadInfo; - topic?: ApiTopic; - onlineCount?: number; - areMessagesLoaded: boolean; - messagesCount?: number; - self?: ApiUser; - }; +type StateProps = { + chat?: ApiChat; + threadInfo?: ApiThreadInfo; + topic?: ApiTopic; + onlineCount?: number; + areMessagesLoaded: boolean; + messagesCount?: number; + self?: ApiUser; + monoforumChannel?: ApiChat; +}; const GroupChatInfo: FC = ({ typingStatus, @@ -93,7 +96,7 @@ const GroupChatInfo: FC = ({ withChatType, threadInfo, noRtl, - chat, + chat: realChat, onlineCount, areMessagesLoaded, topic, @@ -105,6 +108,8 @@ const GroupChatInfo: FC = ({ emojiStatusSize, isSavedDialog, self, + withMonoforumStatus, + monoforumChannel, onClick, onEmojiStatusClick, }) => { @@ -114,7 +119,10 @@ const GroupChatInfo: FC = ({ loadMoreProfilePhotos, } = getActions(); - const lang = useOldLang(); + const chat = !withMonoforumStatus && monoforumChannel ? monoforumChannel : realChat; + + const oldLang = useOldLang(); + const lang = useLang(); const isSuperGroup = chat && isChatSuperGroup(chat); const isTopic = Boolean(chat?.isForum && threadInfo && topic); @@ -148,6 +156,24 @@ const GroupChatInfo: FC = ({ } function renderStatusOrTyping() { + if (withUpdatingStatus && !areMessagesLoaded && !isRestricted) { + return ( + + ); + } + + if (withMonoforumStatus) { + return ( + + {lang('MonoforumStatus')} + + ); + } + + if (realChat?.isMonoforum) { + return undefined; + } + if (status) { return withDots ? ( @@ -159,12 +185,6 @@ const GroupChatInfo: FC = ({ ); } - if (withUpdatingStatus && !areMessagesLoaded && !isRestricted) { - return ( - - ); - } - if (!chat) { return undefined; } @@ -182,7 +202,7 @@ const GroupChatInfo: FC = ({ activeKey={messagesCount !== undefined ? 1 : 2} className="message-count-transition" > - {messagesCount !== undefined && lang('messages', messagesCount, 'i')} + {messagesCount !== undefined && oldLang('messages', messagesCount, 'i')} ); @@ -190,12 +210,12 @@ const GroupChatInfo: FC = ({ if (withChatType) { return ( - {lang(getChatTypeString(chat))} + {oldLang(getChatTypeString(chat))} ); } - const groupStatus = getGroupStatus(lang, chat); - const onlineStatus = onlineCount ? `, ${lang('OnlineCount', onlineCount, 'i')}` : undefined; + const groupStatus = getGroupStatus(oldLang, chat); + const onlineStatus = onlineCount ? `, ${oldLang('OnlineCount', onlineCount, 'i')}` : undefined; return ( @@ -211,7 +231,7 @@ const GroupChatInfo: FC = ({ className={ buildClassName('ChatInfo', className) } - dir={!noRtl && lang.isRtl ? 'rtl' : undefined} + dir={!noRtl && oldLang.isRtl ? 'rtl' : undefined} onClick={onClick} > {!noAvatar && !isTopic && ( @@ -231,6 +251,7 @@ const GroupChatInfo: FC = ({ size={avatarSize} peer={chat} withStory={withStory} + asMessageBubble={Boolean(monoforumChannel)} storyViewerOrigin={storyViewerOrigin} storyViewerMode="single-peer" isSavedDialog={isSavedDialog} @@ -251,6 +272,7 @@ const GroupChatInfo: FC = ({ : ( ( const topic = threadId ? selectTopic(global, chatId, threadId) : undefined; const messagesCount = topic && selectThreadMessagesCount(global, chatId, threadId!); const self = selectUser(global, global.currentUserId!); + const monoforumChannel = selectMonoforumChannel(global, chatId); return { chat, @@ -281,6 +304,7 @@ export default memo(withGlobal( areMessagesLoaded, messagesCount, self, + monoforumChannel, }; }, )(GroupChatInfo)); diff --git a/src/components/common/ProfileInfo.tsx b/src/components/common/ProfileInfo.tsx index 23ee41083..d3f6f4ca2 100644 --- a/src/components/common/ProfileInfo.tsx +++ b/src/components/common/ProfileInfo.tsx @@ -27,6 +27,7 @@ import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import renderText from './helpers/renderText'; import useIntervalForceUpdate from '../../hooks/schedulers/useIntervalForceUpdate'; +import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; @@ -45,6 +46,7 @@ type OwnProps = { peerId: string; forceShowSelf?: boolean; canPlayVideo: boolean; + isForMonoforum?: boolean; }; type StateProps = @@ -81,6 +83,7 @@ const ProfileInfo: FC = ({ emojiStatusSlug, profilePhotos, peerId, + isForMonoforum, }) => { const { openMediaViewer, @@ -91,7 +94,8 @@ const ProfileInfo: FC = ({ openUniqueGiftBySlug, } = getActions(); - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); useIntervalForceUpdate(user ? STATUS_UPDATE_INTERVAL : undefined); @@ -100,7 +104,7 @@ const ProfileInfo: FC = ({ const prevAvatarOwnerId = usePreviousDeprecated(avatarOwnerId); const [hasSlideAnimation, setHasSlideAnimation] = useState(true); // slideOptimized doesn't work well when animation is dynamically disabled - const slideAnimation = hasSlideAnimation ? (lang.isRtl ? 'slideRtl' : 'slide') : 'none'; + const slideAnimation = hasSlideAnimation ? (oldLang.isRtl ? 'slideRtl' : 'slide') : 'none'; const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const isFirst = photos.length <= 1 || currentPhotoIndex === 0; @@ -217,9 +221,9 @@ const ProfileInfo: FC = ({ letterClassName={styles.topicIconTitle} noLoopLimit /> -

{renderText(topic!.title)}

+

{renderText(topic!.title)}

- {messagesCount ? lang('Chat.Title.Topic', messagesCount, 'i') : lang('lng_forum_no_messages')} + {messagesCount ? oldLang('Chat.Title.Topic', messagesCount, 'i') : oldLang('lng_forum_no_messages')}

); @@ -265,6 +269,14 @@ const ProfileInfo: FC = ({ const isSystemBotChat = isSystemBot(peerId); if (isAnonymousForwards || isSystemBotChat) return undefined; + if (isForMonoforum) { + return ( + + {lang('MonoforumStatus')} + + ); + } + if (user) { return (
= ({ )} > - {getUserStatus(lang, user, userStatus)} + {getUserStatus(oldLang, user, userStatus)} {userStatus?.isReadDateRestrictedByMe && ( - {lang('StatusHiddenShow')} + {oldLang('StatusHiddenShow')} )}
@@ -290,8 +302,8 @@ const ProfileInfo: FC = ({ { isChatChannel(chat!) - ? lang('Subscribers', chat!.membersCount ?? 0, 'i') - : lang('Members', chat!.membersCount ?? 0, 'i') + ? oldLang('Subscribers', chat!.membersCount ?? 0, 'i') + : oldLang('Members', chat!.membersCount ?? 0, 'i') } ); @@ -304,7 +316,7 @@ const ProfileInfo: FC = ({ return (
{renderPhotoTabs()} @@ -315,7 +327,7 @@ const ProfileInfo: FC = ({ )} >
- {lang(profilePhotos.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')} + {oldLang(profilePhotos.personalPhoto.isVideo ? 'UserInfo.CustomVideo' : 'UserInfo.CustomPhoto')}
)} @@ -333,7 +345,7 @@ const ProfileInfo: FC = ({ size="mini" /> )} - {lang(profilePhotos.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')} + {oldLang(profilePhotos.fallbackPhoto.isVideo ? 'UserInfo.PublicVideo' : 'UserInfo.PublicPhoto')}
)} @@ -344,24 +356,24 @@ const ProfileInfo: FC = ({ {!isFirst && ( = ({ function renderChatList() { return ( <> -
+
)} {canShowOpenChatButton && ( -
+
)} @@ -625,7 +634,7 @@ function MiddleColumn({ {( isMobile && (renderingCanSubscribe || (renderingShouldJoinToSend && !renderingShouldSendJoinRequest)) ) && ( -
+
)} {isMobile && renderingShouldSendJoinRequest && ( -
+
)} {isMobile && renderingCanStartBot && ( -
+
)} {isMobile && renderingCanRestartBot && ( -
+
)} {isMobile && renderingCanUnblock && ( -
+
)} @@ -788,7 +797,8 @@ export default memo(withGlobal( const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID; const isChannel = Boolean(chat && isChatChannel(chat)); const canSubscribe = Boolean( - chat && isMainThread && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined && !chat.joinRequests, + chat && isMainThread && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined && !chat.joinRequests + && !chat.isMonoforum, ); const shouldJoinToSend = Boolean(chat?.isNotJoined && chat.isJoinToSend); const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest); @@ -803,6 +813,8 @@ export default memo(withGlobal( const shouldBlockSendInForum = chat?.isForum ? threadId === MAIN_THREAD_ID && !draftReplyInfo && (selectTopic(global, chatId, GENERAL_TOPIC_ID)?.isClosed) : false; + const isMonoforumAdmin = selectIsMonoforumAdmin(global, chatId); + const shouldBlockSendInMonoforum = Boolean(chat?.isMonoforum && !draftReplyInfo && isMonoforumAdmin); const topics = selectTopics(global, chatId); const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); @@ -840,6 +852,7 @@ export default memo(withGlobal( && !isBotNotStarted && !(shouldJoinToSend && chat?.isNotJoined) && !shouldBlockSendInForum + && !shouldBlockSendInMonoforum && !isSavedDialog && (!isAccountFrozen || freezeAppealChat?.id === chatId), isPinnedMessageList, @@ -864,6 +877,7 @@ export default memo(withGlobal( paidMessagesStars, isAccountFrozen, freezeAppealChat, + shouldBlockSendInMonoforum, }; }, )(MiddleColumn)); diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 2cb562b0a..70efbf9b3 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -261,6 +261,8 @@ const MiddleHeader: FC = ({ const savedMessagesStatus = isSavedDialog ? lang('SavedMessages') : undefined; const realChatId = isSavedDialog ? String(threadId) : chatId; + + const displayChatId = chat?.isMonoforum ? chat.linkedMonoforumId! : realChatId; return ( <> {(isLeftColumnHideable || currentTransitionKey > 0) && renderBackButton(shouldShowCloseButton, !isSavedDialog)} @@ -272,10 +274,10 @@ const MiddleHeader: FC = ({ onTouchStart={handleLongPressTouchStart} onTouchEnd={handleLongPressTouchEnd} > - {isUserId(realChatId) ? ( + {isUserId(displayChatId) ? ( = ({ /> ) : (
@@ -55,8 +60,8 @@ function RequirementToContactMessage({ patternColor, userName, paidMessagesStars { paidMessagesStars - ? lang('FirstMessageInPaidMessagesChat', { - user: userName, + ? lang(isApiPeerUser(peer) ? 'MessagesPlaceholderPaidUser' : 'MessagesPlaceholderPaidChannel', { + peer: getPeerTitle(lang, peer), amount: formatStarsAsIcon(lang, paidMessagesStars, { @@ -68,7 +73,7 @@ function RequirementToContactMessage({ patternColor, userName, paidMessagesStars withNodes: true, withMarkdown: true, }) - : renderText(oldLang('MessageLockedPremium', userName), ['simple_markdown']) + : renderText(oldLang('MessageLockedPremium', getPeerTitle(lang, peer)), ['simple_markdown']) }