Ensure consistency in forwarding logic

This commit is contained in:
trevor-signal 2025-05-27 16:59:50 -04:00 committed by GitHub
parent 38666fe0a4
commit 15263c2d16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 72 additions and 9 deletions

View file

@ -199,6 +199,7 @@ export type OwnProps = Readonly<{
props: SmartCompositionRecordingDraftProps props: SmartCompositionRecordingDraftProps
) => JSX.Element | null; ) => JSX.Element | null;
selectedMessageIds: ReadonlyArray<string> | undefined; selectedMessageIds: ReadonlyArray<string> | undefined;
areSelectedMessagesForwardable: boolean | undefined;
toggleSelectMode: (on: boolean) => void; toggleSelectMode: (on: boolean) => void;
toggleForwardMessagesModal: ( toggleForwardMessagesModal: (
payload: ForwardMessagesPayload, payload: ForwardMessagesPayload,
@ -367,6 +368,7 @@ export const CompositionArea = memo(function CompositionArea({
renderSmartCompositionRecordingDraft, renderSmartCompositionRecordingDraft,
// Selected messages // Selected messages
selectedMessageIds, selectedMessageIds,
areSelectedMessagesForwardable,
toggleSelectMode, toggleSelectMode,
toggleForwardMessagesModal, toggleForwardMessagesModal,
// DraftGifMessageSendModal // DraftGifMessageSendModal
@ -906,6 +908,7 @@ export const CompositionArea = memo(function CompositionArea({
<SelectModeActions <SelectModeActions
i18n={i18n} i18n={i18n}
selectedMessageIds={selectedMessageIds} selectedMessageIds={selectedMessageIds}
areSelectedMessagesForwardable={areSelectedMessagesForwardable === true}
onExitSelectMode={() => { onExitSelectMode={() => {
toggleSelectMode(false); toggleSelectMode(false);
}} }}

View file

@ -74,6 +74,7 @@ const defaultMessageProps: TimelineMessagesProps = {
}), }),
canCopy: true, canCopy: true,
canEditMessage: true, canEditMessage: true,
canForward: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,

View file

@ -12,6 +12,7 @@ const MAX_FORWARD_COUNT = 30;
type SelectModeActionsProps = Readonly<{ type SelectModeActionsProps = Readonly<{
selectedMessageIds: ReadonlyArray<string>; selectedMessageIds: ReadonlyArray<string>;
areSelectedMessagesForwardable: boolean;
onExitSelectMode: () => void; onExitSelectMode: () => void;
onDeleteMessages: () => void; onDeleteMessages: () => void;
onForwardMessages: () => void; onForwardMessages: () => void;
@ -21,6 +22,7 @@ type SelectModeActionsProps = Readonly<{
export default function SelectModeActions({ export default function SelectModeActions({
selectedMessageIds, selectedMessageIds,
areSelectedMessagesForwardable,
onExitSelectMode, onExitSelectMode,
onDeleteMessages, onDeleteMessages,
onForwardMessages, onForwardMessages,
@ -31,7 +33,10 @@ export default function SelectModeActions({
const tooManyMessagesToForward = const tooManyMessagesToForward =
selectedMessageIds.length > MAX_FORWARD_COUNT; selectedMessageIds.length > MAX_FORWARD_COUNT;
const canForward = hasSelectedMessages && !tooManyMessagesToForward; const canForward =
hasSelectedMessages &&
areSelectedMessagesForwardable &&
!tooManyMessagesToForward;
const canDelete = hasSelectedMessages; const canDelete = hasSelectedMessages;
return ( return (

View file

@ -51,6 +51,7 @@ function mockMessageTimelineItem(
canDeleteForEveryone: false, canDeleteForEveryone: false,
canDownload: true, canDownload: true,
canEditMessage: true, canEditMessage: true,
canForward: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,

View file

@ -255,6 +255,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canReply: true, canReply: true,
canDownload: true, canDownload: true,
canDeleteForEveryone: overrideProps.canDeleteForEveryone || false, canDeleteForEveryone: overrideProps.canDeleteForEveryone || false,
canForward: true,
canRetry: overrideProps.canRetry || false, canRetry: overrideProps.canRetry || false,
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false, canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
@ -266,7 +267,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: overrideProps.conversationId ?? '', conversationId: overrideProps.conversationId ?? '',
conversationType: overrideProps.conversationType || 'direct', conversationType: overrideProps.conversationType || 'direct',
contact: overrideProps.contact, contact: overrideProps.contact,
deletedForEveryone: overrideProps.deletedForEveryone,
// disableMenu: overrideProps.disableMenu, // disableMenu: overrideProps.disableMenu,
disableScroll: overrideProps.disableScroll, disableScroll: overrideProps.disableScroll,
direction: overrideProps.direction || 'incoming', direction: overrideProps.direction || 'incoming',
@ -796,11 +796,13 @@ export function Deleted(): JSX.Element {
const propsSent = createProps({ const propsSent = createProps({
conversationType: 'direct', conversationType: 'direct',
deletedForEveryone: true, deletedForEveryone: true,
canForward: false,
status: 'sent', status: 'sent',
}); });
const propsSending = createProps({ const propsSending = createProps({
conversationType: 'direct', conversationType: 'direct',
deletedForEveryone: true, deletedForEveryone: true,
canForward: false,
status: 'sending', status: 'sending',
}); });
@ -817,6 +819,7 @@ DeletedWithExpireTimer.args = {
timestamp: Date.now() - 60 * 1000, timestamp: Date.now() - 60 * 1000,
conversationType: 'group', conversationType: 'group',
deletedForEveryone: true, deletedForEveryone: true,
canForward: false,
expirationLength: 5 * 60 * 1000, expirationLength: 5 * 60 * 1000,
expirationTimestamp: Date.now() + 3 * 60 * 1000, expirationTimestamp: Date.now() + 3 * 60 * 1000,
status: 'sent', status: 'sent',
@ -2265,6 +2268,7 @@ const fullContact = {
export const EmbeddedContactFullContact = Template.bind({}); export const EmbeddedContactFullContact = Template.bind({});
EmbeddedContactFullContact.args = { EmbeddedContactFullContact.args = {
contact: fullContact, contact: fullContact,
canForward: false,
}; };
export const EmbeddedContactAvatarUndownloaded = Template.bind({}); export const EmbeddedContactAvatarUndownloaded = Template.bind({});
@ -2279,6 +2283,7 @@ EmbeddedContactAvatarUndownloaded.args = {
isProfile: true, isProfile: true,
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactAvatarDownloading = Template.bind({}); export const EmbeddedContactAvatarDownloading = Template.bind({});
EmbeddedContactAvatarDownloading.args = { EmbeddedContactAvatarDownloading.args = {
@ -2295,6 +2300,7 @@ EmbeddedContactAvatarDownloading.args = {
isProfile: true, isProfile: true,
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactAvatarTransientError = Template.bind({}); export const EmbeddedContactAvatarTransientError = Template.bind({});
EmbeddedContactAvatarTransientError.args = { EmbeddedContactAvatarTransientError.args = {
@ -2316,6 +2322,7 @@ EmbeddedContactAvatarTransientError.args = {
isProfile: true, isProfile: true,
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactAvatarPermanentError = Template.bind({}); export const EmbeddedContactAvatarPermanentError = Template.bind({});
EmbeddedContactAvatarPermanentError.args = { EmbeddedContactAvatarPermanentError.args = {
@ -2335,6 +2342,7 @@ EmbeddedContactAvatarPermanentError.args = {
isProfile: true, isProfile: true,
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactWithSendMessage = Template.bind({}); export const EmbeddedContactWithSendMessage = Template.bind({});
@ -2345,6 +2353,7 @@ EmbeddedContactWithSendMessage.args = {
serviceId: generateAci(), serviceId: generateAci(),
}, },
direction: 'incoming', direction: 'incoming',
canForward: false,
}; };
export const EmbeddedContactOnlyEmail = Template.bind({}); export const EmbeddedContactOnlyEmail = Template.bind({});
@ -2352,6 +2361,7 @@ EmbeddedContactOnlyEmail.args = {
contact: { contact: {
email: fullContact.email, email: fullContact.email,
}, },
canForward: false,
}; };
export const EmbeddedContactGivenName = Template.bind({}); export const EmbeddedContactGivenName = Template.bind({});
@ -2361,6 +2371,7 @@ EmbeddedContactGivenName.args = {
givenName: 'Jerry', givenName: 'Jerry',
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactOrganization = Template.bind({}); export const EmbeddedContactOrganization = Template.bind({});
@ -2368,6 +2379,7 @@ EmbeddedContactOrganization.args = {
contact: { contact: {
organization: 'Company 5', organization: 'Company 5',
}, },
canForward: false,
}; };
export const EmbeddedContactGivenFamilyName = Template.bind({}); export const EmbeddedContactGivenFamilyName = Template.bind({});
@ -2378,6 +2390,7 @@ EmbeddedContactGivenFamilyName.args = {
familyName: 'FamilyName', familyName: 'FamilyName',
}, },
}, },
canForward: false,
}; };
export const EmbeddedContactFamilyName = Template.bind({}); export const EmbeddedContactFamilyName = Template.bind({});
@ -2387,6 +2400,7 @@ EmbeddedContactFamilyName.args = {
familyName: 'FamilyName', familyName: 'FamilyName',
}, },
}, },
canForward: false,
}; };
export const GiftBadgeUnopened = Template.bind({}); export const GiftBadgeUnopened = Template.bind({});
@ -2397,6 +2411,7 @@ GiftBadgeUnopened.args = {
level: 3, level: 3,
state: GiftBadgeStates.Unopened, state: GiftBadgeStates.Unopened,
}, },
canForward: false,
}; };
export const GiftBadgeFailed = Template.bind({}); export const GiftBadgeFailed = Template.bind({});
@ -2404,6 +2419,7 @@ GiftBadgeFailed.args = {
giftBadge: { giftBadge: {
state: GiftBadgeStates.Failed, state: GiftBadgeStates.Failed,
}, },
canForward: false,
}; };
const getPreferredBadge = () => ({ const getPreferredBadge = () => ({
@ -2430,6 +2446,7 @@ GiftBadgeRedeemed30Days.args = {
level: 3, level: 3,
state: GiftBadgeStates.Redeemed, state: GiftBadgeStates.Redeemed,
}, },
canForward: false,
}; };
export const GiftBadgeRedeemed24Hours = Template.bind({}); export const GiftBadgeRedeemed24Hours = Template.bind({});
@ -2441,6 +2458,7 @@ GiftBadgeRedeemed24Hours.args = {
level: 3, level: 3,
state: GiftBadgeStates.Redeemed, state: GiftBadgeStates.Redeemed,
}, },
canForward: false,
}; };
export const GiftBadgeOpened60Minutes = Template.bind({}); export const GiftBadgeOpened60Minutes = Template.bind({});
@ -2452,6 +2470,7 @@ GiftBadgeOpened60Minutes.args = {
level: 3, level: 3,
state: GiftBadgeStates.Opened, state: GiftBadgeStates.Opened,
}, },
canForward: false,
}; };
export const GiftBadgeRedeemed1Minute = Template.bind({}); export const GiftBadgeRedeemed1Minute = Template.bind({});
@ -2463,6 +2482,7 @@ GiftBadgeRedeemed1Minute.args = {
level: 3, level: 3,
state: GiftBadgeStates.Redeemed, state: GiftBadgeStates.Redeemed,
}, },
canForward: false,
}; };
export const GiftBadgeOpenedExpired = Template.bind({}); export const GiftBadgeOpenedExpired = Template.bind({});
@ -2474,6 +2494,7 @@ GiftBadgeOpenedExpired.args = {
level: 3, level: 3,
state: GiftBadgeStates.Opened, state: GiftBadgeStates.Opened,
}, },
canForward: false,
}; };
export const GiftBadgeMissingBadge = Template.bind({}); export const GiftBadgeMissingBadge = Template.bind({});
@ -2485,12 +2506,14 @@ GiftBadgeMissingBadge.args = {
level: 3, level: 3,
state: GiftBadgeStates.Redeemed, state: GiftBadgeStates.Redeemed,
}, },
canForward: false,
}; };
export const PaymentNotification = Template.bind({}); export const PaymentNotification = Template.bind({});
PaymentNotification.args = { PaymentNotification.args = {
canReply: false, canReply: false,
canReact: false, canReact: false,
canForward: false,
payment: { payment: {
kind: PaymentEventKind.Notification, kind: PaymentEventKind.Notification,
note: 'Hello there', note: 'Hello there',

View file

@ -48,6 +48,7 @@ export type PropsData = {
canDownload: boolean; canDownload: boolean;
canCopy: boolean; canCopy: boolean;
canEditMessage: boolean; canEditMessage: boolean;
canForward: boolean;
canRetry: boolean; canRetry: boolean;
canRetryDeleteForEveryone: boolean; canRetryDeleteForEveryone: boolean;
canReact: boolean; canReact: boolean;
@ -96,6 +97,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canDownload, canDownload,
canCopy, canCopy,
canEditMessage, canEditMessage,
canForward,
canReact, canReact,
canReply, canReply,
canRetry, canRetry,
@ -103,15 +105,11 @@ export function TimelineMessage(props: Props): JSX.Element {
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
deletedForEveryone,
direction, direction,
giftBadge,
i18n, i18n,
id, id,
isTargeted, isTargeted,
isTapToView,
kickOffAttachmentDownload, kickOffAttachmentDownload,
payment,
copyMessageText, copyMessageText,
pushPanelForConversation, pushPanelForConversation,
reactToMessage, reactToMessage,
@ -255,8 +253,6 @@ export function TimelineMessage(props: Props): JSX.Element {
); );
const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef); const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef);
const canForward =
!isTapToView && !deletedForEveryone && !giftBadge && !payment;
const shouldShowAdditional = const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow; doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;

View file

@ -754,6 +754,7 @@ export const getPropsForMessage = (
canEditMessage: canEditMessage(message), canEditMessage: canEditMessage(message),
canDeleteForEveryone: canDeleteForEveryone(message, conversation.isMe), canDeleteForEveryone: canDeleteForEveryone(message, conversation.isMe),
canDownload: canDownload(message, conversationSelector), canDownload: canDownload(message, conversationSelector),
canForward: canForward(message),
canReact: canReact(message, ourConversationId, conversationSelector), canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector), canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message), canRetry: hasErrors(message),
@ -2104,6 +2105,15 @@ export function canDownload(
return false; return false;
} }
export function canForward(message: ReadonlyMessageAttributesType): boolean {
return (
!isTapToView(message) &&
!message.deletedForEveryone &&
!message.giftBadge &&
!getPayment(message)
);
}
export function getLastChallengeError( export function getLastChallengeError(
message: Pick<MessageWithUIFieldsType, 'errors'> message: Pick<MessageWithUIFieldsType, 'errors'>
): ShallowChallengeError | undefined { ): ShallowChallengeError | undefined {

View file

@ -26,6 +26,7 @@ import {
getGroupAdminsSelector, getGroupAdminsSelector,
getHasPanelOpen, getHasPanelOpen,
getLastEditableMessageId, getLastEditableMessageId,
getMessages,
getSelectedMessageIds, getSelectedMessageIds,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
@ -37,7 +38,7 @@ import {
getShowStickersIntroduction, getShowStickersIntroduction,
getTextFormattingEnabled, getTextFormattingEnabled,
} from '../selectors/items'; } from '../selectors/items';
import { getPropsForQuote } from '../selectors/message'; import { canForward, getPropsForQuote } from '../selectors/message';
import { import {
getBlessedStickerPacks, getBlessedStickerPacks,
getInstalledStickerPacks, getInstalledStickerPacks,
@ -96,6 +97,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault); const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault);
const recentEmojis = useSelector(selectRecentEmojis); const recentEmojis = useSelector(selectRecentEmojis);
const selectedMessageIds = useSelector(getSelectedMessageIds); const selectedMessageIds = useSelector(getSelectedMessageIds);
const messageLookup = useSelector(getMessages);
const isFormattingEnabled = useSelector(getTextFormattingEnabled); const isFormattingEnabled = useSelector(getTextFormattingEnabled);
const lastEditableMessageId = useSelector(getLastEditableMessageId); const lastEditableMessageId = useSelector(getLastEditableMessageId);
const receivedPacks = useSelector(getReceivedStickerPacks); const receivedPacks = useSelector(getReceivedStickerPacks);
@ -133,6 +135,16 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
shouldSendHighQualityAttachments, shouldSendHighQualityAttachments,
} = composerState; } = composerState;
const areSelectedMessagesForwardable = useMemo(() => {
return selectedMessageIds?.every(messageId => {
const message = messageLookup[messageId];
if (!message) {
return false;
}
return canForward(message);
});
}, [messageLookup, selectedMessageIds]);
const isActive = useMemo(() => { const isActive = useMemo(() => {
return !hasGlobalModalOpen && !hasPanelOpen; return !hasGlobalModalOpen && !hasPanelOpen;
}, [hasGlobalModalOpen, hasPanelOpen]); }, [hasGlobalModalOpen, hasPanelOpen]);
@ -365,6 +377,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
sortedGroupMembers={conversation.sortedGroupMembers ?? null} sortedGroupMembers={conversation.sortedGroupMembers ?? null}
// Select Mode // Select Mode
selectedMessageIds={selectedMessageIds} selectedMessageIds={selectedMessageIds}
areSelectedMessagesForwardable={areSelectedMessagesForwardable}
toggleSelectMode={toggleSelectMode} toggleSelectMode={toggleSelectMode}
toggleForwardMessagesModal={toggleForwardMessagesModal} toggleForwardMessagesModal={toggleForwardMessagesModal}
// DraftGifMessageSendModal // DraftGifMessageSendModal

View file

@ -23,6 +23,7 @@ import {
sortByMessageOrder, sortByMessageOrder,
type ForwardMessageData, type ForwardMessageData,
} from '../types/ForwardDraft'; } from '../types/ForwardDraft';
import { canForward } from '../state/selectors/message';
export async function maybeForwardMessages( export async function maybeForwardMessages(
messages: Array<ForwardMessageData>, messages: Array<ForwardMessageData>,
@ -36,6 +37,16 @@ export async function maybeForwardMessages(
.map(id => window.ConversationController.get(id)) .map(id => window.ConversationController.get(id))
.filter(isNotNil); .filter(isNotNil);
const areAllMessagesForwardable = messages.every(msg =>
msg.originalMessage ? canForward(msg.originalMessage) : true
);
if (!areAllMessagesForwardable) {
throw new Error(
'maybeForwardMessage: Attempting to forward unforwardable message(s)'
);
}
const cannotSend = conversations.some( const cannotSend = conversations.some(
conversation => conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin() conversation?.get('announcementsOnly') && !conversation.areWeAdmin()