From d7e3456550334fde5570b2b83c53940a5245a076 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 14 Apr 2026 14:36:45 +0200 Subject: [PATCH] Forward: Fix forward to forum topics (#6842) Co-authored-by: Dmitry Kabanov <153344039+dmitrykabanovdev@users.noreply.github.com> Co-authored-by: Dmitry Kabanov --- src/components/common/RecipientPicker.tsx | 79 +++++++++---------- .../common/pickers/ChatOrUserPicker.tsx | 15 ++-- .../main/ForwardRecipientPicker.tsx | 24 +++--- src/global/actions/api/messages.ts | 23 ++++-- src/global/types/actions.ts | 3 +- src/types/index.ts | 5 ++ src/util/keys/chatSelectionKey.ts | 24 ++++++ 7 files changed, 113 insertions(+), 60 deletions(-) create mode 100644 src/util/keys/chatSelectionKey.ts diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index 0925a10b2..41a89eff1 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -22,6 +22,13 @@ import { import { selectCurrentLimit } from '../../global/selectors/limits'; import buildClassName from '../../util/buildClassName'; import { unique } from '../../util/iteratees'; +import { + areChatSelectionKeysEqual, + buildChatSelectionKey, + type ChatSelectionKey, + getChatSelectionKeyHash, + includesChatSelectionKey, +} from '../../util/keys/chatSelectionKey'; import sortChatIds from './helpers/sortChatIds'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; @@ -52,7 +59,7 @@ export type OwnProps = { viewportFooter?: TeactNode; loadMore?: NoneToVoidFunction; onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; - onSelectedIdsChange?: (ids: string[]) => void; + onSelectedIdsChange?: (ids: ChatSelectionKey[]) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; }; @@ -100,9 +107,9 @@ const RecipientPicker = ({ const lang = useLang(); const [search, setSearch] = useState(''); - const [selectedIds, setSelectedIds] = useState([]); - const [removingIds, setRemovingIds] = useState([]); - const [appearingIds, setAppearingIds] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [removingIds, setRemovingIds] = useState([]); + const [appearingIds, setAppearingIds] = useState([]); const selectedIdsRef = useStateRef(selectedIds); const removingIdsRef = useStateRef(removingIds); const appearingIdsRef = useStateRef(appearingIds); @@ -134,46 +141,46 @@ const RecipientPicker = ({ setActiveFolderIndex(index); }); - const updateSelectedIds = useLastCallback((newIds: string[], newlyAddedId?: string) => { + const updateSelectedIds = useLastCallback((newIds: ChatSelectionKey[], newlyAddedKey?: ChatSelectionKey) => { setSelectedIds(newIds); onSelectedIdsChange?.(newIds); - if (newlyAddedId && selectCanAnimateInterface(getGlobal())) { - setAppearingIds([...appearingIdsRef.current, newlyAddedId]); + if (newlyAddedKey && selectCanAnimateInterface(getGlobal())) { + setAppearingIds([...appearingIdsRef.current, newlyAddedKey]); setTimeout(() => { - setAppearingIds(appearingIdsRef.current.filter((id) => id !== newlyAddedId)); + setAppearingIds(appearingIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, newlyAddedKey))); }, 200); } }); - const handleRemoveSelected = useLastCallback((selectionId: string) => { - if (removingIdsRef.current.includes(selectionId)) return; + const handleRemoveSelected = useLastCallback((selectionKey: ChatSelectionKey) => { + if (includesChatSelectionKey(removingIdsRef.current, selectionKey)) return; const canAnimate = selectCanAnimateInterface(getGlobal()); if (!canAnimate) { - const newIds = selectedIdsRef.current.filter((id) => id !== selectionId); + const newIds = selectedIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey)); setSelectedIds(newIds); onSelectedIdsChange?.(newIds); return; } - setRemovingIds([...removingIdsRef.current, selectionId]); + setRemovingIds([...removingIdsRef.current, selectionKey]); setTimeout(() => { - setRemovingIds(removingIdsRef.current.filter((id) => id !== selectionId)); - const newIds = selectedIdsRef.current.filter((id) => id !== selectionId); + setRemovingIds(removingIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey))); + const newIds = selectedIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey)); setSelectedIds(newIds); onSelectedIdsChange?.(newIds); }, 300); }); const handleToggleSelection = useLastCallback((peerId: string, threadId?: ThreadId) => { - const selectionId = threadId ? `${peerId}:${threadId}` : peerId; + const selectionKey = buildChatSelectionKey(peerId, threadId ? Number(threadId) : undefined); - if (selectedIds.includes(selectionId)) { - handleRemoveSelected(selectionId); + if (includesChatSelectionKey(selectedIds, selectionKey)) { + handleRemoveSelected(selectionKey); } else { - updateSelectedIds([...selectedIds, selectionId], selectionId); + updateSelectedIds([...selectedIds, selectionKey], selectionKey); } }); @@ -262,19 +269,8 @@ const RecipientPicker = ({ const hasSelectedChips = isMultiSelect && selectedIds.length > 0; - const parseSelectionId = useLastCallback((selectionId: string): { peerId: string; topicId?: number } => { - const colonIndex = selectionId.indexOf(':'); - if (colonIndex === -1) { - return { peerId: selectionId }; - } - return { - peerId: selectionId.substring(0, colonIndex), - topicId: Number(selectionId.substring(colonIndex + 1)), - }; - }); - - const getChipTitle = useLastCallback((selectionId: string): string | undefined => { - const { peerId, topicId } = parseSelectionId(selectionId); + const getChipTitle = useLastCallback((selectionKey: ChatSelectionKey): string | undefined => { + const { peerId, topicId } = selectionKey; if (!topicId) return undefined; const global = getGlobal(); @@ -309,15 +305,16 @@ const RecipientPicker = ({ return (
- {selectedIds.map((selectionId) => { - const { peerId } = parseSelectionId(selectionId); - const chipTitle = getChipTitle(selectionId); - const isAppearing = appearingIds.includes(selectionId); - const isRemoving = removingIds.includes(selectionId); + {selectedIds.map((selectionKey) => { + const { peerId } = selectionKey; + const chipTitle = getChipTitle(selectionKey); + const isAppearing = includesChatSelectionKey(appearingIds, selectionKey); + const isRemoving = includesChatSelectionKey(removingIds, selectionKey); + const keyHash = getChatSelectionKeyHash(selectionKey); return (
@@ -356,6 +353,8 @@ const RecipientPicker = ({ ); }, [hasSelectedChips, selectedIds, appearingIds, removingIds]); + const selectedPeerIds = useMemo(() => selectedIds.map((key) => key.peerId), [selectedIds]); + const subheaderContent = useMemo(() => { const hasRecentContacts = recentContactIds.length > 0 && !search; const hasFolderTabs = shouldRenderFolders; @@ -368,7 +367,7 @@ const RecipientPicker = ({ @@ -389,7 +388,7 @@ const RecipientPicker = ({ currentUserId, handleSelect, isMultiSelect, - selectedIds, + selectedPeerIds, folderTabs, activeFolderIndex, handleSwitchFolderIndex, diff --git a/src/components/common/pickers/ChatOrUserPicker.tsx b/src/components/common/pickers/ChatOrUserPicker.tsx index 9bcca1e79..60decceac 100644 --- a/src/components/common/pickers/ChatOrUserPicker.tsx +++ b/src/components/common/pickers/ChatOrUserPicker.tsx @@ -22,6 +22,11 @@ import { } from '../../../global/selectors'; import { selectAnimationLevel } from '../../../global/selectors/sharedState'; import buildClassName from '../../../util/buildClassName'; +import { + buildChatSelectionKey, + type ChatSelectionKey, + includesChatSelectionKey, +} from '../../../util/keys/chatSelectionKey'; import { resolveTransitionName } from '../../../util/resolveTransitionName'; import { REM } from '../helpers/mediaDimensions'; import renderText from '../helpers/renderText'; @@ -60,7 +65,7 @@ export type OwnProps = { renderSearchRow?: (props: SearchRowRenderProps) => TeactNode; footer?: TeactNode; viewportFooter?: TeactNode; - selectedIds?: string[]; + selectedIds?: ChatSelectionKey[]; loadMore?: NoneToVoidFunction; onSearchChange: (search: string) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; @@ -237,10 +242,10 @@ const ChatOrUserPicker = ({ const isForum = chat?.isForum; const isSelf = peer && !isApiPeerChat(peer) ? peer.isSelf : undefined; - const isSelected = selectedIds?.includes(id); + const isSelected = selectedIds && includesChatSelectionKey(selectedIds, buildChatSelectionKey(id)); const selectedTopicsCount = isForum && selectedIds - ? selectedIds.filter((selId) => selId.startsWith(`${id}:`)).length + ? selectedIds.filter((key) => key.peerId === id && key.topicId !== undefined).length : 0; const hasSelectedTopics = selectedTopicsCount > 0; @@ -342,8 +347,8 @@ const ChatOrUserPicker = ({ onKeyDown={handleTopicKeyDown} > {topicIds.map((topicId, i) => { - const selectionId = `${forumId}:${topicId}`; - const isTopicSelected = selectedIds?.includes(selectionId); + const chatSelectionKey = buildChatSelectionKey(forumId!, topicId); + const isTopicSelected = selectedIds && includesChatSelectionKey(selectedIds, chatSelectionKey); const topicCheckboxElement = isMultiSelect ? (
diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index fd2eab6b6..0c03d884c 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -4,7 +4,8 @@ import { } from '../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../global'; -import type { ThreadId } from '../../types'; +import type { ForwardTarget, ThreadId } from '../../types'; +import type { ChatSelectionKey } from '../../util/keys/chatSelectionKey'; import { getChatTitle, getUserFirstOrLastName } from '../../global/helpers'; import { @@ -78,7 +79,7 @@ const ForwardRecipientPicker: FC = ({ const renderingIsStory = usePreviousDeprecated(isStory, true); const [isShown, markIsShown, unmarkIsShown] = useFlag(); - const [selectedIds, setSelectedIds] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); const [caption, setCaption] = useState(''); const [isPaymentConfirmOpen, openPaymentConfirm, closePaymentConfirm] = useFlag(); const [shouldAutoApprove, setShouldAutoApprove] = useState(shouldPaidMessageAutoApprove); @@ -90,20 +91,20 @@ const ForwardRecipientPicker: FC = ({ if (!selectedIds.length) return { paidChatsCount: 0, totalStars: 0, totalMessages: 0 }; const global = getGlobal(); - let paidChatsCount = 0; + const paidChatIds = new Set(); let totalStars = 0; const hasCaption = caption.trim().length > 0; const totalMessages = messageCount + (hasCaption ? 1 : 0); - for (const chatId of selectedIds) { + for (const { peerId: chatId } of selectedIds) { const paidStars = selectPeerPaidMessagesStars(global, chatId); if (paidStars) { - paidChatsCount++; + paidChatIds.add(chatId); totalStars += paidStars * totalMessages; } } - return { paidChatsCount, totalStars, totalMessages }; + return { paidChatsCount: paidChatIds.size, totalStars, totalMessages }; }, [selectedIds, messageCount, caption]); const canCopyLink = useMemo(() => { @@ -179,7 +180,7 @@ const ForwardRecipientPicker: FC = ({ exitForwardMode(); }, [exitForwardMode]); - const handleSelectedIdsChange = useLastCallback((ids: string[]) => { + const handleSelectedIdsChange = useLastCallback((ids: ChatSelectionKey[]) => { setSelectedIds(ids); }); @@ -196,7 +197,8 @@ const ForwardRecipientPicker: FC = ({ if (!selectedIds.length) return; if (selectedIds.length === 1) { - setForwardChatOrTopic({ chatId: selectedIds[0] }); + const { peerId: chatId, topicId } = selectedIds[0]; + setForwardChatOrTopic({ chatId, topicId }); return; } @@ -221,7 +223,11 @@ const ForwardRecipientPicker: FC = ({ }); const executeForward = useLastCallback(() => { - forwardToMultipleChats({ toChatIds: selectedIds, comment: caption || undefined }); + const targets: ForwardTarget[] = selectedIds.map(({ peerId, topicId }) => ({ + chatId: peerId, + topicId, + })); + forwardToMultipleChats({ targets, comment: caption || undefined }); showNotification({ message: lang('FwdMessagesToChats', { count: selectedIds.length }, { pluralValue: selectedIds.length }), diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index ae2462997..45d05e839 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -2620,6 +2620,7 @@ interface ForwardToChatOptions { global: GlobalState; fromChat: ApiChat; toChat: ApiChat; + toThreadId?: ThreadId; realMessages: ApiMessage[]; serviceMessages: ApiMessage[]; comment?: string; @@ -2633,6 +2634,7 @@ function forwardMessagesToChat({ global, fromChat, toChat, + toThreadId = MAIN_THREAD_ID, realMessages, serviceMessages, comment, @@ -2642,12 +2644,21 @@ function forwardMessagesToChat({ isCurrentUserPremium, }: ForwardToChatOptions) { const sendAs = selectSendAs(global, toChat.id); - const lastMessageId = selectChatLastMessageId(global, toChat.id); + const threadInfo = toThreadId !== MAIN_THREAD_ID ? selectThreadInfo(global, toChat.id, toThreadId) : undefined; + const lastMessageId = toThreadId === MAIN_THREAD_ID + ? selectChatLastMessageId(global, toChat.id) + : threadInfo?.lastMessageId; const messagePriceInStars = selectPeerPaidMessagesStars(global, toChat.id); + const targetMessageList = { + chatId: toChat.id, + threadId: toThreadId, + type: 'thread', + } as const; if (comment) { sendMessage(global, { chat: toChat, + messageList: targetMessageList, text: comment, sendAs, lastMessageId, @@ -2664,7 +2675,7 @@ function forwardMessagesToChat({ const forwardParams: ForwardMessagesParams = { fromChat, toChat, - toThreadId: MAIN_THREAD_ID, + toThreadId, messages: slice, isSilent: true, sendAs, @@ -2687,6 +2698,7 @@ function forwardMessagesToChat({ sendMessage(global, { chat: toChat, + messageList: targetMessageList, text, entities, sticker, @@ -2699,7 +2711,7 @@ function forwardMessagesToChat({ } addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionReturnType => { - const { toChatIds, comment, tabId = getCurrentTabId() } = payload; + const { targets, comment, tabId = getCurrentTabId() } = payload; const { fromChatId, messageIds, withMyScore, noAuthors, noCaptions, @@ -2725,14 +2737,15 @@ addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionRet return; } - for (const toChatId of toChatIds) { - const toChat = selectChat(global, toChatId); + for (const { chatId, topicId } of targets) { + const toChat = selectChat(global, chatId); if (!toChat) continue; forwardMessagesToChat({ global, fromChat, toChat, + toThreadId: topicId || MAIN_THREAD_ID, realMessages: forwardableRealMessages, serviceMessages, comment, diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 1f91750c5..d4b5344e2 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -78,6 +78,7 @@ import type { CallSound, ChatListType, ConfettiParams, + ForwardTarget, GiftProfileFilterOptions, GlobalSearchContent, IAnchorPosition, @@ -2024,7 +2025,7 @@ export interface ActionPayloads { exitForwardMode: WithTabId | undefined; changeRecipient: WithTabId | undefined; forwardToMultipleChats: { - toChatIds: string[]; + targets: ForwardTarget[]; comment?: string; } & WithTabId; forwardToSavedMessages: { diff --git a/src/types/index.ts b/src/types/index.ts index 7169bc45c..c81772b6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -105,6 +105,11 @@ export interface IDocumentGroup { export type ThreadId = string | number; +export type ForwardTarget = { + chatId: string; + topicId?: number; +}; + export type ThemeKey = 'light' | 'dark'; export type AnimationLevel = 0 | 1 | 2; export type FoldersPosition = 'top' | 'left'; diff --git a/src/util/keys/chatSelectionKey.ts b/src/util/keys/chatSelectionKey.ts new file mode 100644 index 000000000..1bc4e899b --- /dev/null +++ b/src/util/keys/chatSelectionKey.ts @@ -0,0 +1,24 @@ +export type ChatSelectionKey = { + peerId: string; + topicId?: number; +}; + +export function buildChatSelectionKey(peerId: string, topicId?: number): ChatSelectionKey { + return { peerId, topicId }; +} + +export function areChatSelectionKeysEqual(a: ChatSelectionKey, b: ChatSelectionKey): boolean { + return a.peerId === b.peerId && a.topicId === b.topicId; +} + +export function includesChatSelectionKey(arr: ChatSelectionKey[], key: ChatSelectionKey): boolean { + return arr.some((k) => areChatSelectionKeysEqual(k, key)); +} + +export function findChatSelectionKeyIndex(arr: ChatSelectionKey[], key: ChatSelectionKey): number { + return arr.findIndex((k) => areChatSelectionKeysEqual(k, key)); +} + +export function getChatSelectionKeyHash(key: ChatSelectionKey): string { + return key.topicId !== undefined ? `${key.peerId}:${key.topicId}` : key.peerId; +}