Timeline: Include all item types in Select Mode

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal
2026-03-31 15:36:37 -05:00
committed by GitHub
parent d2bede015a
commit e85a9e7cba
11 changed files with 191 additions and 15 deletions

View File

@@ -2143,6 +2143,7 @@ $timer-icons:
.module-inline-notification-wrapper {
outline: none;
position: relative;
&:focus {
@include mixins.keyboard-mode {

View File

@@ -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 <CollapseSetViewer {...props} />;
}
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 <CollapseSetViewer {...props} />;
}
export function AutoexpandIfTargeted(): React.JSX.Element {
const props: Props = {
...defaultProps,

View File

@@ -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);
});
}}
/>
</div>
) : undefined}
<div
className={classNames(
'CollapseSet__height-container',
isExpanded ? 'CollapseSet__height-container--expanded' : undefined
isSelected || isExpanded
? 'CollapseSet__height-container--expanded'
: undefined
)}
onTransitionEnd={event => {
if (event.propertyName === 'height') {
@@ -177,12 +197,12 @@ export function CollapseSetViewer(props: Props): React.JSX.Element {
<div
className={classNames(
'CollapseSet__transparency-container',
isExpanded
isSelected || isExpanded
? 'CollapseSet__transparency-container--expanded'
: undefined
)}
>
{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 ? (
<AxoSymbol.InlineGlyph
symbol="chevron-up"
label={i18n('icu:collapsedItems--expanded')}
@@ -328,6 +360,9 @@ function CollapseSetButton(
label={i18n('icu:collapsedItems--collapsed')}
/>
);
if (isSelected) {
trailingIcon = <span />;
}
return (
<MessageContextMenu
@@ -345,7 +380,7 @@ function CollapseSetButton(
onRetryMessageSend={null}
onRetryDeleteForEveryone={null}
onCopy={null}
onSelect={null}
onSelect={props.onSelect}
onForward={null}
onMoreInfo={null}
onPinMessage={null}

View File

@@ -89,7 +89,13 @@ export function ContactName({
contactNameColor ? getClassName(`--${contactNameColor}`) : null
)}
dir="auto"
onClick={onClick}
onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (onClick) {
onClick();
event.stopPropagation();
event.preventDefault();
}
}}
>
<UserText text={text} />
{(isSignalConversation || isMe) && (

View File

@@ -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: (
<div className={tw('p-2.5 text-center')}>
This is the default contents
</div>
),
},
} satisfies Meta<Props>;
export function Default(args: Props): React.JSX.Element {
return <InlineNotificationWrapper {...args} />;
}
export function SelectMode(args: Props): React.JSX.Element {
return <InlineNotificationWrapper {...args} isSelectMode />;
}
export function SelectModeAndSelected(args: Props): React.JSX.Element {
return <InlineNotificationWrapper {...args} isSelectMode isSelected />;
}

View File

@@ -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<HTMLDivElement>(null);
useEffect(() => {
@@ -37,6 +51,45 @@ export function InlineNotificationWrapper({
}
}, [id, conversationId, targetMessage]);
if (isSelectMode) {
return (
<div
className={classNames(
'module-inline-notification-wrapper',
isSelected ? 'module-message__wrapper--selected' : undefined
)}
role="checkbox"
tabIndex={0}
ref={focusRef}
onFocus={handleFocus}
onClick={(event: React.MouseEvent<HTMLSpanElement>) => {
toggleSelectMessage(conversationId, id, event.shiftKey, !isSelected);
event.stopPropagation();
event.preventDefault();
}}
onKeyDown={(event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.code === 'Space') {
toggleSelectMessage(
conversationId,
id,
event.shiftKey,
!isSelected
);
event.stopPropagation();
event.preventDefault();
}
}}
>
<span
aria-label="Select"
className="module-message__select-checkbox"
aria-checked={isSelected}
/>
{children}
</div>
);
}
return (
<div
className="module-inline-notification-wrapper"

View File

@@ -2326,6 +2326,7 @@ export class Message extends React.PureComponent<Props, State> {
direction,
getPreferredBadge,
i18n,
isSelectMode,
shouldCollapseBelow,
showContactModal,
theme,
@@ -2341,6 +2342,7 @@ export class Message extends React.PureComponent<Props, State> {
'module-message__author-avatar-container--with-reactions':
this.#hasReactions(),
})}
inert={isSelectMode ? true : undefined}
>
{shouldCollapseBelow ? (
<AvatarSpacer size={GROUP_AVATAR_SIZE} />
@@ -3313,6 +3315,7 @@ export class Message extends React.PureComponent<Props, State> {
deletedForEveryone,
direction,
id,
isSelectMode,
isSticker,
isTapToView,
renderMessageContextMenu,
@@ -3375,7 +3378,7 @@ export class Message extends React.PureComponent<Props, State> {
}
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<Props, State> {
ev.stopPropagation();
}}
tabIndex={-1}
inert={isSelectMode ? true : undefined}
>
{this.#renderAuthor()}
<div dir={TextDirectionToDirAttribute[textDirection]}>
@@ -3561,7 +3565,6 @@ export class Message extends React.PureComponent<Props, State> {
role="row"
onFocus={this.handleFocus}
ref={this.focusRef}
inert={isSelectMode}
>
{this.#renderError()}
{this.#renderAvatar()}

View File

@@ -362,6 +362,7 @@ const renderItem = ({
isBlocked={false}
isGroup={false}
isSelectMode={false}
isSelected={false}
i18n={i18n}
interactivity={MessageInteractivity.Normal}
interactionMode="keyboard"

View File

@@ -44,6 +44,7 @@ const getDefaultProps = () => ({
isNextItemCallingNotification: false,
isPinned: false,
isSelectMode: false,
isSelected: false,
isTargeted: false,
isBlocked: false,
isGroup: false,

View File

@@ -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}
</InlineNotificationWrapper>

View File

@@ -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}