From 8dfdcfaf9d3a5465cfdf69edb6300471280ca140 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:01:04 -0500 Subject: [PATCH] Prevent forward of at-mentions, don't render in 1:1 conversations Co-authored-by: Scott Nonnenberg --- ts/components/ForwardMessagesModal.dom.tsx | 24 +------------------ ts/components/Input.dom.tsx | 22 ++++++++++++++--- ts/state/ducks/conversations.preload.ts | 10 +++++--- ts/state/selectors/message.preload.ts | 24 +++++++++++++++---- ts/state/smart/CompositionArea.preload.tsx | 3 +++ .../smart/ForwardMessagesModal.preload.tsx | 21 +++++++++++++++- 6 files changed, 69 insertions(+), 35 deletions(-) diff --git a/ts/components/ForwardMessagesModal.dom.tsx b/ts/components/ForwardMessagesModal.dom.tsx index 799ac26dc2..cf82bb3251 100644 --- a/ts/components/ForwardMessagesModal.dom.tsx +++ b/ts/components/ForwardMessagesModal.dom.tsx @@ -33,7 +33,6 @@ import { LinkPreviewSourceType } from '../types/LinkPreview.std.ts'; import { ToastType } from '../types/Toast.dom.tsx'; import type { ShowToastAction } from '../state/ducks/toast.preload.ts'; import type { HydratedBodyRangesType } from '../types/BodyRange.std.ts'; -import { applyRangesToText } from '../types/BodyRange.std.ts'; import { UserText } from './UserText.dom.tsx'; import { Modal } from './Modal.dom.tsx'; import { SizeObserver } from '../hooks/useSizeObserver.dom.tsx'; @@ -146,28 +145,7 @@ export function ForwardMessagesModal({ const previews = lonelyLinkPreview?.domain ? [lonelyLinkPreview] : []; doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]); } else { - doForwardMessages( - conversationIds, - drafts.map(draft => { - // We don't keep @mention bodyRanges in multi-forward scenarios - const result = applyRangesToText( - { - body: draft.messageBody ?? '', - bodyRanges: draft.bodyRanges ?? [], - }, - { - replaceMentions: true, - replaceSpoilers: false, - } - ); - - return { - ...draft, - messageBody: result.body, - bodyRanges: result.bodyRanges, - }; - }) - ); + doForwardMessages(conversationIds, drafts); } }, [ drafts, diff --git a/ts/components/Input.dom.tsx b/ts/components/Input.dom.tsx index 253c7bdd8c..1a567bede1 100644 --- a/ts/components/Input.dom.tsx +++ b/ts/components/Input.dom.tsx @@ -16,6 +16,7 @@ import type { LocalizerType } from '../types/Util.std.ts'; import { getClassNamesFor } from '../util/getClassNamesFor.std.ts'; import { useRefMerger } from '../hooks/useRefMerger.std.ts'; import { byteLength } from '../Bytes.std.ts'; +import { truncateString } from '../util/truncateString.std.ts'; export type PropsType = { autoFocus?: boolean; @@ -181,17 +182,32 @@ export const Input = forwardRef< const pastedText = event.clipboardData.getData('Text'); + const pastedLength = countLength(pastedText); const newLengthCount = countLength(textBeforeSelection) + - countLength(pastedText) + + pastedLength + countLength(textAfterSelection); + const pastedBytes = countBytes(pastedText); const newByteCount = countBytes(textBeforeSelection) + - countBytes(pastedText) + + pastedBytes + countBytes(textAfterSelection); - if (newLengthCount > maxLengthCount || newByteCount > maxByteCount) { + const lengthDelta = newLengthCount - maxLengthCount; + const byteDelta = newByteCount - maxByteCount; + if (lengthDelta > 0 || byteDelta > 0) { event.preventDefault(); + + const newPastedLength = pastedLength - lengthDelta; + const newPastedBytes = pastedBytes - byteDelta; + + const truncatedPaste = truncateString(pastedText, { + byteLimit: newPastedBytes, + graphemeLimit: newPastedLength, + }); + + inputEl.value = + textBeforeSelection + truncatedPaste + textAfterSelection; } maybeSetLarge(); diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index cd08a46c21..6e2a1d02d4 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -2099,9 +2099,13 @@ function setMessageToEdit( : undefined; } - const draftBodyRanges = processBodyRanges(message, { - conversationSelector: getConversationSelector(getState()), - }); + const draftBodyRanges = processBodyRanges( + message, + isGroup(conversation.attributes), + { + conversationSelector: getConversationSelector(getState()), + } + ); conversation.set({ draftEditMessage: { body: message.body, diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index cb24f89548..22cb8f6e51 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -56,7 +56,10 @@ import type { import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact.std.ts'; import { embeddedContactSelector } from '../../types/EmbeddedContact.std.ts'; -import type { HydratedBodyRangesType } from '../../types/BodyRange.std.ts'; +import { + BodyRange, + type HydratedBodyRangesType, +} from '../../types/BodyRange.std.ts'; import { hydrateRanges } from '../../util/BodyRange.node.ts'; import type { AssertProps, LocalizerType } from '../../types/Util.std.ts'; import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews.std.ts'; @@ -356,13 +359,20 @@ export const getAttachmentsForMessage = ( export const processBodyRanges = ( { bodyRanges }: Pick, + isGroup: boolean, options: { conversationSelector: GetConversationByIdType } ): HydratedBodyRangesType | undefined => { if (!bodyRanges) { return undefined; } - return hydrateRanges(bodyRanges, options.conversationSelector)?.sort( + let toHydrate = bodyRanges; + + if (!isGroup) { + toHydrate = toHydrate.filter(range => !BodyRange.isMention(range)); + } + + return hydrateRanges(toHydrate, options.conversationSelector)?.sort( (a, b) => b.start - a.start ); }; @@ -702,10 +712,12 @@ export const getPropsForQuote = ( conversationSelector, ourConversationId, defaultConversationColor, + isGroup, }: { conversationSelector: GetConversationByIdType; ourConversationId?: string; defaultConversationColor: DefaultConversationColorType; + isGroup: boolean; } ): PropsData['quote'] => { const { quote } = message; @@ -758,7 +770,7 @@ export const getPropsForQuote = ( authorPhoneNumber, authorProfileName, authorTitle, - bodyRanges: processBodyRanges(quote, { conversationSelector }), + bodyRanges: processBodyRanges(quote, isGroup, { conversationSelector }), conversationColor, conversationTitle: conversation.title, customColor, @@ -869,11 +881,10 @@ export const getPropsForMessage = ( item => item.wasTooBig ); const attachments = getAttachmentsForMessage(message); - const bodyRanges = processBodyRanges(message, options); const author = getAuthorForMessage(message, options); const previews = getPreviewsForMessage(message); const reactions = getReactionsForMessage(message, options); - const quote = getPropsForQuote(message, options); + const storyReplyContext = getPropsForStoryReplyContext(message, options); const textAttachment = getTextAttachment(message); const payment = getPayment(message); @@ -901,6 +912,9 @@ export const getPropsForMessage = ( const conversation = getConversation(message, conversationSelector); const isGroup = conversation.type === 'group'; + const bodyRanges = processBodyRanges(message, isGroup, options); + const quote = getPropsForQuote(message, { ...options, isGroup }); + const isGroupTerminated = isGroup && conversation.terminated; const { sticker } = message; diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx index 70285818a4..e7bb83fc1b 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -81,6 +81,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ }) { const conversationSelector = useSelector(getConversationSelector); const conversation = conversationSelector(id); + const isGroup = conversation.type === 'group'; strictAssert(conversation, `Conversation id ${id} not found!`); const i18n = useSelector(getIntl); @@ -189,6 +190,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ conversationSelector, ourConversationId, defaultConversationColor, + isGroup, }) : undefined; }, [ @@ -196,6 +198,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ conversationSelector, ourConversationId, defaultConversationColor, + isGroup, ]); const { diff --git a/ts/state/smart/ForwardMessagesModal.preload.tsx b/ts/state/smart/ForwardMessagesModal.preload.tsx index 1a04503996..fe74eef7ed 100644 --- a/ts/state/smart/ForwardMessagesModal.preload.tsx +++ b/ts/state/smart/ForwardMessagesModal.preload.tsx @@ -30,6 +30,7 @@ import type { MessageForwardDraft, } from '../../types/ForwardDraft.std.ts'; import { getForwardMessagesProps } from '../selectors/globalModals.std.ts'; +import { applyRangesToText } from '../../types/BodyRange.std.ts'; const log = createLogger('ForwardMessagesModal'); @@ -76,7 +77,25 @@ function SmartForwardMessagesModalInner({ const [drafts, setDrafts] = useState>( () => { - return forwardMessagesProps.messageDrafts; + return forwardMessagesProps.messageDrafts.map(draft => { + // We don't keep @mention bodyRanges when forwarding, so we turn them to text + const result = applyRangesToText( + { + body: draft.messageBody ?? '', + bodyRanges: draft.bodyRanges ?? [], + }, + { + replaceMentions: true, + replaceSpoilers: false, + } + ); + + return { + ...draft, + messageBody: result.body, + bodyRanges: result.bodyRanges, + }; + }); } );