Forward: Fix forward to forum topics (#6842)

Co-authored-by: Dmitry Kabanov <153344039+dmitrykabanovdev@users.noreply.github.com>
Co-authored-by: Dmitry Kabanov <dmitrykabanovdev@gmail.com>
This commit is contained in:
Alexander Zinchuk 2026-04-14 14:36:45 +02:00
parent 7b26965b45
commit d7e3456550
7 changed files with 113 additions and 60 deletions

View File

@ -22,6 +22,13 @@ import {
import { selectCurrentLimit } from '../../global/selectors/limits'; import { selectCurrentLimit } from '../../global/selectors/limits';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { unique } from '../../util/iteratees'; import { unique } from '../../util/iteratees';
import {
areChatSelectionKeysEqual,
buildChatSelectionKey,
type ChatSelectionKey,
getChatSelectionKeyHash,
includesChatSelectionKey,
} from '../../util/keys/chatSelectionKey';
import sortChatIds from './helpers/sortChatIds'; import sortChatIds from './helpers/sortChatIds';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
@ -52,7 +59,7 @@ export type OwnProps = {
viewportFooter?: TeactNode; viewportFooter?: TeactNode;
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; onSelectRecipient: (peerId: string, threadId?: ThreadId) => void;
onSelectedIdsChange?: (ids: string[]) => void; onSelectedIdsChange?: (ids: ChatSelectionKey[]) => void;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction;
}; };
@ -100,9 +107,9 @@ const RecipientPicker = ({
const lang = useLang(); const lang = useLang();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<ChatSelectionKey[]>([]);
const [removingIds, setRemovingIds] = useState<string[]>([]); const [removingIds, setRemovingIds] = useState<ChatSelectionKey[]>([]);
const [appearingIds, setAppearingIds] = useState<string[]>([]); const [appearingIds, setAppearingIds] = useState<ChatSelectionKey[]>([]);
const selectedIdsRef = useStateRef(selectedIds); const selectedIdsRef = useStateRef(selectedIds);
const removingIdsRef = useStateRef(removingIds); const removingIdsRef = useStateRef(removingIds);
const appearingIdsRef = useStateRef(appearingIds); const appearingIdsRef = useStateRef(appearingIds);
@ -134,46 +141,46 @@ const RecipientPicker = ({
setActiveFolderIndex(index); setActiveFolderIndex(index);
}); });
const updateSelectedIds = useLastCallback((newIds: string[], newlyAddedId?: string) => { const updateSelectedIds = useLastCallback((newIds: ChatSelectionKey[], newlyAddedKey?: ChatSelectionKey) => {
setSelectedIds(newIds); setSelectedIds(newIds);
onSelectedIdsChange?.(newIds); onSelectedIdsChange?.(newIds);
if (newlyAddedId && selectCanAnimateInterface(getGlobal())) { if (newlyAddedKey && selectCanAnimateInterface(getGlobal())) {
setAppearingIds([...appearingIdsRef.current, newlyAddedId]); setAppearingIds([...appearingIdsRef.current, newlyAddedKey]);
setTimeout(() => { setTimeout(() => {
setAppearingIds(appearingIdsRef.current.filter((id) => id !== newlyAddedId)); setAppearingIds(appearingIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, newlyAddedKey)));
}, 200); }, 200);
} }
}); });
const handleRemoveSelected = useLastCallback((selectionId: string) => { const handleRemoveSelected = useLastCallback((selectionKey: ChatSelectionKey) => {
if (removingIdsRef.current.includes(selectionId)) return; if (includesChatSelectionKey(removingIdsRef.current, selectionKey)) return;
const canAnimate = selectCanAnimateInterface(getGlobal()); const canAnimate = selectCanAnimateInterface(getGlobal());
if (!canAnimate) { if (!canAnimate) {
const newIds = selectedIdsRef.current.filter((id) => id !== selectionId); const newIds = selectedIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey));
setSelectedIds(newIds); setSelectedIds(newIds);
onSelectedIdsChange?.(newIds); onSelectedIdsChange?.(newIds);
return; return;
} }
setRemovingIds([...removingIdsRef.current, selectionId]); setRemovingIds([...removingIdsRef.current, selectionKey]);
setTimeout(() => { setTimeout(() => {
setRemovingIds(removingIdsRef.current.filter((id) => id !== selectionId)); setRemovingIds(removingIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey)));
const newIds = selectedIdsRef.current.filter((id) => id !== selectionId); const newIds = selectedIdsRef.current.filter((key) => !areChatSelectionKeysEqual(key, selectionKey));
setSelectedIds(newIds); setSelectedIds(newIds);
onSelectedIdsChange?.(newIds); onSelectedIdsChange?.(newIds);
}, 300); }, 300);
}); });
const handleToggleSelection = useLastCallback((peerId: string, threadId?: ThreadId) => { 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)) { if (includesChatSelectionKey(selectedIds, selectionKey)) {
handleRemoveSelected(selectionId); handleRemoveSelected(selectionKey);
} else { } else {
updateSelectedIds([...selectedIds, selectionId], selectionId); updateSelectedIds([...selectedIds, selectionKey], selectionKey);
} }
}); });
@ -262,19 +269,8 @@ const RecipientPicker = ({
const hasSelectedChips = isMultiSelect && selectedIds.length > 0; const hasSelectedChips = isMultiSelect && selectedIds.length > 0;
const parseSelectionId = useLastCallback((selectionId: string): { peerId: string; topicId?: number } => { const getChipTitle = useLastCallback((selectionKey: ChatSelectionKey): string | undefined => {
const colonIndex = selectionId.indexOf(':'); const { peerId, topicId } = selectionKey;
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);
if (!topicId) return undefined; if (!topicId) return undefined;
const global = getGlobal(); const global = getGlobal();
@ -309,15 +305,16 @@ const RecipientPicker = ({
return ( return (
<div className="search-row-with-chips"> <div className="search-row-with-chips">
<div className="chips-and-search-scroll no-scrollbar"> <div className="chips-and-search-scroll no-scrollbar">
{selectedIds.map((selectionId) => { {selectedIds.map((selectionKey) => {
const { peerId } = parseSelectionId(selectionId); const { peerId } = selectionKey;
const chipTitle = getChipTitle(selectionId); const chipTitle = getChipTitle(selectionKey);
const isAppearing = appearingIds.includes(selectionId); const isAppearing = includesChatSelectionKey(appearingIds, selectionKey);
const isRemoving = removingIds.includes(selectionId); const isRemoving = includesChatSelectionKey(removingIds, selectionKey);
const keyHash = getChatSelectionKeyHash(selectionKey);
return ( return (
<div <div
key={selectionId} key={keyHash}
className={buildClassName( className={buildClassName(
'picker-chip-wrapper', 'picker-chip-wrapper',
isAppearing && 'picker-chip-appear', isAppearing && 'picker-chip-appear',
@ -332,7 +329,7 @@ const RecipientPicker = ({
canClose canClose
className="picker-chip" className="picker-chip"
itemClassName="picker-chip-name" itemClassName="picker-chip-name"
clickArg={selectionId} clickArg={selectionKey}
onClick={handleRemoveSelected} onClick={handleRemoveSelected}
/> />
</div> </div>
@ -356,6 +353,8 @@ const RecipientPicker = ({
); );
}, [hasSelectedChips, selectedIds, appearingIds, removingIds]); }, [hasSelectedChips, selectedIds, appearingIds, removingIds]);
const selectedPeerIds = useMemo(() => selectedIds.map((key) => key.peerId), [selectedIds]);
const subheaderContent = useMemo(() => { const subheaderContent = useMemo(() => {
const hasRecentContacts = recentContactIds.length > 0 && !search; const hasRecentContacts = recentContactIds.length > 0 && !search;
const hasFolderTabs = shouldRenderFolders; const hasFolderTabs = shouldRenderFolders;
@ -368,7 +367,7 @@ const RecipientPicker = ({
<PickerRecentContacts <PickerRecentContacts
contactIds={recentContactIds} contactIds={recentContactIds}
currentUserId={currentUserId} currentUserId={currentUserId}
selectedIds={isMultiSelect ? selectedIds : undefined} selectedIds={isMultiSelect ? selectedPeerIds : undefined}
className={styles.recentContacts} className={styles.recentContacts}
onSelect={handleSelect} onSelect={handleSelect}
/> />
@ -389,7 +388,7 @@ const RecipientPicker = ({
currentUserId, currentUserId,
handleSelect, handleSelect,
isMultiSelect, isMultiSelect,
selectedIds, selectedPeerIds,
folderTabs, folderTabs,
activeFolderIndex, activeFolderIndex,
handleSwitchFolderIndex, handleSwitchFolderIndex,

View File

@ -22,6 +22,11 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import { selectAnimationLevel } from '../../../global/selectors/sharedState'; import { selectAnimationLevel } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import {
buildChatSelectionKey,
type ChatSelectionKey,
includesChatSelectionKey,
} from '../../../util/keys/chatSelectionKey';
import { resolveTransitionName } from '../../../util/resolveTransitionName'; import { resolveTransitionName } from '../../../util/resolveTransitionName';
import { REM } from '../helpers/mediaDimensions'; import { REM } from '../helpers/mediaDimensions';
import renderText from '../helpers/renderText'; import renderText from '../helpers/renderText';
@ -60,7 +65,7 @@ export type OwnProps = {
renderSearchRow?: (props: SearchRowRenderProps) => TeactNode; renderSearchRow?: (props: SearchRowRenderProps) => TeactNode;
footer?: TeactNode; footer?: TeactNode;
viewportFooter?: TeactNode; viewportFooter?: TeactNode;
selectedIds?: string[]; selectedIds?: ChatSelectionKey[];
loadMore?: NoneToVoidFunction; loadMore?: NoneToVoidFunction;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void;
@ -237,10 +242,10 @@ const ChatOrUserPicker = ({
const isForum = chat?.isForum; const isForum = chat?.isForum;
const isSelf = peer && !isApiPeerChat(peer) ? peer.isSelf : undefined; const isSelf = peer && !isApiPeerChat(peer) ? peer.isSelf : undefined;
const isSelected = selectedIds?.includes(id); const isSelected = selectedIds && includesChatSelectionKey(selectedIds, buildChatSelectionKey(id));
const selectedTopicsCount = isForum && selectedIds const selectedTopicsCount = isForum && selectedIds
? selectedIds.filter((selId) => selId.startsWith(`${id}:`)).length ? selectedIds.filter((key) => key.peerId === id && key.topicId !== undefined).length
: 0; : 0;
const hasSelectedTopics = selectedTopicsCount > 0; const hasSelectedTopics = selectedTopicsCount > 0;
@ -342,8 +347,8 @@ const ChatOrUserPicker = ({
onKeyDown={handleTopicKeyDown} onKeyDown={handleTopicKeyDown}
> >
{topicIds.map((topicId, i) => { {topicIds.map((topicId, i) => {
const selectionId = `${forumId}:${topicId}`; const chatSelectionKey = buildChatSelectionKey(forumId!, topicId);
const isTopicSelected = selectedIds?.includes(selectionId); const isTopicSelected = selectedIds && includesChatSelectionKey(selectedIds, chatSelectionKey);
const topicCheckboxElement = isMultiSelect ? ( const topicCheckboxElement = isMultiSelect ? (
<div className={buildClassName('picker-checkbox', isTopicSelected && 'selected')}> <div className={buildClassName('picker-checkbox', isTopicSelected && 'selected')}>

View File

@ -4,7 +4,8 @@ import {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; 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 { getChatTitle, getUserFirstOrLastName } from '../../global/helpers';
import { import {
@ -78,7 +79,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
const renderingIsStory = usePreviousDeprecated(isStory, true); const renderingIsStory = usePreviousDeprecated(isStory, true);
const [isShown, markIsShown, unmarkIsShown] = useFlag(); const [isShown, markIsShown, unmarkIsShown] = useFlag();
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<ChatSelectionKey[]>([]);
const [caption, setCaption] = useState(''); const [caption, setCaption] = useState('');
const [isPaymentConfirmOpen, openPaymentConfirm, closePaymentConfirm] = useFlag(); const [isPaymentConfirmOpen, openPaymentConfirm, closePaymentConfirm] = useFlag();
const [shouldAutoApprove, setShouldAutoApprove] = useState(shouldPaidMessageAutoApprove); const [shouldAutoApprove, setShouldAutoApprove] = useState(shouldPaidMessageAutoApprove);
@ -90,20 +91,20 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (!selectedIds.length) return { paidChatsCount: 0, totalStars: 0, totalMessages: 0 }; if (!selectedIds.length) return { paidChatsCount: 0, totalStars: 0, totalMessages: 0 };
const global = getGlobal(); const global = getGlobal();
let paidChatsCount = 0; const paidChatIds = new Set<string>();
let totalStars = 0; let totalStars = 0;
const hasCaption = caption.trim().length > 0; const hasCaption = caption.trim().length > 0;
const totalMessages = messageCount + (hasCaption ? 1 : 0); const totalMessages = messageCount + (hasCaption ? 1 : 0);
for (const chatId of selectedIds) { for (const { peerId: chatId } of selectedIds) {
const paidStars = selectPeerPaidMessagesStars(global, chatId); const paidStars = selectPeerPaidMessagesStars(global, chatId);
if (paidStars) { if (paidStars) {
paidChatsCount++; paidChatIds.add(chatId);
totalStars += paidStars * totalMessages; totalStars += paidStars * totalMessages;
} }
} }
return { paidChatsCount, totalStars, totalMessages }; return { paidChatsCount: paidChatIds.size, totalStars, totalMessages };
}, [selectedIds, messageCount, caption]); }, [selectedIds, messageCount, caption]);
const canCopyLink = useMemo(() => { const canCopyLink = useMemo(() => {
@ -179,7 +180,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
exitForwardMode(); exitForwardMode();
}, [exitForwardMode]); }, [exitForwardMode]);
const handleSelectedIdsChange = useLastCallback((ids: string[]) => { const handleSelectedIdsChange = useLastCallback((ids: ChatSelectionKey[]) => {
setSelectedIds(ids); setSelectedIds(ids);
}); });
@ -196,7 +197,8 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (!selectedIds.length) return; if (!selectedIds.length) return;
if (selectedIds.length === 1) { if (selectedIds.length === 1) {
setForwardChatOrTopic({ chatId: selectedIds[0] }); const { peerId: chatId, topicId } = selectedIds[0];
setForwardChatOrTopic({ chatId, topicId });
return; return;
} }
@ -221,7 +223,11 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
}); });
const executeForward = useLastCallback(() => { 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({ showNotification({
message: lang('FwdMessagesToChats', { count: selectedIds.length }, { pluralValue: selectedIds.length }), message: lang('FwdMessagesToChats', { count: selectedIds.length }, { pluralValue: selectedIds.length }),

View File

@ -2620,6 +2620,7 @@ interface ForwardToChatOptions {
global: GlobalState; global: GlobalState;
fromChat: ApiChat; fromChat: ApiChat;
toChat: ApiChat; toChat: ApiChat;
toThreadId?: ThreadId;
realMessages: ApiMessage[]; realMessages: ApiMessage[];
serviceMessages: ApiMessage[]; serviceMessages: ApiMessage[];
comment?: string; comment?: string;
@ -2633,6 +2634,7 @@ function forwardMessagesToChat({
global, global,
fromChat, fromChat,
toChat, toChat,
toThreadId = MAIN_THREAD_ID,
realMessages, realMessages,
serviceMessages, serviceMessages,
comment, comment,
@ -2642,12 +2644,21 @@ function forwardMessagesToChat({
isCurrentUserPremium, isCurrentUserPremium,
}: ForwardToChatOptions) { }: ForwardToChatOptions) {
const sendAs = selectSendAs(global, toChat.id); 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 messagePriceInStars = selectPeerPaidMessagesStars(global, toChat.id);
const targetMessageList = {
chatId: toChat.id,
threadId: toThreadId,
type: 'thread',
} as const;
if (comment) { if (comment) {
sendMessage(global, { sendMessage(global, {
chat: toChat, chat: toChat,
messageList: targetMessageList,
text: comment, text: comment,
sendAs, sendAs,
lastMessageId, lastMessageId,
@ -2664,7 +2675,7 @@ function forwardMessagesToChat({
const forwardParams: ForwardMessagesParams = { const forwardParams: ForwardMessagesParams = {
fromChat, fromChat,
toChat, toChat,
toThreadId: MAIN_THREAD_ID, toThreadId,
messages: slice, messages: slice,
isSilent: true, isSilent: true,
sendAs, sendAs,
@ -2687,6 +2698,7 @@ function forwardMessagesToChat({
sendMessage(global, { sendMessage(global, {
chat: toChat, chat: toChat,
messageList: targetMessageList,
text, text,
entities, entities,
sticker, sticker,
@ -2699,7 +2711,7 @@ function forwardMessagesToChat({
} }
addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionReturnType => { addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionReturnType => {
const { toChatIds, comment, tabId = getCurrentTabId() } = payload; const { targets, comment, tabId = getCurrentTabId() } = payload;
const { const {
fromChatId, messageIds, withMyScore, noAuthors, noCaptions, fromChatId, messageIds, withMyScore, noAuthors, noCaptions,
@ -2725,14 +2737,15 @@ addActionHandler('forwardToMultipleChats', (global, actions, payload): ActionRet
return; return;
} }
for (const toChatId of toChatIds) { for (const { chatId, topicId } of targets) {
const toChat = selectChat(global, toChatId); const toChat = selectChat(global, chatId);
if (!toChat) continue; if (!toChat) continue;
forwardMessagesToChat({ forwardMessagesToChat({
global, global,
fromChat, fromChat,
toChat, toChat,
toThreadId: topicId || MAIN_THREAD_ID,
realMessages: forwardableRealMessages, realMessages: forwardableRealMessages,
serviceMessages, serviceMessages,
comment, comment,

View File

@ -78,6 +78,7 @@ import type {
CallSound, CallSound,
ChatListType, ChatListType,
ConfettiParams, ConfettiParams,
ForwardTarget,
GiftProfileFilterOptions, GiftProfileFilterOptions,
GlobalSearchContent, GlobalSearchContent,
IAnchorPosition, IAnchorPosition,
@ -2024,7 +2025,7 @@ export interface ActionPayloads {
exitForwardMode: WithTabId | undefined; exitForwardMode: WithTabId | undefined;
changeRecipient: WithTabId | undefined; changeRecipient: WithTabId | undefined;
forwardToMultipleChats: { forwardToMultipleChats: {
toChatIds: string[]; targets: ForwardTarget[];
comment?: string; comment?: string;
} & WithTabId; } & WithTabId;
forwardToSavedMessages: { forwardToSavedMessages: {

View File

@ -105,6 +105,11 @@ export interface IDocumentGroup {
export type ThreadId = string | number; export type ThreadId = string | number;
export type ForwardTarget = {
chatId: string;
topicId?: number;
};
export type ThemeKey = 'light' | 'dark'; export type ThemeKey = 'light' | 'dark';
export type AnimationLevel = 0 | 1 | 2; export type AnimationLevel = 0 | 1 | 2;
export type FoldersPosition = 'top' | 'left'; export type FoldersPosition = 'top' | 'left';

View File

@ -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;
}