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 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<string[]>([]);
const [removingIds, setRemovingIds] = useState<string[]>([]);
const [appearingIds, setAppearingIds] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<ChatSelectionKey[]>([]);
const [removingIds, setRemovingIds] = useState<ChatSelectionKey[]>([]);
const [appearingIds, setAppearingIds] = useState<ChatSelectionKey[]>([]);
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 (
<div className="search-row-with-chips">
<div className="chips-and-search-scroll no-scrollbar">
{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 (
<div
key={selectionId}
key={keyHash}
className={buildClassName(
'picker-chip-wrapper',
isAppearing && 'picker-chip-appear',
@ -332,7 +329,7 @@ const RecipientPicker = ({
canClose
className="picker-chip"
itemClassName="picker-chip-name"
clickArg={selectionId}
clickArg={selectionKey}
onClick={handleRemoveSelected}
/>
</div>
@ -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 = ({
<PickerRecentContacts
contactIds={recentContactIds}
currentUserId={currentUserId}
selectedIds={isMultiSelect ? selectedIds : undefined}
selectedIds={isMultiSelect ? selectedPeerIds : undefined}
className={styles.recentContacts}
onSelect={handleSelect}
/>
@ -389,7 +388,7 @@ const RecipientPicker = ({
currentUserId,
handleSelect,
isMultiSelect,
selectedIds,
selectedPeerIds,
folderTabs,
activeFolderIndex,
handleSwitchFolderIndex,

View File

@ -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 ? (
<div className={buildClassName('picker-checkbox', isTopicSelected && 'selected')}>

View File

@ -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<OwnProps & StateProps> = ({
const renderingIsStory = usePreviousDeprecated(isStory, true);
const [isShown, markIsShown, unmarkIsShown] = useFlag();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<ChatSelectionKey[]>([]);
const [caption, setCaption] = useState('');
const [isPaymentConfirmOpen, openPaymentConfirm, closePaymentConfirm] = useFlag();
const [shouldAutoApprove, setShouldAutoApprove] = useState(shouldPaidMessageAutoApprove);
@ -90,20 +91,20 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
if (!selectedIds.length) return { paidChatsCount: 0, totalStars: 0, totalMessages: 0 };
const global = getGlobal();
let paidChatsCount = 0;
const paidChatIds = new Set<string>();
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<OwnProps & StateProps> = ({
exitForwardMode();
}, [exitForwardMode]);
const handleSelectedIdsChange = useLastCallback((ids: string[]) => {
const handleSelectedIdsChange = useLastCallback((ids: ChatSelectionKey[]) => {
setSelectedIds(ids);
});
@ -196,7 +197,8 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
});
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 }),

View File

@ -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,

View File

@ -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: {

View File

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

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