mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-02 08:13:37 +01:00
Timeline: Include all item types in Select Mode
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
@@ -2143,6 +2143,7 @@ $timer-icons:
|
||||
|
||||
.module-inline-notification-wrapper {
|
||||
outline: none;
|
||||
position: relative;
|
||||
|
||||
&:focus {
|
||||
@include mixins.keyboard-mode {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -362,6 +362,7 @@ const renderItem = ({
|
||||
isBlocked={false}
|
||||
isGroup={false}
|
||||
isSelectMode={false}
|
||||
isSelected={false}
|
||||
i18n={i18n}
|
||||
interactivity={MessageInteractivity.Normal}
|
||||
interactionMode="keyboard"
|
||||
|
||||
@@ -44,6 +44,7 @@ const getDefaultProps = () => ({
|
||||
isNextItemCallingNotification: false,
|
||||
isPinned: false,
|
||||
isSelectMode: false,
|
||||
isSelected: false,
|
||||
isTargeted: false,
|
||||
isBlocked: false,
|
||||
isGroup: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user