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}