From 377378ac91cd0da81ffbb39b948f346f5faa579b Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:36:09 -0500 Subject: [PATCH] Fix more focus behavior Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> --- stylesheets/_modules.scss | 30 +- ts/axo/AxoTooltip.dom.tsx | 24 +- ts/components/CompositionInput.dom.tsx | 27 +- .../EditHistoryMessagesModal.dom.stories.tsx | 3 + ts/components/StoryViewsNRepliesModal.dom.tsx | 3 + .../conversation/CollapseSet.dom.stories.tsx | 3 +- .../conversation/CollapseSet.dom.tsx | 2 +- ts/components/conversation/GIF.dom.tsx | 5 - .../conversation/Image.dom.stories.tsx | 1 - ts/components/conversation/Image.dom.tsx | 9 - .../conversation/ImageGrid.dom.stories.tsx | 1 - ts/components/conversation/ImageGrid.dom.tsx | 3 - .../InlineNotificationWrapper.dom.tsx | 9 +- ts/components/conversation/Message.dom.tsx | 417 ++++++++---------- .../conversation/MessageContextMenu.dom.tsx | 2 +- .../MessageDetail.dom.stories.tsx | 3 + .../conversation/Quote.dom.stories.tsx | 3 + .../conversation/Timeline.dom.stories.tsx | 5 +- ts/components/conversation/Timeline.dom.tsx | 27 +- .../conversation/TimelineItem.dom.stories.tsx | 2 +- .../conversation/TimelineItem.dom.tsx | 9 +- .../TimelineMessage.dom.stories.tsx | 3 + .../conversation/TimelineMessage.dom.tsx | 2 +- .../GroupMemberLabelEditor.dom.tsx | 3 + .../GroupMembersSearchDialog.dom.tsx | 4 - .../addGlobalKeyboardShortcuts.preload.ts | 2 +- ts/state/ducks/conversations.preload.ts | 23 +- ts/state/selectors/conversations.dom.ts | 8 +- ts/state/selectors/message.preload.ts | 19 +- ts/state/selectors/timeline.preload.ts | 5 +- .../getNotificationDataForMessage.preload.ts | 2 +- 31 files changed, 330 insertions(+), 329 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f6849a68ab..d8aef44a94 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -353,13 +353,13 @@ $message-padding-horizontal: 12px; } .module-message__container--targeted { - &.module-message__container--pointer-mode { + .module-message--flashing & { animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1); } } .module-message__container--targeted-lighter { - &.module-message__container--pointer-mode { + .module-message--flashing & { animation: module-message__highlight-lighter 1.2s cubic-bezier(0.17, 0.17, 0, 1); } @@ -437,14 +437,12 @@ $message-padding-horizontal: 12px; } } -.module-message { - @include mixins.focus-visible-within { - @include mixins.light-theme { - background: variables.$color-selected-message-background-light; - } - @include mixins.dark-theme { - background: variables.$color-selected-message-background-dark; - } +.module-message:focus-visible { + @include mixins.light-theme { + background: variables.$color-selected-message-background-light; + } + @include mixins.dark-theme { + background: variables.$color-selected-message-background-dark; } } @@ -1368,15 +1366,7 @@ $message-padding-horizontal: 12px; } .module-Avatar__contents { - outline: 3px solid; - - @include mixins.light-theme() { - outline-color: variables.$color-white; - } - - @include mixins.dark-theme() { - outline-color: variables.$color-gray-95; - } + outline: 3px solid var(--axo-color-surface-primary); } } @@ -2377,7 +2367,7 @@ button.ConversationDetails__action-button { } button.module-image__border-overlay:focus-visible { - box-shadow: inset 0px 0px 0px 2px variables.$color-ultramarine; + @include mixins.axo-focus-ring-inset; } .module-image__border-overlay--dark { diff --git a/ts/axo/AxoTooltip.dom.tsx b/ts/axo/AxoTooltip.dom.tsx index 0dc11771f3..5568e90b9a 100644 --- a/ts/axo/AxoTooltip.dom.tsx +++ b/ts/axo/AxoTooltip.dom.tsx @@ -257,8 +257,6 @@ export namespace AxoTooltip { keyboardShortcut?: string | null; /** Forces the tooltip to stay open when the trigger is clicked. */ keepOpenOnActivation?: boolean; - /** Forces the tooltip to stay closed when calling `.focus()` (ex: restoring focus on menu close) */ - onlyShowOnFocusForUserInputDeviceEvents?: boolean; }>; export type RootProps = RootConfigProps & @@ -303,7 +301,6 @@ export namespace AxoTooltip { keyboardShortcut, experimentalTimestamp, keepOpenOnActivation, - onlyShowOnFocusForUserInputDeviceEvents, } = props; const direction = useDirection(); const collisionBoundary = useContext(CollisionBoundaryContext); @@ -371,19 +368,14 @@ export namespace AxoTooltip { [keepOpenOnActivation] ); - const handleFocus = useCallback( - (event: FocusEvent) => { - if (onlyShowOnFocusForUserInputDeviceEvents) { - // Only show the tooltip if the focus event was fired by an input device, not `.focus()` - // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/sourceCapabilities - // @ts-expect-error not available in type defs - if (event.nativeEvent.sourceCapabilities == null) { - event.preventDefault(); - } - } - }, - [onlyShowOnFocusForUserInputDeviceEvents] - ); + const handleFocus = useCallback((event: FocusEvent) => { + // Only show the tooltip if the focus event was fired by an input device, not `.focus()` + // https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/sourceCapabilities + // @ts-expect-error not available in type defs + if (event.nativeEvent.sourceCapabilities == null) { + event.preventDefault(); + } + }, []); useEffect(() => { if (props.disabled) { diff --git a/ts/components/CompositionInput.dom.tsx b/ts/components/CompositionInput.dom.tsx index a6e0b8b574..522468e7c1 100644 --- a/ts/components/CompositionInput.dom.tsx +++ b/ts/components/CompositionInput.dom.tsx @@ -95,7 +95,7 @@ import { AxoTooltip } from '../axo/AxoTooltip.dom.tsx'; import { tw } from '../axo/tw.dom.tsx'; import type { Emoji } from '../axo/emoji.std.ts'; import { RecoveryKeyPasteWarning } from './RecoveryKeyPasteWarning.dom.tsx'; -import { mergeProps, useFocusRing } from 'react-aria'; +import { mergeProps, useFocusVisible, useFocusWithin } from 'react-aria'; const log = createLogger('CompositionInput'); @@ -115,6 +115,21 @@ Quill.register( true ); +function usePreservedFocusVisibleWithin() { + const { isFocusVisible } = useFocusVisible({ + isTextInput: true, + }); + + const [isFocusWithin, setFocusWithin] = useState(false); + const { focusWithinProps: focusVisibleWithinProps } = useFocusWithin({ + onFocusWithinChange: setFocusWithin, + }); + + const isFocusVisibleWithin = isFocusWithin && isFocusVisible; + + return { isFocusVisibleWithin, focusVisibleWithinProps }; +} + export type InputApi = { focus: () => void; hasFocus: () => boolean; @@ -237,10 +252,8 @@ export function CompositionInput(props: Props): ReactElement { const [isMouseDown, setIsMouseDown] = useState(false); - const { isFocusVisible, focusProps } = useFocusRing({ - isTextInput: true, - within: true, - }); + const { isFocusVisibleWithin, focusVisibleWithinProps } = + usePreservedFocusVisibleWithin(); const generateDelta = ( text: string, @@ -1047,8 +1060,8 @@ export function CompositionInput(props: Props): ReactElement { ref={ref} data-testid="CompositionInput" data-enabled={isInputEnabled ? 'true' : 'false'} - data-focus-visible={isFocusVisible} - {...mergeProps(focusProps, { onMouseDown })} + data-focus-visible={isFocusVisibleWithin} + {...mergeProps(focusVisibleWithinProps, { onMouseDown })} > {onRecoveryKeyPasteConfirm ? ( diff --git a/ts/components/conversation/CollapseSet.dom.stories.tsx b/ts/components/conversation/CollapseSet.dom.stories.tsx index b3e036c007..3145207e9e 100644 --- a/ts/components/conversation/CollapseSet.dom.stories.tsx +++ b/ts/components/conversation/CollapseSet.dom.stories.tsx @@ -42,7 +42,7 @@ const defaultProps: Props = { isSelectMode: false, isSelected: false, renderItem, - targetedMessage: undefined, + targetedMessage: null, toggleDeleteMessagesModal: action('toggleDeleteMessagesModal'), toggleSelectMessage: action('toggleSelectMessage'), }; @@ -83,6 +83,7 @@ export function AutoexpandIfTargeted(): JSX.Element { targetedMessage: { id: 'id1', counter: 1, + source: null, }, }; return ; diff --git a/ts/components/conversation/CollapseSet.dom.tsx b/ts/components/conversation/CollapseSet.dom.tsx index f8a168bafe..0ce36f6acd 100644 --- a/ts/components/conversation/CollapseSet.dom.tsx +++ b/ts/components/conversation/CollapseSet.dom.tsx @@ -35,7 +35,7 @@ export type Props = CollapseSet & { isSelectMode: boolean; isSelected: boolean; renderItem: (props: RenderItemProps) => JSX.Element; - targetedMessage: TargetedMessageType | undefined; + targetedMessage: TargetedMessageType | null; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; toggleSelectMessage: ( conversationId: string, diff --git a/ts/components/conversation/GIF.dom.tsx b/ts/components/conversation/GIF.dom.tsx index c0a0af6c6b..260725dd17 100644 --- a/ts/components/conversation/GIF.dom.tsx +++ b/ts/components/conversation/GIF.dom.tsx @@ -38,7 +38,6 @@ const MAX_GIF_TIME = 8; export type Props = { readonly attachment: AttachmentForUIType; readonly size?: number; - readonly tabIndex: number; // test-only, to force reduced motion experience readonly _forceTapToPlay?: boolean; @@ -58,7 +57,6 @@ export function GIF(props: Props): JSX.Element { const { attachment, size, - tabIndex, _forceTapToPlay, i18n, @@ -264,7 +262,6 @@ export function GIF(props: Props): JSX.Element { i18n, cancelDownloadClick, cancelDownloadKeyDown, - tabIndex, }); let overlay: JSX.Element | undefined; @@ -285,7 +282,6 @@ export function GIF(props: Props): JSX.Element { aria-label={i18n('icu:GIF--download')} onClick={onOverlayClick} onKeyDown={onOverlayKeyDown} - tabIndex={tabIndex} > @@ -297,7 +293,6 @@ export function GIF(props: Props): JSX.Element { className="module-image__overlay-circle module-image__overlay-circle--undownloadable" aria-label={i18n('icu:mediaNotAvailable')} onClick={undownloadableClick} - tabIndex={tabIndex} >
diff --git a/ts/components/conversation/Image.dom.stories.tsx b/ts/components/conversation/Image.dom.stories.tsx index 6e1865bfc5..bf6dd070f9 100644 --- a/ts/components/conversation/Image.dom.stories.tsx +++ b/ts/components/conversation/Image.dom.stories.tsx @@ -47,7 +47,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ onError: action('onError'), overlayText: overrideProps.overlayText || '', playIconOverlay: overrideProps.playIconOverlay || false, - tabIndex: overrideProps.tabIndex || 0, theme: overrideProps.theme || ('light' as ThemeType), url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl, width: overrideProps.width || 300, diff --git a/ts/components/conversation/Image.dom.tsx b/ts/components/conversation/Image.dom.tsx index 41027a77fc..2b9d8f4c5c 100644 --- a/ts/components/conversation/Image.dom.tsx +++ b/ts/components/conversation/Image.dom.tsx @@ -33,7 +33,6 @@ export type Props = { width?: number; cropWidth?: number; cropHeight?: number; - tabIndex?: number; overlayText?: string; @@ -86,7 +85,6 @@ export function Image({ onError, overlayText, playIconOverlay, - tabIndex, theme, url, width = 0, @@ -194,7 +192,6 @@ export function Image({ onClick={startDownloadClick} onKeyDown={startDownloadKeyDown} style={curveStyles} - tabIndex={tabIndex} > @@ -212,7 +209,6 @@ export function Image({ className="module-image__overlay-circle module-image__overlay-circle--undownloadable" aria-label={i18n('icu:mediaNotAvailable')} onClick={undownloadableClick} - tabIndex={tabIndex} >
@@ -229,7 +225,6 @@ export function Image({ i18n, cancelDownloadClick, cancelDownloadKeyDown, - tabIndex, }); return ( @@ -301,7 +296,6 @@ export function Image({ style={curveStyles} onClick={showVisualAttachmentClick} onKeyDown={showVisualAttachmentKeyDown} - tabIndex={tabIndex} /> ) : null} {closeButton ? ( @@ -329,13 +323,11 @@ export function getSpinner({ cancelDownloadClick, cancelDownloadKeyDown, i18n, - tabIndex, }: { attachment: AttachmentForUIType; cancelDownloadClick: (event: MouseEvent) => void; cancelDownloadKeyDown: (event: KeyboardEvent) => void; i18n: LocalizerType; - tabIndex: number | undefined; }): JSX.Element | undefined { if (!attachment.pending) { return undefined; @@ -354,7 +346,6 @@ export function getSpinner({ aria-label={i18n('icu:cancelDownload')} onClick={cancelDownloadClick} onKeyDown={cancelDownloadKeyDown} - tabIndex={tabIndex} >
diff --git a/ts/components/conversation/ImageGrid.dom.stories.tsx b/ts/components/conversation/ImageGrid.dom.stories.tsx index ef566112aa..d28f0b165f 100644 --- a/ts/components/conversation/ImageGrid.dom.stories.tsx +++ b/ts/components/conversation/ImageGrid.dom.stories.tsx @@ -53,7 +53,6 @@ export default { cancelDownload: action('cancelDownload'), onError: action('onError'), stickerSize: 0, - tabIndex: 0, withContentAbove: false, withContentBelow: false, }, diff --git a/ts/components/conversation/ImageGrid.dom.tsx b/ts/components/conversation/ImageGrid.dom.tsx index 230b50250b..192c8d59c3 100644 --- a/ts/components/conversation/ImageGrid.dom.tsx +++ b/ts/components/conversation/ImageGrid.dom.tsx @@ -39,7 +39,6 @@ export type Props = { shouldCollapseAbove?: boolean; shouldCollapseBelow?: boolean; stickerSize?: number; - tabIndex?: number; withContentAbove?: boolean; withContentBelow?: boolean; @@ -125,7 +124,6 @@ export function ImageGrid({ startDownload, shouldCollapseAbove, shouldCollapseBelow, - tabIndex, theme, withContentAbove, withContentBelow, @@ -229,7 +227,6 @@ export function ImageGrid({ height={height} width={width} url={getUrl(attachment) ?? attachment.thumbnailFromBackup?.url} - tabIndex={tabIndex} showMediaNoLongerAvailableToast={showMediaNoLongerAvailableToast} showVisualAttachment={showAttachmentOrNoLongerAvailableToast(0)} cancelDownload={cancelDownload} diff --git a/ts/components/conversation/InlineNotificationWrapper.dom.tsx b/ts/components/conversation/InlineNotificationWrapper.dom.tsx index c5e0e7da16..36fb5374fb 100644 --- a/ts/components/conversation/InlineNotificationWrapper.dom.tsx +++ b/ts/components/conversation/InlineNotificationWrapper.dom.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef } from 'react'; import classNames from 'classnames'; import type { ReactNode, JSX, MouseEvent, KeyboardEvent } from 'react'; +import { TargetedMessageSource } from '../../state/ducks/conversationsEnums.std.ts'; export type Props = { id: string; @@ -12,7 +13,11 @@ export type Props = { isTargeted: boolean; isSelectMode: boolean; isSelected: boolean; - targetMessage: (messageId: string, conversationId: string) => unknown; + targetMessage: ( + messageId: string, + conversationId: string, + targetedMessageSource: TargetedMessageSource + ) => unknown; toggleSelectMessage: ( conversationId: string, messageId: string, @@ -44,7 +49,7 @@ export function InlineNotificationWrapper({ }, [isTargeted]); const handleFocus = useCallback(() => { - targetMessage(id, conversationId); + targetMessage(id, conversationId, TargetedMessageSource.Focus); }, [id, conversationId, targetMessage]); if (isSelectMode) { diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 63e7ee6b39..2bbbb78b40 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -8,7 +8,6 @@ import type { RefObject, JSX, KeyboardEvent, - MutableRefObject, MouseEvent, } from 'react'; import { forwardRef, useRef, PureComponent, createRef } from 'react'; @@ -124,7 +123,7 @@ import type { ContactModalStateType } from '../../types/globalModals.std.ts'; import { tw } from '../../axo/tw.dom.tsx'; import { Emoji } from '../../axo/emoji.std.ts'; import { AxoButton } from '../../axo/AxoButton.dom.tsx'; -import { getInteractionModality } from '@react-aria/interactions'; +import { TargetedMessageSource } from '../../state/ducks/conversationsEnums.std.ts'; const { drop, take, unescape } = lodash; @@ -135,8 +134,6 @@ const EXPIRED_DELAY = 600; const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT; const STICKER_SIZE = 200; const GIF_SIZE = 300; -// Note: this needs to match the animation time -const TARGETED_TIMEOUT = 1200; const SENT_STATUSES = new Set([ 'delivered', 'read', @@ -226,8 +223,9 @@ export type PropsData = { textAttachment?: AttachmentForUIType; isEditedMessage?: boolean; isSticker?: boolean; - isTargeted?: boolean; - isTargetedCounter?: number; + isTargeted: boolean; + isTargetedCounter: number | null; + isTargetedSource: TargetedMessageSource | null; isSelected: boolean; isSelectMode: boolean; isSignalConversation: boolean; @@ -321,11 +319,11 @@ export type PropsData = { isMessageRequestAccepted: boolean; bodyRanges?: HydratedBodyRangesType; - renderMenu?: () => JSX.Element | undefined; + renderMenu?: () => ReactNode; renderMessageContextMenu?: ( renderer: AxoMenuBuilder.Renderer, children: ReactNode - ) => JSX.Element; + ) => ReactNode; item?: never; // test-only, to force GIF's reduced motion experience @@ -340,7 +338,7 @@ export type PropsHousekeeping = { i18n: LocalizerType; interactivity: MessageInteractivity; platform: string; - renderAudioAttachment: (props: RenderAudioAttachmentProps) => JSX.Element; + renderAudioAttachment: (props: RenderAudioAttachmentProps) => ReactNode; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; shouldHideMetadata: boolean; @@ -387,7 +385,11 @@ export type PropsActions = { conversationId: string; sentAt: number; }) => void; - targetMessage?: (messageId: string, conversationId: string) => unknown; + targetMessage?: ( + messageId: string, + conversationId: string, + targetedMessageSource: TargetedMessageSource + ) => unknown; showEditHistoryModal?: (id: string) => unknown; showAttachmentDownloadStillInProgressToast: (count: number) => unknown; @@ -412,8 +414,8 @@ type State = { expired: boolean; imageBroken: boolean; - isTargeted?: boolean; - prevTargetedCounter?: number; + flashing: boolean; + lastFlashedTargetedMessageCounter: number | null; reactionViewerRoot: HTMLDivElement | null; reactionViewerOutsideClickDestructor?: () => void; @@ -613,29 +615,18 @@ const MessageReactions = forwardRef(function MessageReactions( // oxlint-disable-next-line react/prefer-function-component export class Message extends PureComponent { - public focusRef: RefObject = createRef(); + readonly #focusRef = createRef(); + readonly #audioButtonRef = createRef(); + readonly #reactionsContainerRef = createRef(); + readonly #metadataRef = createRef(); - public audioButtonRef: RefObject = createRef(); + #hasSelectedText = false; + #expirationCheckInterval: ReturnType | null = null; + #giftBadgeInterval: ReturnType | null = null; + #expiredTimeout: ReturnType | null = null; + #deleteForEveryoneTimeout: ReturnType | null = null; - public reactionsContainerRef: RefObject = createRef(); - - readonly #hasSelectedTextRef: MutableRefObject = { - current: false, - }; - - readonly #metadataRef: RefObject = createRef(); - - public expirationCheckInterval: NodeJS.Timeout | undefined; - - public giftBadgeInterval: NodeJS.Timeout | undefined; - - public expiredTimeout: NodeJS.Timeout | undefined; - - public targetedTimeout: NodeJS.Timeout | undefined; - - public deleteForEveryoneTimeout: NodeJS.Timeout | undefined; - - public constructor(props: Props) { + constructor(props: Props) { super(props); this.state = { @@ -645,8 +636,8 @@ export class Message extends PureComponent { expired: false, imageBroken: false, - isTargeted: props.isTargeted, - prevTargetedCounter: props.isTargetedCounter, + flashing: false, + lastFlashedTargetedMessageCounter: null, reactionViewerRoot: null, @@ -658,44 +649,24 @@ export class Message extends PureComponent { }; } - public static getDerivedStateFromProps(props: Props, state: State): State { - if (!props.isTargeted) { - return { - ...state, - isTargeted: false, - prevTargetedCounter: 0, - }; - } - - if ( - props.isTargeted && - props.isTargetedCounter !== state.prevTargetedCounter - ) { - return { - ...state, - isTargeted: props.isTargeted, - prevTargetedCounter: props.isTargetedCounter, - }; - } - - return state; - } - #hasReactions(): boolean { const { reactions } = this.props; return Boolean(reactions && reactions.length); } - public handleFocus = (): void => { + readonly #handleFocus = (): void => { const { isTargeted } = this.props; - const viaKeyboard = getInteractionModality() === 'keyboard'; - if (viaKeyboard && !isTargeted) { - this.setTargeted(); + if (!isTargeted) { + this.#setTargeted(); } }; - public handleImageError = (): void => { + readonly #handleAnimationEnd = (): void => { + this.setState({ flashing: false }); + }; + + readonly #handleImageError = (): void => { const { id } = this.props; log.info(`${id}: Image failed to load; failing over to placeholder`); this.setState({ @@ -703,29 +674,55 @@ export class Message extends PureComponent { }); }; - public setTargeted = (): void => { + #setTargeted() { const { id, conversationId, targetMessage } = this.props; if (targetMessage) { - targetMessage(id, conversationId); + targetMessage(id, conversationId, TargetedMessageSource.Focus); } - }; + } - public setFocus = (): void => { - const container = this.focusRef.current; + #setFocus() { + const container = this.#focusRef.current; if (container && !container.contains(document.activeElement)) { - container.focus(); + const isFocusVisible = + document.activeElement?.matches(':focus-visible') ?? false; + container.focus({ + focusVisible: isFocusVisible, + }); } - }; - public override componentDidMount(): void { + this.#maybeFlash(); + } + + #maybeFlash() { + const { isTargetedCounter, isTargetedSource } = this.props; + const { lastFlashedTargetedMessageCounter } = this.state; + + if ( + isTargetedCounter == null || + isTargetedCounter === lastFlashedTargetedMessageCounter + ) { + return; + } + + if (isTargetedSource !== TargetedMessageSource.NavigateToMessage) { + return; + } + + this.setState({ + flashing: true, + lastFlashedTargetedMessageCounter: isTargetedCounter, + }); + } + + override componentDidMount(): void { const { conversationId } = this.props; window.ConversationController?.onConvoMessageMount(conversationId); - this.startTargetedTimer(); this.#startDeleteForEveryoneTimerIfApplicable(); - this.startGiftBadgeInterval(); + this.#startGiftBadgeInterval(); if (this.#metadataRef.current) { this.#updateMetadataWidth(this.#metadataRef.current.offsetWidth); @@ -733,7 +730,7 @@ export class Message extends PureComponent { const { isTargeted } = this.props; if (isTargeted) { - this.setFocus(); + this.#setFocus(); } const { expirationLength } = this.props; @@ -741,10 +738,10 @@ export class Message extends PureComponent { const increment = getIncrement(expirationLength); const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment); - this.checkExpired(); + this.#checkExpired(); - this.expirationCheckInterval = setInterval(() => { - this.checkExpired(); + this.#expirationCheckInterval = setInterval(() => { + this.#checkExpired(); }, checkFrequency); } @@ -756,30 +753,32 @@ export class Message extends PureComponent { document.addEventListener('selectionchange', this.#handleSelectionChange); } - public override componentWillUnmount(): void { - clearTimeoutIfNecessary(this.targetedTimeout); - clearTimeoutIfNecessary(this.expirationCheckInterval); - clearTimeoutIfNecessary(this.expiredTimeout); - clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); - clearTimeoutIfNecessary(this.giftBadgeInterval); - this.toggleReactionViewer(true); + override componentWillUnmount(): void { + clearTimeoutIfNecessary(this.#expirationCheckInterval); + clearTimeoutIfNecessary(this.#expiredTimeout); + clearTimeoutIfNecessary(this.#deleteForEveryoneTimeout); + clearTimeoutIfNecessary(this.#giftBadgeInterval); + this.#toggleReactionViewer(true); document.removeEventListener( 'selectionchange', this.#handleSelectionChange ); } - public override componentDidUpdate(prevProps: Readonly): void { + override componentDidUpdate(prevProps: Readonly): void { const { isTargeted, status, timestamp } = this.props; - this.startTargetedTimer(); this.#startDeleteForEveryoneTimerIfApplicable(); - if (!prevProps.isTargeted && isTargeted) { - this.setFocus(); + if (isTargeted) { + if (prevProps.isTargeted) { + this.#maybeFlash(); + } else { + this.#setFocus(); + } } - this.checkExpired(); + this.#checkExpired(); if ( prevProps.status === 'sending' && @@ -862,7 +861,7 @@ export class Message extends PureComponent { const isAttachmentNotAvailable = firstAttachment?.isPermanentlyUndownloadable; - if (this.isGenericAttachment(attachments, imageBroken)) { + if (this.#isGenericAttachment(attachments, imageBroken)) { return MetadataPlacement.RenderedElsewhere; } @@ -888,37 +887,19 @@ export class Message extends PureComponent { return MetadataPlacement.InlineWithText; } - public startTargetedTimer(): void { - const { clearTargetedMessage } = this.props; - const { isTargeted } = this.state; - - const viaKeyboard = getInteractionModality() === 'keyboard'; - if (viaKeyboard || !isTargeted) { - return; - } - - if (!this.targetedTimeout) { - this.targetedTimeout = setTimeout(() => { - this.targetedTimeout = undefined; - this.setState({ isTargeted: false }); - clearTargetedMessage(); - }, TARGETED_TIMEOUT); - } - } - - public startGiftBadgeInterval(): void { + #startGiftBadgeInterval() { const { giftBadge } = this.props; if (!giftBadge) { return; } - this.giftBadgeInterval = setInterval(() => { - this.updateGiftBadgeCounter(); + this.#giftBadgeInterval = setInterval(() => { + this.#updateGiftBadgeCounter(); }, GIFT_BADGE_UPDATE_INTERVAL); } - public updateGiftBadgeCounter(): void { + #updateGiftBadgeCounter() { this.setState((state: State) => ({ giftBadgeCounter: (state.giftBadgeCounter || 0) + 1, })); @@ -929,31 +910,31 @@ export class Message extends PureComponent { return Math.max(timestamp - Date.now() + DAY, 0); } - #startDeleteForEveryoneTimerIfApplicable(): void { + #startDeleteForEveryoneTimerIfApplicable() { const { canDeleteForEveryone } = this.props; const { hasDeleteForEveryoneTimerExpired } = this.state; if ( !canDeleteForEveryone || hasDeleteForEveryoneTimerExpired || - this.deleteForEveryoneTimeout + this.#deleteForEveryoneTimeout ) { return; } - this.deleteForEveryoneTimeout = setTimeout(() => { + this.#deleteForEveryoneTimeout = setTimeout(() => { this.setState({ hasDeleteForEveryoneTimerExpired: true }); - delete this.deleteForEveryoneTimeout; + this.#deleteForEveryoneTimeout = null; }, this.#getTimeRemainingForDeleteForEveryone()); } - public checkExpired(): void { + #checkExpired() { const now = Date.now(); const { expirationTimestamp, expirationLength } = this.props; if (!expirationTimestamp || !expirationLength) { return; } - if (this.expiredTimeout) { + if (this.#expiredTimeout) { return; } @@ -967,7 +948,7 @@ export class Message extends PureComponent { expired: true, }); }; - this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); + this.#expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); } } @@ -1039,7 +1020,7 @@ export class Message extends PureComponent { readonly #handleSelectionChange = () => { const selection = document.getSelection(); if (selection != null && !selection.isCollapsed) { - this.#hasSelectedTextRef.current = true; + this.#hasSelectedText = true; } }; @@ -1103,7 +1084,7 @@ export class Message extends PureComponent { isOutlineOnlyBubble={ deletedForEveryone || (attachmentDroppedDueToSize && !text) } - isShowingImage={this.isShowingImage()} + isShowingImage={this.#isShowingImage()} isSticker={isStickerLike} isStickerReply={isStickerLike && Boolean(quote)} onWidthMeasured={isInline ? this.#updateMetadataWidth : undefined} @@ -1147,7 +1128,7 @@ export class Message extends PureComponent { ); } - public renderAttachment(): JSX.Element | null { + #renderAttachment(): ReactNode { const { _forceTapToPlay, attachmentDroppedDueToSize, @@ -1230,11 +1211,10 @@ export class Message extends PureComponent { { showLightbox({ attachment: firstAttachment, @@ -1259,8 +1239,6 @@ export class Message extends PureComponent { if (isSticker || isImage(attachments) || isVideo(attachments)) { const bottomOverlay = !isSticker && !collapseMetadata; - // We only want users to tab into this if there's more than one - const tabIndex = attachments.length > 1 ? 0 : -1; return (
@@ -1273,11 +1251,10 @@ export class Message extends PureComponent { stickerSize={STICKER_SIZE} bottomOverlay={bottomOverlay} i18n={i18n} - onError={this.handleImageError} + onError={this.#handleImageError} theme={theme} shouldCollapseAbove={shouldCollapseAbove} shouldCollapseBelow={shouldCollapseBelow} - tabIndex={tabIndex} showVisualAttachment={attachment => { showLightbox({ attachment, messageId: id }); }} @@ -1297,13 +1274,13 @@ export class Message extends PureComponent { const isAttachmentAudio = isAudio(attachments); if (isAttachmentNotAvailable && (isAttachmentAudio || isSticker)) { - return this.renderSimpleAttachmentNotAvailable(); + return this.#renderSimpleAttachmentNotAvailable(); } if (isAttachmentAudio) { return renderAudioAttachment({ i18n, - buttonRef: this.audioButtonRef, + buttonRef: this.#audioButtonRef, renderingContext, attachment: firstAttachment, collapseMetadata, @@ -1362,7 +1339,7 @@ export class Message extends PureComponent { event.stopPropagation(); event.preventDefault(); - this.openGenericAttachment(); + this.#openGenericAttachment(); }} onKeyDown={(event: KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') { @@ -1372,7 +1349,7 @@ export class Message extends PureComponent { event.stopPropagation(); event.preventDefault(); - this.openGenericAttachment(); + this.#openGenericAttachment(); }} tabIndex={tabIndex} aria-label={ @@ -1456,7 +1433,7 @@ export class Message extends PureComponent { ); } - public renderSimpleAttachmentNotAvailable(): JSX.Element | null { + #renderSimpleAttachmentNotAvailable(): ReactNode { const { attachmentDroppedDueToSize, attachments, @@ -1550,7 +1527,7 @@ export class Message extends PureComponent { ); } - public renderUndownloadableTextAttachment(): JSX.Element | null { + #renderUndownloadableTextAttachment(): ReactNode { const { i18n, textAttachment } = this.props; if (!textAttachment || !textAttachment.isPermanentlyUndownloadable) { return null; @@ -1569,7 +1546,7 @@ export class Message extends PureComponent { ); } - public renderPreview(): JSX.Element | null { + #renderPreview(): ReactNode { const { attachments, conversationType, @@ -1639,7 +1616,7 @@ export class Message extends PureComponent { direction={direction} shouldCollapseAbove={shouldCollapseAbove} withContentBelow - onError={this.handleImageError} + onError={this.#handleImageError} i18n={i18n} theme={theme} showVisualAttachment={() => { @@ -1682,7 +1659,7 @@ export class Message extends PureComponent { url={first.image.url} attachment={first.image} blurHash={first.image.blurHash} - onError={this.handleImageError} + onError={this.#handleImageError} i18n={i18n} showMediaNoLongerAvailableToast={ showMediaNoLongerAvailableToast @@ -1786,7 +1763,7 @@ export class Message extends PureComponent { ); } - public renderAttachmentTooBig(): JSX.Element | null { + #renderAttachmentTooBig(): ReactNode { const { attachments, attachmentDroppedDueToSize, @@ -1842,7 +1819,7 @@ export class Message extends PureComponent { ); } - public renderGiftBadge(): JSX.Element | null { + #renderGiftBadge(): ReactNode { const { conversationTitle, direction, getPreferredBadge, giftBadge, i18n } = this.props; const { showOutgoingGiftBadgeModal } = this.state; @@ -2031,7 +2008,7 @@ export class Message extends PureComponent { throw missingCaseError(giftBadge.state); } - public renderPayment(): JSX.Element | null { + #renderPayment(): ReactNode { const { payment, direction, @@ -2073,7 +2050,7 @@ export class Message extends PureComponent { ); } - public renderPoll(): JSX.Element | null { + #renderPoll(): ReactNode { const { poll, direction, i18n, id, endPoll, canEndPoll, canSendPollVote } = this.props; if (!poll) { @@ -2097,7 +2074,7 @@ export class Message extends PureComponent { return this.props.doubleCheckMissingQuoteReference(this.props.id); }; - public renderQuote(): JSX.Element | null { + #renderQuote(): ReactNode { const { conversationColor, conversationId, @@ -2153,7 +2130,7 @@ export class Message extends PureComponent { ); } - public renderStoryReplyContext(): JSX.Element | null { + #renderStoryReplyContext(): ReactNode { const { conversationTitle, conversationColor, @@ -2211,7 +2188,7 @@ export class Message extends PureComponent { ); } - public renderEmbeddedContact(): JSX.Element | null { + #renderEmbeddedContact(): ReactNode { const { cancelAttachmentDownload, contact, @@ -2274,7 +2251,7 @@ export class Message extends PureComponent { ); } - public renderSendMessageButton(): JSX.Element | null { + #renderSendMessageButton(): ReactNode { const { contact, direction, shouldCollapseBelow, startConversation, i18n } = this.props; const noBottomLeftCurve = direction === 'incoming' && shouldCollapseBelow; @@ -2361,7 +2338,7 @@ export class Message extends PureComponent { ); } - #getMessageStatusContents(): JSX.Element | string | null { + #getMessageStatusContents(): ReactNode { const { author, conversationId, @@ -2434,7 +2411,7 @@ export class Message extends PureComponent { return null; } - public renderText(): JSX.Element | null { + #renderText(): ReactNode { const { text, bodyRanges, @@ -2546,7 +2523,7 @@ export class Message extends PureComponent { ); } - #renderAction(): JSX.Element | null { + #renderAction(): ReactNode { const { direction, activeCallConversationId, i18n, previews } = this.props; if (!this.#shouldShowActionButton()) { @@ -2633,7 +2610,7 @@ export class Message extends PureComponent { ); } - public getWidth(): number | undefined { + #getWidth(): number | undefined { const { attachments, giftBadge, isSticker, isTapToView, previews } = this.props; @@ -2681,7 +2658,7 @@ export class Message extends PureComponent { return undefined; } - public isShowingImage(): boolean { + #isShowingImage(): boolean { const { isTapToView, attachments, previews } = this.props; const { imageBroken } = this.state; @@ -2706,7 +2683,7 @@ export class Message extends PureComponent { return false; } - public isAttachmentPending(): boolean { + #isAttachmentPending(): boolean { const { attachments } = this.props; if (!attachments || attachments.length < 1) { @@ -2719,7 +2696,7 @@ export class Message extends PureComponent { return Boolean(first.pending); } - public renderTapToViewIcon(): JSX.Element { + #renderTapToViewIcon(): ReactNode { const { direction, isTapToViewError, isTapToViewExpired, readStatus } = this.props; const isIncoming = direction === 'incoming'; @@ -2771,7 +2748,7 @@ export class Message extends PureComponent { ); } - public renderTapToViewText(): { title: string; detail: string | undefined } { + #renderTapToViewText(): { title: string; detail: string | undefined } { const { attachments, direction, @@ -2817,7 +2794,7 @@ export class Message extends PureComponent { }; } - public renderTapToView(): JSX.Element | null { + #renderTapToView(): ReactNode { const { attachments, attachmentDroppedDueToSize, @@ -2858,10 +2835,10 @@ export class Message extends PureComponent { direction === 'incoming'; if (isIncoming && !isViewed && (isError || isExpired)) { - return this.renderSimpleAttachmentNotAvailable(); + return this.#renderSimpleAttachmentNotAvailable(); } - const text = this.renderTapToViewText(); + const text = this.#renderTapToViewText(); let content: JSX.Element; if (text.title && text.detail) { content = ( @@ -2971,14 +2948,14 @@ export class Message extends PureComponent { )} > {isExpired || firstAttachment == null ? ( - this.renderTapToViewIcon() + this.#renderTapToViewIcon() ) : ( - {this.renderTapToViewIcon()} + {this.#renderTapToViewIcon()} )} {content} @@ -3004,7 +2981,7 @@ export class Message extends PureComponent { }; }; - public toggleReactionViewer = (onlyRemove = false): void => { + #toggleReactionViewer(onlyRemove = false) { this.setState(oldState => { const { reactionViewerRoot } = oldState; if (reactionViewerRoot) { @@ -3024,11 +3001,11 @@ export class Message extends PureComponent { const reactionViewerOutsideClickDestructor = handleOutsideClick( () => { - this.toggleReactionViewer(true); + this.#toggleReactionViewer(true); return true; }, { - containerElements: [root, this.reactionsContainerRef], + containerElements: [root, this.#reactionsContainerRef], name: 'Message.reactionViewer', } ); @@ -3041,9 +3018,9 @@ export class Message extends PureComponent { return null; }); - }; + } - public renderReactions(outgoing: boolean): JSX.Element | null { + #renderReactions(outgoing: boolean): ReactNode { const { getPreferredBadge, reactions = [], i18n, theme } = this.props; if (!this.#hasReactions()) { @@ -3060,35 +3037,35 @@ export class Message extends PureComponent { theme={theme} outgoing={outgoing} toggleReactionViewer={() => { - this.toggleReactionViewer(); + this.#toggleReactionViewer(); }} reactionViewerRoot={reactionViewerRoot} popperPreventOverflowModifier={this.#popperPreventOverflowModifier} - ref={this.reactionsContainerRef} + ref={this.#reactionsContainerRef} /> ); } - public renderContents(): JSX.Element | null { + #renderContents(): ReactNode { const { deletedForEveryone, giftBadge, isTapToView } = this.props; if (deletedForEveryone) { return ( <> - {this.renderText()} + {this.#renderText()} {this.#renderMetadata()} ); } if (giftBadge) { - return this.renderGiftBadge(); + return this.#renderGiftBadge(); } if (isTapToView) { return ( <> - {this.renderTapToView()} + {this.#renderTapToView()} {this.#renderMetadata()} ); @@ -3096,24 +3073,24 @@ export class Message extends PureComponent { return ( <> - {this.renderQuote()} - {this.renderStoryReplyContext()} - {this.renderAttachment()} - {this.renderPreview()} - {this.renderAttachmentTooBig()} - {this.renderPayment()} - {this.renderPoll()} - {this.renderEmbeddedContact()} - {this.renderText()} - {this.renderUndownloadableTextAttachment()} + {this.#renderQuote()} + {this.#renderStoryReplyContext()} + {this.#renderAttachment()} + {this.#renderPreview()} + {this.#renderAttachmentTooBig()} + {this.#renderPayment()} + {this.#renderPoll()} + {this.#renderEmbeddedContact()} + {this.#renderText()} + {this.#renderUndownloadableTextAttachment()} {this.#renderAction()} {this.#renderMetadata()} - {this.renderSendMessageButton()} + {this.#renderSendMessageButton()} ); } - public handleOpen = (event: KeyboardEvent | MouseEvent): void => { + #handleOpen(event: KeyboardEvent | MouseEvent) { const { attachments, cancelAttachmentDownload, @@ -3136,7 +3113,7 @@ export class Message extends PureComponent { } = this.props; const { imageBroken } = this.state; - const isAttachmentPending = this.isAttachmentPending(); + const isAttachmentPending = this.#isAttachmentPending(); if (giftBadge && giftBadge.state === GiftBadgeStates.Unopened) { openGiftBadge(id); @@ -3199,20 +3176,20 @@ export class Message extends PureComponent { return; } - if (this.isGenericAttachment(attachments, imageBroken)) { - this.openGenericAttachment(); + if (this.#isGenericAttachment(attachments, imageBroken)) { + this.#openGenericAttachment(); return; } if ( isAudio(attachments) && - this.audioButtonRef && - this.audioButtonRef.current + this.#audioButtonRef && + this.#audioButtonRef.current ) { event.preventDefault(); event.stopPropagation(); - this.audioButtonRef.current.click(); + this.#audioButtonRef.current.click(); return; } @@ -3248,9 +3225,9 @@ export class Message extends PureComponent { showLightbox({ attachment, messageId: id }); } - }; + } - public openGenericAttachment = (event?: MouseEvent): void => { + #openGenericAttachment() { const { id, attachments, @@ -3261,11 +3238,6 @@ export class Message extends PureComponent { cancelAttachmentDownload, } = this.props; - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - const firstAttachment = attachments?.[0]; if (!firstAttachment) { return; @@ -3289,27 +3261,27 @@ export class Message extends PureComponent { } else { saveAttachment(firstAttachment, timestamp); } - }; + } - public handleClick = (event: MouseEvent): void => { + readonly #handleClick = (event: MouseEvent): void => { // We don't want clicks on body text to result in the 'default action' for the message const { text } = this.props; if (text && text.length > 0) { return; } - this.handleOpen(event); + this.#handleOpen(event); }; - public handleKeyDown = (event: KeyboardEvent): void => { + readonly #handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } - this.handleOpen(event); + this.#handleOpen(event); }; - private isGenericAttachment( + #isGenericAttachment( attachments: ReadonlyArray | undefined, imageBroken: boolean ) { @@ -3321,7 +3293,7 @@ export class Message extends PureComponent { ); } - public renderContainer(): JSX.Element { + #renderContainer(): ReactNode { const { attachments, attachmentDroppedDueToSize, @@ -3333,17 +3305,16 @@ export class Message extends PureComponent { id, isSelectMode, isSticker, + isTargeted, isTapToView, quote, renderMessageContextMenu, text, textDirection, } = this.props; - const { isTargeted, imageBroken } = this.state; + const { imageBroken } = this.state; - const isPointerMode = getInteractionModality() === 'pointer'; - - const width = this.getWidth(); + const width = this.#getWidth(); const isEmojiOnly = this.#canRenderStickerLikeEmoji(); const isStickerLike = isEmojiOnly || @@ -3361,7 +3332,7 @@ export class Message extends PureComponent { (text || (!isVideo(attachments) && !isImage(attachments))); const isClickable = isTapToView || - (this.isGenericAttachment(attachments, imageBroken) && + (this.#isGenericAttachment(attachments, imageBroken) && !text && !attachments?.[0]?.isPermanentlyUndownloadable) || contact; @@ -3373,7 +3344,6 @@ export class Message extends PureComponent { : null, isTargeted ? 'module-message__container--targeted' : null, lighterSelect ? 'module-message__container--targeted-lighter' : null, - isPointerMode ? 'module-message__container--pointer-mode' : null, isStickerLike ? 'module-message__container--sticker-like' : null, !isStickerLike ? `module-message__container--${direction}` : null, isEmojiOnly ? 'module-message__container--emoji' : null, @@ -3421,26 +3391,27 @@ export class Message extends PureComponent { id={`message-accessibility-contents:${id}`} style={containerStyles} role="row" - onClick={this.handleClick} + onClick={this.#handleClick} onDoubleClick={ev => { // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} + onAnimationEnd={this.#handleAnimationEnd} tabIndex={-1} inert={isSelectMode ? true : undefined} > {this.#renderAuthor()}
- {this.renderContents()} + {this.#renderContents()}
)} - {this.renderReactions(direction === 'outgoing')} + {this.#renderReactions(direction === 'outgoing')}
); } - renderAltAccessibilityTree(): JSX.Element { + #renderAltAccessibilityTree(): ReactNode { const { id, i18n, author } = this.props; return ( @@ -3453,13 +3424,13 @@ export class Message extends PureComponent { - {this.renderText()} + {this.#renderText()} ); } - public override render(): JSX.Element | null { + override render(): ReactNode { const { id, attachments, @@ -3470,6 +3441,7 @@ export class Message extends PureComponent { isSelected, isSelectMode, isSignalConversation, + isTargeted, platform, renderMenu, shouldCollapseAbove, @@ -3480,7 +3452,7 @@ export class Message extends PureComponent { onWrapperKeyDown, } = this.props; const isMacOS = platform === 'darwin'; - const { expired, expiring, isTargeted, imageBroken } = this.state; + const { expired, expiring, imageBroken, flashing } = this.state; if (expired) { return null; @@ -3516,7 +3488,7 @@ export class Message extends PureComponent { } else { wrapperProps = { onMouseDown: () => { - this.#hasSelectedTextRef.current = false; + this.#hasSelectedText = false; }, // We use `onClickCapture` here and prevent default/stop propagation to // prevent other click handlers from firing. @@ -3526,7 +3498,7 @@ export class Message extends PureComponent { return; } - if (this.#hasSelectedTextRef.current) { + if (this.#hasSelectedText) { return; } @@ -3555,7 +3527,7 @@ export class Message extends PureComponent { } }, onKeyDown: event => { - this.handleKeyDown(event); + this.#handleKeyDown(event); onWrapperKeyDown?.(event); }, }; @@ -3579,7 +3551,7 @@ export class Message extends PureComponent { role="presentation" className="module-message__select-checkbox" /> - {this.renderAltAccessibilityTree()} + {this.#renderAltAccessibilityTree()} )}
{ shouldCollapseAbove && 'module-message--collapsed-above', shouldCollapseBelow && 'module-message--collapsed-below', isTargeted ? 'module-message--targeted' : null, - expiring ? 'module-message--expired' : null + expiring ? 'module-message--expired' : null, + flashing ? 'module-message--flashing' : null )} data-testid={timestamp} tabIndex={0} // We need to have a role because screenreaders need to be able to focus here to // read the message, but we can't be a button; that would break inner buttons. role="row" - onFocus={this.handleFocus} - ref={this.focusRef} + onFocus={this.#handleFocus} + ref={this.#focusRef} > {this.#renderError()} {this.#renderAvatar()} - {this.renderContainer()} + {this.#renderContainer()} {renderMenu?.()}
diff --git a/ts/components/conversation/MessageContextMenu.dom.tsx b/ts/components/conversation/MessageContextMenu.dom.tsx index 11f43c9bc2..6e0e2eae8b 100644 --- a/ts/components/conversation/MessageContextMenu.dom.tsx +++ b/ts/components/conversation/MessageContextMenu.dom.tsx @@ -143,7 +143,7 @@ export function MessageContextMenu({ )} {onMoreInfo && ( - + {i18n('icu:MessageContextMenu__info')} )} diff --git a/ts/components/conversation/MessageDetail.dom.stories.tsx b/ts/components/conversation/MessageDetail.dom.stories.tsx index 4d56b69ab5..9c41bdb6aa 100644 --- a/ts/components/conversation/MessageDetail.dom.stories.tsx +++ b/ts/components/conversation/MessageDetail.dom.stories.tsx @@ -41,6 +41,9 @@ const defaultMessage: MessageDataPropsType = { isSignalConversation: false, isSMS: false, isSpoilerExpanded: {}, + isTargeted: false, + isTargetedCounter: null, + isTargetedSource: null, isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index 213a79e1fe..f64305c49e 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -113,6 +113,9 @@ const defaultMessageProps: TimelineMessagesProps = { isSignalConversation: false, isSMS: false, isSpoilerExpanded: {}, + isTargeted: false, + isTargetedCounter: null, + isTargetedSource: null, isVoiceMessagePlayed: false, handleDebugMessage: action('debugMessage'), toggleSelectMessage: action('toggleSelectMessage'), diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 56385203a6..687c3bc8a4 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -64,6 +64,9 @@ function mockMessageTimelineItem( isSignalConversation: false, isSMS: false, isSpoilerExpanded: {}, + isTargeted: false, + isTargetedCounter: null, + isTargetedSource: null, isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, @@ -385,7 +388,7 @@ const renderItem = ({ shouldCollapseBelow={false} shouldHideMetadata={false} shouldRenderDateHeader={false} - targetedMessage={undefined} + targetedMessage={null} {...actions()} /> ); diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index 2372e33453..f50f211e17 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -46,6 +46,7 @@ import { MessageInteractivity } from './Message.dom.tsx'; import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.tsx'; import type { CollapseSet } from '../../util/CollapseSet.std.ts'; import { tw } from '../../axo/tw.dom.tsx'; +import { TargetedMessageSource } from '../../state/ducks/conversationsEnums.std.ts'; const { first, get, isNumber, last, throttle } = lodash; @@ -126,7 +127,11 @@ export type PropsActionsType = { ) => unknown; markMessageRead: (conversationId: string, messageId: string) => unknown; maybePeekGroupCall: (conversationId: string) => unknown; - targetMessage: (messageId: string, conversationId: string) => unknown; + targetMessage: ( + messageId: string, + conversationId: string, + targetedMessageSource: TargetedMessageSource + ) => unknown; setCenterMessage: ( conversationId: string, messageId: string | undefined @@ -258,7 +263,7 @@ export class Timeline extends Component { const lastIndex = items.length - 1; const lastItem = items[lastIndex]; strictAssert(lastItem, 'Missing lastItem'); - targetMessage(lastItem.id, id); + targetMessage(lastItem.id, id, TargetedMessageSource.Focus); } else { const containerEl = this.#containerRef.current; if (containerEl) { @@ -319,7 +324,7 @@ export class Timeline extends Component { if (setFocus) { const item = items[oldestUnseenIndex]; strictAssert(item, 'Missing item at oldestUnseenIndex'); - targetMessage(item.id, id); + targetMessage(item.id, id, TargetedMessageSource.Focus); } else { lastSeenElement.scrollIntoView(); } @@ -941,7 +946,11 @@ export class Timeline extends Component { targetInnerMessage, 'No message at targetIndex in items.messages' ); - targetMessage(targetInnerMessage.id, id); + targetMessage( + targetInnerMessage.id, + id, + TargetedMessageSource.NavigateToMessage + ); event.preventDefault(); event.stopPropagation(); @@ -958,7 +967,7 @@ export class Timeline extends Component { const targetItem = items[targetIndex]; strictAssert(targetItem, 'Missing item at targetIndex'); if (targetItem.type === 'none') { - targetMessage(targetItem.id, id); + targetMessage(targetItem.id, id, TargetedMessageSource.Focus); event.preventDefault(); event.stopPropagation(); @@ -971,7 +980,7 @@ export class Timeline extends Component { : first(targetItem.messages); strictAssert(targetInnerMessage, 'Expect to get first/last of target'); - targetMessage(targetInnerMessage.id, id); + targetMessage(targetInnerMessage.id, id, TargetedMessageSource.Focus); event.preventDefault(); event.stopPropagation(); @@ -1047,7 +1056,7 @@ export class Timeline extends Component { if (direction === -1) { if (currentTop <= targetTop || index === 0) { - targetMessage(currentMessageId, id); + targetMessage(currentMessageId, id, TargetedMessageSource.Focus); event.preventDefault(); event.stopPropagation(); @@ -1057,7 +1066,7 @@ export class Timeline extends Component { const currentBottom = currentTop + currentRect.height; if (currentBottom > targetBottom || index === max - 1) { - targetMessage(currentMessageId, id); + targetMessage(currentMessageId, id, TargetedMessageSource.Focus); event.preventDefault(); event.stopPropagation(); @@ -1071,7 +1080,7 @@ export class Timeline extends Component { if (event.key === 'Home' || (commandOrCtrl && event.key === 'ArrowUp')) { const firstMessageId = first(items); if (firstMessageId) { - targetMessage(firstMessageId.id, id); + targetMessage(firstMessageId.id, id, TargetedMessageSource.Focus); event.preventDefault(); event.stopPropagation(); } diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index 7d5057449a..6157a725e9 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -52,7 +52,7 @@ const getDefaultProps = () => ({ isGroup: false, interactivity: MessageInteractivity.Normal, interactionMode: 'keyboard' as const, - targetedMessage: undefined, + targetedMessage: null, theme: ThemeType.light, platform: 'darwin', handleDebugMessage: action('handleDebugMessage'), diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index db33e0c888..a2cb07634e 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -74,6 +74,7 @@ import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.tsx import type { CollapseSet } from '../../util/CollapseSet.std.ts'; import { CollapseSetViewer } from './CollapseSet.dom.tsx'; import type { TargetedMessageType } from '../../state/selectors/conversations.dom.ts'; +import type { TargetedMessageSource } from '../../state/ducks/conversationsEnums.std.ts'; type CallHistoryType = { type: 'callHistory'; @@ -224,7 +225,11 @@ type PropsLocalType = { pollTimestamp: number, conversationId: string ) => unknown; - targetMessage: (messageId: string, conversationId: string) => unknown; + targetMessage: ( + messageId: string, + conversationId: string, + targetedMessageSource: TargetedMessageSource + ) => void; toggleSelectMessage: ( conversationId: string, messageId: string, @@ -239,7 +244,7 @@ type PropsLocalType = { renderUniversalTimerNotification: () => JSX.Element; renderItem: (props: RenderItemProps) => JSX.Element; i18n: LocalizerType; - targetedMessage: TargetedMessageType | undefined; + targetedMessage: TargetedMessageType | null; theme: ThemeType; }; diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index c43e3c0504..bfda3b5e30 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -304,6 +304,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToView: overrideProps.isTapToView, isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, + isTargeted: false, + isTargetedCounter: null, + isTargetedSource: null, isVoiceMessagePlayed: false, cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index 785418f969..6afbea3f36 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -55,7 +55,7 @@ export type PropsData = { canReply: boolean; canPinMessage: boolean; selectedReaction?: Emoji.Variant; - isTargeted?: boolean; + isTargeted: boolean; isSignalConversation: boolean; } & Omit; diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx index 76ac95f618..a1da754c74 100644 --- a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx @@ -232,6 +232,9 @@ export function GroupMemberLabelEditor({ isSelectMode={false} isSignalConversation={false} isSMS={false} + isTargeted={false} + isTargetedCounter={null} + isTargetedSource={null} isVoiceMessagePlayed={false} direction="incoming" // oxlint-disable-next-line react/purity diff --git a/ts/components/conversation/conversation-details/GroupMembersSearchDialog.dom.tsx b/ts/components/conversation/conversation-details/GroupMembersSearchDialog.dom.tsx index e0f57cd907..3645be3ddb 100644 --- a/ts/components/conversation/conversation-details/GroupMembersSearchDialog.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupMembersSearchDialog.dom.tsx @@ -239,10 +239,6 @@ function FilterMenu(props: FilterMenuProps): ReactNode { variant="implied-secondary" label={i18n('icu:GroupMembersSearchDialog__FilterMenu__Label')} pressed={props.filter !== GroupMembersSearchDialogFilter.All} - tooltip={{ - label: i18n('icu:GroupMembersSearchDialog__FilterMenu__Label'), - onlyShowOnFocusForUserInputDeviceEvents: true, - }} /> diff --git a/ts/services/addGlobalKeyboardShortcuts.preload.ts b/ts/services/addGlobalKeyboardShortcuts.preload.ts index 5964976ea4..84d2155e91 100644 --- a/ts/services/addGlobalKeyboardShortcuts.preload.ts +++ b/ts/services/addGlobalKeyboardShortcuts.preload.ts @@ -421,7 +421,7 @@ export function addGlobalKeyboardShortcuts(): void { window.reduxActions.composer.setQuoteByMessageId( conversation.id, - quote ? undefined : targetedMessage + quote ? undefined : (targetedMessage ?? undefined) ); return; diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 33f1dd849d..9913fe4d5e 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -634,9 +634,9 @@ export type ConversationsStateType = ReadonlyDeep<{ conversationsByGroupId: ConversationLookupType; conversationsByUsername: ConversationLookupType; - targetedMessage: string | undefined; + targetedMessage: string | null; targetedMessageCounter: number; - targetedMessageSource: TargetedMessageSource | undefined; + targetedMessageSource: TargetedMessageSource | null; lastSelectedMessage: MessageTimestamps | undefined; selectedMessageIds: ReadonlyArray | undefined; @@ -831,6 +831,7 @@ export type MessageTargetedActionType = ReadonlyDeep<{ type: 'MESSAGE_TARGETED'; payload: { messageId: string; + targetedMessageSource: TargetedMessageSource; }; }>; export type ToggleSelectMessagesActionType = ReadonlyDeep<{ @@ -3149,7 +3150,8 @@ function removeAllConversations(): RemoveAllConversationsActionType { function targetMessage( messageId: string, - conversationId: string + conversationId: string, + targetedMessageSource: TargetedMessageSource ): ThunkAction { return async (dispatch, getState) => { const selectedConversationId = getSelectedConversationId(getState()); @@ -3165,6 +3167,7 @@ function targetMessage( type: 'MESSAGE_TARGETED', payload: { messageId, + targetedMessageSource, }, }); }; @@ -4895,7 +4898,7 @@ function showConversation({ function onConversationOpened( conversationId: string, messageId: string | undefined, - targetedMessageSource: TargetedMessageSource | undefined + targetedMessageSource: TargetedMessageSource | null ): ThunkAction< void, RootStateType, @@ -5267,9 +5270,9 @@ export function getEmptyState(): ConversationsStateType { lastCenterMessageByConversation: {}, messagesByConversation: {}, messagesLookup: {}, - targetedMessage: undefined, + targetedMessage: null, targetedMessageCounter: 0, - targetedMessageSource: undefined, + targetedMessageSource: null, lastSelectedMessage: undefined, selectedMessageIds: undefined, showArchived: false, @@ -6067,7 +6070,7 @@ export function reducer( ...state, targetedMessage: messageId, targetedMessageCounter: state.targetedMessageCounter + 1, - targetedMessageSource: TargetedMessageSource.Focus, + targetedMessageSource: action.payload.targetedMessageSource, }; } @@ -6830,9 +6833,9 @@ export function reducer( if (action.type === 'CLEAR_TARGETED_MESSAGE') { return { ...state, - targetedMessage: undefined, + targetedMessage: null, targetedMessageCounter: 0, - targetedMessageSource: undefined, + targetedMessageSource: null, }; } if (action.type === 'CLEAR_UNREAD_METRICS') { @@ -6871,7 +6874,7 @@ export function reducer( ? state.preloadData : undefined, hasContactSpoofingReview: false, - targetedMessage: messageId, + targetedMessage: messageId ?? null, targetedMessageSource: messageId ? TargetedMessageSource.NavigateToMessage : TargetedMessageSource.Reset, diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 17a02bd4c2..77a86577d0 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -200,23 +200,25 @@ export const getSafeConversationWithSameTitle = createSelector( export type TargetedMessageType = { id: string; counter: number; + source: TargetedMessageSource | null; }; export const getTargetedMessage = createSelector( getConversations, - (state: ConversationsStateType): TargetedMessageType | undefined => { + (state: ConversationsStateType): TargetedMessageType | null => { if (!state.targetedMessage) { - return undefined; + return null; } return { id: state.targetedMessage, counter: state.targetedMessageCounter, + source: state.targetedMessageSource, }; } ); export const getTargetedMessageSource = createSelector( getConversations, - (state: ConversationsStateType): TargetedMessageSource | undefined => { + (state: ConversationsStateType): TargetedMessageSource | null => { return state.targetedMessageSource; } ); diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index ed9f1cca8e..32bf105433 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -185,6 +185,7 @@ import type { PinnedMessageNotificationData } from '../../components/conversatio import type { PollTerminateNotificationDataType } from '../../components/conversation/PollTerminateNotification.dom.tsx'; import { Emoji } from '../../axo/emoji.std.ts'; import { isDownloadableOrBackfillable } from '../../util/downloadAttachment.preload.ts'; +import type { TargetedMessageSource } from '../ducks/conversationsEnums.std.ts'; const { groupBy, isEmpty, isNumber, isObject, map } = lodash; @@ -214,8 +215,9 @@ export type GetPropsForBubbleOptions = Readonly<{ ourNumber?: string; ourAci: AciString | undefined; ourPni: PniString | undefined; - targetedMessageId?: string; - targetedMessageCounter?: number; + targetedMessageId: string | null; + targetedMessageCounter: number | null; + targetedMessageSource: TargetedMessageSource | null; pinnedMessagesMessageIds: ReadonlyArray | null; selectedMessageIds: ReadonlyArray | undefined; regionCode?: string; @@ -818,6 +820,7 @@ export type GetPropsForMessageOptions = Pick< | 'ourNumber' | 'targetedMessageId' | 'targetedMessageCounter' + | 'targetedMessageSource' | 'pinnedMessagesMessageIds' | 'selectedMessageIds' | 'regionCode' @@ -925,6 +928,7 @@ const getPropsForMessage = ( regionCode, targetedMessageId, targetedMessageCounter, + targetedMessageSource, pinnedMessagesMessageIds, selectedMessageIds, contactNameColors, @@ -1056,7 +1060,8 @@ const getPropsForMessage = ( isSignalConversation: isSignalConversation(author), isSticker: Boolean(sticker), isTargeted, - isTargetedCounter: isTargeted ? targetedMessageCounter : undefined, + isTargetedCounter: isTargeted ? targetedMessageCounter : null, + isTargetedSource: isTargeted ? targetedMessageSource : null, isTapToView: isMessageTapToView, isTapToViewError: isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, @@ -1121,8 +1126,9 @@ export const getMessagePropsSelector = createSelector( ourAci, ourPni, regionCode, - targetedMessageCounter: targetedMessage?.counter, - targetedMessageId: targetedMessage?.id, + targetedMessageCounter: targetedMessage?.counter ?? null, + targetedMessageId: targetedMessage?.id ?? null, + targetedMessageSource: targetedMessage?.source ?? null, pinnedMessagesMessageIds, selectedMessageIds, defaultConversationColor, @@ -2992,6 +2998,9 @@ export const getMessageDetailsSelector = createSelector( regionCode, pinnedMessagesMessageIds, selectedMessageIds, + targetedMessageId: null, + targetedMessageCounter: null, + targetedMessageSource: null, defaultConversationColor, hasMediaBackups, getStoryReplyAttachment, diff --git a/ts/state/selectors/timeline.preload.ts b/ts/state/selectors/timeline.preload.ts index 8b061c2610..51da4caaff 100644 --- a/ts/state/selectors/timeline.preload.ts +++ b/ts/state/selectors/timeline.preload.ts @@ -95,8 +95,9 @@ const getTimelineItem = ( ourAci, ourPni, regionCode, - targetedMessageId: targetedMessage?.id, - targetedMessageCounter: targetedMessage?.counter, + targetedMessageId: targetedMessage?.id ?? null, + targetedMessageCounter: targetedMessage?.counter ?? null, + targetedMessageSource: targetedMessage?.source ?? null, contactNameColors, callSelector, callHistorySelector, diff --git a/ts/util/getNotificationDataForMessage.preload.ts b/ts/util/getNotificationDataForMessage.preload.ts index 96fdc90d1c..0567c6940f 100644 --- a/ts/util/getNotificationDataForMessage.preload.ts +++ b/ts/util/getNotificationDataForMessage.preload.ts @@ -441,7 +441,7 @@ export function getNotificationDataForMessage( callHistorySelector: getCallHistorySelector(state), conversationSelector: getConversationSelector(state), selectedMessageIds: getSelectedMessageIds(state), - targetedMessageId: getTargetedMessage(state)?.id, + targetedMessageId: getTargetedMessage(state)?.id ?? null, }); if (callingNotification) { const text = getCallingNotificationText(callingNotification, i18n);