diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e8cd42dc7c..b2fdfedea9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2143,6 +2143,7 @@ $timer-icons: .module-inline-notification-wrapper { outline: none; + position: relative; &:focus { @include mixins.keyboard-mode { diff --git a/ts/components/conversation/CollapseSet.dom.stories.tsx b/ts/components/conversation/CollapseSet.dom.stories.tsx index 8b1368ffd0..f48e9af1ad 100644 --- a/ts/components/conversation/CollapseSet.dom.stories.tsx +++ b/ts/components/conversation/CollapseSet.dom.stories.tsx @@ -40,9 +40,11 @@ const defaultProps: Props = { isBlocked: false, isGroup: true, isSelectMode: false, + isSelected: false, renderItem, targetedMessage: undefined, toggleDeleteMessagesModal: action('toggleDeleteMessagesModal'), + toggleSelectMessage: action('toggleSelectMessage'), }; export function GroupWithTwo(): React.JSX.Element { @@ -57,6 +59,19 @@ export function GroupWithTwo(): React.JSX.Element { return ; } +export function GroupWithTwoSelectedCannotCollapse(): React.JSX.Element { + const props: Props = { + ...defaultProps, + type: 'group-updates', + isSelected: true, + messages: [ + { id: 'id1', isUnseen: false }, + { id: 'id2', isUnseen: false }, + ], + }; + return ; +} + export function AutoexpandIfTargeted(): React.JSX.Element { const props: Props = { ...defaultProps, diff --git a/ts/components/conversation/CollapseSet.dom.tsx b/ts/components/conversation/CollapseSet.dom.tsx index ba6543281f..18e4132193 100644 --- a/ts/components/conversation/CollapseSet.dom.tsx +++ b/ts/components/conversation/CollapseSet.dom.tsx @@ -34,9 +34,16 @@ export type Props = CollapseSet & { isBlocked: boolean; isGroup: boolean; isSelectMode: boolean; + isSelected: boolean; renderItem: (props: RenderItemProps) => React.JSX.Element; targetedMessage: TargetedMessageType | undefined; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; + toggleSelectMessage: ( + conversationId: string, + messageId: string, + shift: boolean, + selected: boolean + ) => void; }; export function CollapseSetViewer(props: Props): React.JSX.Element { @@ -51,10 +58,12 @@ export function CollapseSetViewer(props: Props): React.JSX.Element { conversationId, isBlocked, isGroup, + isSelected, messages, renderItem, targetedMessage, toggleDeleteMessagesModal, + toggleSelectMessage, } = props; const [isExpanded, setIsExpanded] = useState(false); const [messageCache, setMessageCache] = useState< @@ -151,6 +160,10 @@ export function CollapseSetViewer(props: Props): React.JSX.Element { dayCount={collapsedDayCount} isExpanded={isExpanded} onClick={() => { + if (isSelected) { + return; + } + setIsAnimating(true); setIsExpanded(value => !value); }} @@ -160,13 +173,20 @@ export function CollapseSetViewer(props: Props): React.JSX.Element { messageIds: collapsedMessages.map(item => item.id), }); }} + onSelect={() => { + collapsedMessages.forEach(message => { + toggleSelectMessage(conversationId, message.id, false, true); + }); + }} /> ) : undefined}
{ if (event.propertyName === 'height') { @@ -177,12 +197,12 @@ export function CollapseSetViewer(props: Props): React.JSX.Element {
- {shouldShowButton && (isExpanded || isAnimating) ? ( + {shouldShowButton && (isSelected || isExpanded || isAnimating) ? ( <> {collapsedMessages.map((child, index) => { const previousMessage = messages[index - 1]; @@ -204,9 +224,10 @@ export function CollapseSetViewer(props: Props): React.JSX.Element { containerElementRef, containerWidthBreakpoint, conversationId, - interactivity: isExpanded - ? MessageInteractivity.Normal - : MessageInteractivity.Hidden, + interactivity: + isSelected || isExpanded + ? MessageInteractivity.Normal + : MessageInteractivity.Hidden, isBlocked, isGroup, isOldestTimelineItem, @@ -263,12 +284,23 @@ function CollapseSetButton( isExpanded: boolean; isGroup: boolean; isSelectMode: boolean; + isSelected: boolean; i18n: LocalizerType; onClick: () => unknown; onDelete: () => unknown; + onSelect: () => unknown; } ): React.JSX.Element { - const { count, dayCount, i18n, isExpanded, onClick, onDelete, type } = props; + const { + count, + dayCount, + i18n, + isExpanded, + isSelected, + onClick, + onDelete, + type, + } = props; strictAssert( type !== 'none', @@ -317,7 +349,7 @@ function CollapseSetButton( }); } - const trailingIcon = isExpanded ? ( + let trailingIcon = isExpanded ? ( ); + if (isSelected) { + trailingIcon = ; + } return ( ) => { + if (onClick) { + onClick(); + event.stopPropagation(); + event.preventDefault(); + } + }} > {(isSignalConversation || isMe) && ( diff --git a/ts/components/conversation/InlineNotificationWrapper.dom.stories.tsx b/ts/components/conversation/InlineNotificationWrapper.dom.stories.tsx new file mode 100644 index 0000000000..55069df1a7 --- /dev/null +++ b/ts/components/conversation/InlineNotificationWrapper.dom.stories.tsx @@ -0,0 +1,40 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import type { Meta } from '@storybook/react'; + +import { InlineNotificationWrapper } from './InlineNotificationWrapper.dom.js'; +import { action } from '@storybook/addon-actions'; +import { tw } from '../../axo/tw.dom.js'; + +import type { Props } from './InlineNotificationWrapper.dom.js'; + +export default { + title: 'Components/Conversation/InlineNotificationWrapper', + args: { + conversationId: 'cId1', + isTargeted: false, + isSelected: false, + targetMessage: action('targetMessage'), + toggleSelectMessage: action('toggleSelectMessage'), + children: ( +
+ This is the default contents +
+ ), + }, +} satisfies Meta; + +export function Default(args: Props): React.JSX.Element { + return ; +} + +export function SelectMode(args: Props): React.JSX.Element { + return ; +} + +export function SelectModeAndSelected(args: Props): React.JSX.Element { + return ; +} diff --git a/ts/components/conversation/InlineNotificationWrapper.dom.tsx b/ts/components/conversation/InlineNotificationWrapper.dom.tsx index ab376880ff..368d20a5f4 100644 --- a/ts/components/conversation/InlineNotificationWrapper.dom.tsx +++ b/ts/components/conversation/InlineNotificationWrapper.dom.tsx @@ -1,15 +1,26 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +import type { ReactNode } from 'react'; + import { getInteractionMode } from '../../services/InteractionMode.dom.ts'; -type PropsType = { +export type Props = { id: string; conversationId: string; isTargeted: boolean; + isSelectMode: boolean; + isSelected: boolean; targetMessage: (messageId: string, conversationId: string) => unknown; + toggleSelectMessage: ( + conversationId: string, + messageId: string, + shift: boolean, + selected: boolean + ) => void; children: ReactNode; }; @@ -17,9 +28,12 @@ export function InlineNotificationWrapper({ id, conversationId, isTargeted, + isSelectMode, + isSelected, targetMessage, + toggleSelectMessage, children, -}: PropsType): React.JSX.Element { +}: Props): React.JSX.Element { const focusRef = useRef(null); useEffect(() => { @@ -37,6 +51,45 @@ export function InlineNotificationWrapper({ } }, [id, conversationId, targetMessage]); + if (isSelectMode) { + return ( +
) => { + toggleSelectMessage(conversationId, id, event.shiftKey, !isSelected); + event.stopPropagation(); + event.preventDefault(); + }} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.code === 'Space') { + toggleSelectMessage( + conversationId, + id, + event.shiftKey, + !isSelected + ); + event.stopPropagation(); + event.preventDefault(); + } + }} + > + + {children} +
+ ); + } + return (
{ direction, getPreferredBadge, i18n, + isSelectMode, shouldCollapseBelow, showContactModal, theme, @@ -2341,6 +2342,7 @@ export class Message extends React.PureComponent { 'module-message__author-avatar-container--with-reactions': this.#hasReactions(), })} + inert={isSelectMode ? true : undefined} > {shouldCollapseBelow ? ( @@ -3313,6 +3315,7 @@ export class Message extends React.PureComponent { deletedForEveryone, direction, id, + isSelectMode, isSticker, isTapToView, renderMessageContextMenu, @@ -3375,7 +3378,7 @@ export class Message extends React.PureComponent { } function maybeWrapWithContextMenu(children: ReactNode): ReactNode { - if (renderMessageContextMenu) { + if (renderMessageContextMenu && !isSelectMode) { return renderMessageContextMenu('AxoContextMenu', children); } return children; @@ -3397,6 +3400,7 @@ export class Message extends React.PureComponent { ev.stopPropagation(); }} tabIndex={-1} + inert={isSelectMode ? true : undefined} > {this.#renderAuthor()}
@@ -3561,7 +3565,6 @@ export class Message extends React.PureComponent { role="row" onFocus={this.handleFocus} ref={this.focusRef} - inert={isSelectMode} > {this.#renderError()} {this.#renderAvatar()} diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index e2eb011710..8ce6481fc7 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -362,6 +362,7 @@ const renderItem = ({ isBlocked={false} isGroup={false} isSelectMode={false} + isSelected={false} i18n={i18n} interactivity={MessageInteractivity.Normal} interactionMode="keyboard" diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index e8e8e817d7..69707c85b7 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -44,6 +44,7 @@ const getDefaultProps = () => ({ isNextItemCallingNotification: false, isPinned: false, isSelectMode: false, + isSelected: false, isTargeted: false, isBlocked: false, isGroup: false, diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index cdad4133c3..c4b5cc134f 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -216,6 +216,7 @@ type PropsLocalType = { isGroup: boolean; isNextItemCallingNotification: boolean; isSelectMode: boolean; + isSelected: boolean; isTargeted: boolean; scrollToPinnedMessage: (pinMessage: PinMessageData) => void; scrollToPollMessage: ( @@ -224,6 +225,12 @@ type PropsLocalType = { conversationId: string ) => unknown; targetMessage: (messageId: string, conversationId: string) => unknown; + toggleSelectMessage: ( + conversationId: string, + messageId: string, + shift: boolean, + selected: boolean + ) => void; shouldRenderDateHeader: boolean; onOpenEditNicknameAndNoteModal: (contactId: string) => void; onOpenMessageRequestActionsConfirmation: (state: MessageRequestState) => void; @@ -267,6 +274,7 @@ export const TimelineItem = memo(function TimelineItem({ isGroup, isNextItemCallingNotification, isSelectMode, + isSelected, isTargeted, item, onOpenEditNicknameAndNoteModal, @@ -287,6 +295,7 @@ export const TimelineItem = memo(function TimelineItem({ shouldRenderDateHeader, targetedMessage, theme, + toggleSelectMessage, ...reducedProps }: PropsType): React.JSX.Element | null { if (!item) { @@ -317,6 +326,7 @@ export const TimelineItem = memo(function TimelineItem({ platform={platform} i18n={i18n} theme={theme} + toggleSelectMessage={toggleSelectMessage} /> ); } else if (item.type === 'collapseSet') { @@ -329,9 +339,11 @@ export const TimelineItem = memo(function TimelineItem({ isBlocked={isBlocked} isGroup={isGroup} isSelectMode={isSelectMode} + isSelected={isSelected} renderItem={renderItem} targetedMessage={targetedMessage} toggleDeleteMessagesModal={reducedProps.toggleDeleteMessagesModal} + toggleSelectMessage={toggleSelectMessage} i18n={i18n} /> ); @@ -518,7 +530,10 @@ export const TimelineItem = memo(function TimelineItem({ id={id} conversationId={conversationId} isTargeted={isTargeted} + isSelectMode={isSelectMode} + isSelected={isSelected} targetMessage={targetMessage} + toggleSelectMessage={toggleSelectMessage} > {notification} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 4fbdb97e82..879e07c6b7 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -151,6 +151,11 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( } : itemFromSelector; + const isSelected = + selectedMessageIds?.includes(messageId) || + (item.type !== 'none' && + item.messages.some(message => selectedMessageIds?.includes(message.id))); + const { blockGroupLinkRequests, cancelAttachmentDownload, @@ -247,6 +252,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( isNextItemCallingNotification={isNextItemCallingNotification} isTargeted={isTargeted} isSelectMode={selectedMessageIds != null} + isSelected={isSelected} renderAudioAttachment={renderAudioAttachment} renderContact={renderContact} renderReactionPicker={renderReactionPicker}