Init PinnedMessagesPanel

This commit is contained in:
Jamie
2025-11-20 13:18:31 -08:00
committed by GitHub
parent 60bb04a4fc
commit 9f8c3cd765
25 changed files with 336 additions and 37 deletions

View File

@@ -9,7 +9,11 @@ import type { AttachmentType } from '../types/Attachment.std.js';
import type { LocalizerType } from '../types/Util.std.js';
import type { MessagePropsType } from '../state/selectors/message.preload.js';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.js';
import { Message, TextDirection } from './conversation/Message.dom.js';
import {
Message,
MessageInteractivity,
TextDirection,
} from './conversation/Message.dom.js';
import { Modal } from './Modal.dom.js';
import { WidthBreakpoint } from './_util.std.js';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled.std.js';
@@ -132,6 +136,7 @@ export function EditHistoryMessagesModal({
displayLimit={displayLimitById[currentMessageId]}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
interactivity={MessageInteractivity.Static}
isEditedMessage
isSpoilerExpanded={revealedSpoilersById[currentMessageId] || {}}
key={currentMessage.timestamp}
@@ -204,6 +209,7 @@ export function EditHistoryMessagesModal({
{...MESSAGE_DEFAULT_PROPS}
{...messageAttributes}
id={syntheticId}
interactivity={MessageInteractivity.Static}
containerElementRef={containerElementRef}
displayLimit={displayLimitById[syntheticId]}
getPreferredBadge={getPreferredBadge}

View File

@@ -22,7 +22,11 @@ import { Avatar, AvatarSize } from './Avatar.dom.js';
import { CompositionInput } from './CompositionInput.dom.js';
import { ContactName } from './conversation/ContactName.dom.js';
import { Emojify } from './conversation/Emojify.dom.js';
import { Message, TextDirection } from './conversation/Message.dom.js';
import {
Message,
MessageInteractivity,
TextDirection,
} from './conversation/Message.dom.js';
import { MessageTimestamp } from './conversation/MessageTimestamp.dom.js';
import { Modal } from './Modal.dom.js';
import { ReactionPicker } from './conversation/ReactionPicker.dom.js';
@@ -673,6 +677,7 @@ function ReplyOrReactionMessage({
i18n={i18n}
platform={platform}
id={reply.id}
interactivity={MessageInteractivity.Normal}
interactionMode="mouse"
isSpoilerExpanded={isSpoilerExpanded}
isVoiceMessagePlayed={false}

View File

@@ -168,6 +168,15 @@ const TextDirectionToDirAttribute = {
export const Directions = ['incoming', 'outgoing'] as const;
export type DirectionType = (typeof Directions)[number];
export enum MessageInteractivity {
/** Enable all interactions for message type */
Normal = 'Normal',
/** Disable all interactions for message type */
Static = 'Static',
/** Enable some interactions for embedded messages (ex: PinnedMessagesPanel) */
Embed = 'Embed',
}
export type AudioAttachmentProps = {
renderingContext: string;
i18n: LocalizerType;
@@ -344,6 +353,7 @@ export type PropsHousekeeping = {
disableScroll?: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
interactivity: MessageInteractivity;
interactionMode: InteractionModeType;
platform: string;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
@@ -3297,6 +3307,7 @@ export class Message extends React.PureComponent<Props, State> {
attachments,
direction,
i18n,
interactivity,
isSticker,
isSelected,
isSelectMode,
@@ -3352,6 +3363,10 @@ export class Message extends React.PureComponent<Props, State> {
// prevent other click handlers from firing.
onClickCapture: event => {
if (isMacOS ? event.metaKey : event.ctrlKey) {
if (interactivity !== MessageInteractivity.Normal) {
return;
}
if (this.#hasSelectedTextRef.current) {
return;
}

View File

@@ -14,7 +14,7 @@ import type {
Props as MessagePropsType,
PropsData as MessagePropsDataType,
} from './Message.dom.js';
import { Message } from './Message.dom.js';
import { Message, MessageInteractivity } from './Message.dom.js';
import type { LocalizerType, ThemeType } from '../../types/Util.std.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges.preload.js';
@@ -351,6 +351,7 @@ export function MessageDetail({
endPoll={endPoll}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
interactivity={MessageInteractivity.Static}
interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted}

View File

@@ -10,7 +10,7 @@ import { ConversationColors } from '../../types/Colors.std.js';
import { pngUrl } from '../../storybook/Fixtures.std.js';
import type { Props as TimelineMessagesProps } from './TimelineMessage.dom.js';
import { TimelineMessage } from './TimelineMessage.dom.js';
import { TextDirection } from './Message.dom.js';
import { MessageInteractivity, TextDirection } from './Message.dom.js';
import {
AUDIO_MP3,
IMAGE_PNG,
@@ -101,6 +101,7 @@ const defaultMessageProps: TimelineMessagesProps = {
platform: 'darwin',
id: 'messageId',
// renderingContext: 'storybook',
interactivity: MessageInteractivity.Normal,
interactionMode: 'keyboard',
isBlocked: false,
isMessageRequestAccepted: true,

View File

@@ -19,7 +19,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing.std.js';
import { ReadStatus } from '../../messages/MessageReadStatus.std.js';
import type { WidthBreakpoint } from '../_util.std.js';
import { ThemeType } from '../../types/Util.std.js';
import { TextDirection } from './Message.dom.js';
import { MessageInteractivity, TextDirection } from './Message.dom.js';
import { PaymentEventKind } from '../../types/Payment.std.js';
import type { PropsData as TimelineMessageProps } from './TimelineMessage.dom.js';
import { CollidingAvatars } from '../CollidingAvatars.dom.js';
@@ -373,6 +373,7 @@ const renderItem = ({
isBlocked={false}
isGroup={false}
i18n={i18n}
interactivity={MessageInteractivity.Normal}
interactionMode="keyboard"
isNextItemCallingNotification={false}
theme={ThemeType.light}

View File

@@ -49,6 +49,7 @@ import {
createScrollerLock,
ScrollerLockContext,
} from '../../hooks/useScrollLock.dom.js';
import { MessageInteractivity } from './Message.dom.js';
const { first, get, isNumber, last, throttle } = lodash;
@@ -132,6 +133,7 @@ type PropsHousekeepingType = {
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
conversationId: string;
interactivity: MessageInteractivity;
isBlocked: boolean;
isGroup: boolean;
isOldestTimelineItem: boolean;
@@ -1069,6 +1071,7 @@ export class Timeline extends React.Component<
containerWidthBreakpoint: widthBreakpoint,
conversationId: id,
isBlocked,
interactivity: MessageInteractivity.Normal,
isGroup,
isOldestTimelineItem: haveOldest && itemIndex === 0,
messageId,

View File

@@ -15,6 +15,7 @@ import { WidthBreakpoint } from '../_util.std.js';
import { ThemeType } from '../../types/Util.std.js';
import { PaymentEventKind } from '../../types/Payment.std.js';
import { ErrorBoundary } from './ErrorBoundary.dom.js';
import { MessageInteractivity } from './Message.dom.js';
const { i18n } = window.SignalContext;
@@ -43,6 +44,7 @@ const getDefaultProps = () => ({
isTargeted: false,
isBlocked: false,
isGroup: false,
interactivity: MessageInteractivity.Normal,
interactionMode: 'keyboard' as const,
theme: ThemeType.light,
platform: 'darwin',

View File

@@ -67,6 +67,7 @@ import {
type MessageRequestResponseNotificationData,
} from './MessageRequestResponseNotification.dom.js';
import type { MessageRequestState } from './MessageRequestActionsConfirmation.dom.js';
import type { MessageInteractivity } from './Message.dom.js';
type CallHistoryType = {
type: 'callHistory';
@@ -200,6 +201,7 @@ type PropsLocalType = {
conversationId: string;
item?: TimelineItemType;
id: string;
interactivity: MessageInteractivity;
isBlocked: boolean;
isGroup: boolean;
isNextItemCallingNotification: boolean;
@@ -242,6 +244,7 @@ export const TimelineItem = memo(function TimelineItem({
getPreferredBadge,
i18n,
id,
interactivity,
isBlocked,
isGroup,
isNextItemCallingNotification,
@@ -281,6 +284,7 @@ export const TimelineItem = memo(function TimelineItem({
<TimelineMessage
{...reducedProps}
{...item.data}
interactivity={interactivity}
isTargeted={isTargeted}
targetMessage={targetMessage}
setMessageToEdit={setMessageToEdit}

View File

@@ -12,7 +12,7 @@ import { ConversationColors } from '../../types/Colors.std.js';
import type { AudioAttachmentProps } from './Message.dom.js';
import type { Props } from './TimelineMessage.dom.js';
import { TimelineMessage } from './TimelineMessage.dom.js';
import { TextDirection } from './Message.dom.js';
import { MessageInteractivity, TextDirection } from './Message.dom.js';
import {
AUDIO_MP3,
IMAGE_JPEG,
@@ -265,6 +265,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
platform: 'darwin',
id: overrideProps.id ?? 'random-message-id',
// renderingContext: 'storybook',
interactivity: MessageInteractivity.Normal,
interactionMode: overrideProps.interactionMode || 'keyboard',
isSticker: isBoolean(overrideProps.isSticker)
? overrideProps.isSticker

View File

@@ -13,7 +13,7 @@ import type { LocalizerType } from '../../types/I18N.std.js';
import { handleOutsideClick } from '../../util/handleOutsideClick.dom.js';
import { offsetDistanceModifier } from '../../util/popperUtil.std.js';
import { WidthBreakpoint } from '../_util.std.js';
import { Message } from './Message.dom.js';
import { Message, MessageInteractivity } from './Message.dom.js';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker.dom.js';
import type {
Props as MessageProps,
@@ -114,6 +114,7 @@ export function TimelineMessage(props: Props): JSX.Element {
direction,
i18n,
id,
interactivity,
isTargeted,
kickOffAttachmentDownload,
copyMessageText,
@@ -257,6 +258,8 @@ export function TimelineMessage(props: Props): JSX.Element {
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
const canSelect = interactivity === MessageInteractivity.Normal;
const handleDownload = canDownload ? openGenericAttachment : null;
const handleReplyToMessage = useCallback(() => {
@@ -317,7 +320,11 @@ export function TimelineMessage(props: Props): JSX.Element {
canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : null
}
onCopy={canCopy ? () => copyMessageText(id) : null}
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onSelect={
canSelect
? () => toggleSelectMessage(conversationId, id, false, true)
: null
}
onForward={
canForward
? () =>
@@ -350,6 +357,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canEditMessage,
canForward,
canRetry,
canSelect,
canEndPoll,
canRetryDeleteForEveryone,
conversationId,

View File

@@ -63,6 +63,9 @@ import {
getTooltipContent,
} from '../InAnotherCallTooltip.dom.js';
import { BadgeSustainerInstructionsDialog } from '../../BadgeSustainerInstructionsDialog.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import { isInternalFeaturesEnabled } from '../../../util/isInternalFeaturesEnabled.dom.js';
import { tw } from '../../../axo/tw.dom.js';
enum ModalState {
AddingGroupMembers,
@@ -725,6 +728,23 @@ export function ConversationDetails({
)}
</PanelSection>
)}
{isInternalFeaturesEnabled() && (
<PanelSection title="Internal">
<PanelRow
onClick={() =>
pushPanelForConversation({
type: PanelType.PinnedMessages,
})
}
icon={
<div className={tw('flex size-8 items-center justify-center')}>
<AxoSymbol.Icon symbol="pin" size={20} label={null} />
</div>
}
label="View all pinned messages"
/>
</PanelSection>
)}
{isGroup && (
<ConversationDetailsMembershipList
canAddNewMembers={canAddNewMembers}

View File

@@ -0,0 +1,85 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { Fragment, memo, useMemo, useRef, useState } from 'react';
import { useLayoutEffect } from '@react-aria/utils';
import type { LocalizerType } from '../../../types/I18N.std.js';
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
import type { PinnedMessage } from '../../../types/PinnedMessage.std.js';
import type { SmartTimelineItemProps } from '../../../state/smart/TimelineItem.preload.js';
import { WidthBreakpoint } from '../../_util.std.js';
import { AxoScrollArea } from '../../../axo/AxoScrollArea.dom.js';
import {
createScrollerLock,
ScrollerLockContext,
} from '../../../hooks/useScrollLock.dom.js';
import { getWidthBreakpoint } from '../../../util/timelineUtil.std.js';
import { strictAssert } from '../../../util/assert.std.js';
import { useSizeObserver } from '../../../hooks/useSizeObserver.dom.js';
import { MessageInteractivity } from '../Message.dom.js';
export type PinnedMessagesPanelProps = Readonly<{
i18n: LocalizerType;
conversation: ConversationType;
pinnedMessages: ReadonlyArray<PinnedMessage>;
renderTimelineItem: (props: SmartTimelineItemProps) => JSX.Element;
}>;
export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
props: PinnedMessagesPanelProps
) {
const containerElementRef = useRef<HTMLDivElement>(null);
const [containerWidthBreakpoint, setContainerWidthBreakpoint] = useState(
WidthBreakpoint.Wide
);
useLayoutEffect(() => {
strictAssert(containerElementRef.current, 'Missing container ref');
const container = containerElementRef.current;
setContainerWidthBreakpoint(getWidthBreakpoint(container.offsetWidth));
}, []);
useSizeObserver(containerElementRef, size => {
setContainerWidthBreakpoint(getWidthBreakpoint(size.width));
});
const scrollerLock = useMemo(() => {
return createScrollerLock('PinnedMessagesPanel', () => {
// noop - we probably don't need to do anything here because the only
// thing that can happen is the pinned messages getting removed/added
});
}, []);
return (
<AxoScrollArea.Root scrollbarWidth="wide">
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div ref={containerElementRef}>
<ScrollerLockContext.Provider value={scrollerLock}>
{props.pinnedMessages.map((pinnedMessage, pinnedMessageIndex) => {
const next = props.pinnedMessages[pinnedMessageIndex + 1];
const prev = props.pinnedMessages[pinnedMessageIndex - 1];
return (
<Fragment key={pinnedMessage.id}>
{props.renderTimelineItem({
containerElementRef,
containerWidthBreakpoint,
conversationId: props.conversation.id,
interactivity: MessageInteractivity.Embed,
isBlocked: props.conversation.isBlocked ?? false,
isGroup: props.conversation.type === 'group',
isOldestTimelineItem: pinnedMessageIndex === 0,
messageId: pinnedMessage.messageId,
nextMessageId: next?.messageId,
previousMessageId: prev?.messageId,
unreadIndicatorPlacement: undefined,
})}
</Fragment>
);
})}
</ScrollerLockContext.Provider>
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</AxoScrollArea.Root>
);
});

View File

@@ -6,12 +6,6 @@ import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
import type { LocalizerType } from '../../types/I18N.std.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import { isConversationUnread } from '../../util/isConversationUnread.std.js';
import {
Environment,
getEnvironment,
isMockEnvironment,
} from '../../environment.std.js';
import { isAlpha } from '../../util/version.std.js';
import { drop } from '../../util/drop.std.js';
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.js';
import { getMuteOptions } from '../../util/getMuteOptions.std.js';
@@ -29,28 +23,7 @@ import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { UserText } from '../UserText.dom.js';
import { isConversationMuted } from '../../util/isConversationMuted.std.js';
function isEnabled() {
const env = getEnvironment();
if (
env === Environment.Development ||
env === Environment.Test ||
isMockEnvironment()
) {
return true;
}
const version = window.getVersion?.();
if (version != null) {
if (isAlpha(version)) {
return true;
}
}
return false;
}
import { isInternalFeaturesEnabled } from '../../util/isInternalFeaturesEnabled.dom.js';
export type ChatFolderToggleChat = (
chatFolderId: ChatFolderId,
@@ -281,7 +254,7 @@ export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationLis
>
{i18n('icu:deleteConversation')}
</AxoContextMenu.Item>
{isEnabled() && (
{isInternalFeaturesEnabled() && (
<>
<AxoContextMenu.Separator />
<AxoContextMenu.Group>