mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-05 01:10:49 +00:00
Init PinnedMessagesPanel
This commit is contained in:
@@ -1678,6 +1678,10 @@
|
||||
"messageformat": "See all messages",
|
||||
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu > See all messages"
|
||||
},
|
||||
"icu:PinnedMessagesPanel__Title": {
|
||||
"messageformat": "Pinned messages",
|
||||
"description": "Conversation > Pinned messages panel (view all) > Title"
|
||||
},
|
||||
"icu:sessionEnded": {
|
||||
"messageformat": "Secure session reset",
|
||||
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -76,6 +76,7 @@ import { getMessageById } from '../../messages/getMessageById.preload.js';
|
||||
import { canReply, isNormalBubble } from '../selectors/message.preload.js';
|
||||
import { getAuthorId } from '../../messages/sources.preload.js';
|
||||
import {
|
||||
getActivePanel,
|
||||
getConversationSelector,
|
||||
getSelectedConversationId,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
@@ -835,6 +836,13 @@ export function setQuoteByMessageId(
|
||||
throw new Error('setQuoteByMessageId: No conversation found');
|
||||
}
|
||||
|
||||
const activePanel = getActivePanel(getState());
|
||||
if (activePanel != null && messageId != null) {
|
||||
// Reset the conversation panels and scroll to the message
|
||||
// in case we're inside of a conversation panel like pinned messages
|
||||
dispatch(scrollToMessage(conversationId, messageId));
|
||||
}
|
||||
|
||||
const draftEditMessage = conversation.get('draftEditMessage');
|
||||
// We can remove quotes, but we can't add them
|
||||
if (draftEditMessage && messageId) {
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
getMessagesByConversation,
|
||||
getPendingAvatarDownloadSelector,
|
||||
getAllConversations,
|
||||
getActivePanel,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import type {
|
||||
@@ -2102,6 +2103,13 @@ function setMessageToEdit(
|
||||
return;
|
||||
}
|
||||
|
||||
const activePanel = getActivePanel(getState());
|
||||
if (activePanel != null) {
|
||||
// Reset the conversation panels and scroll to the message
|
||||
// in case we're inside of a conversation panel like pinned messages
|
||||
dispatch(scrollToMessage(conversationId, messageId));
|
||||
}
|
||||
|
||||
setQuoteByMessageId(conversationId, undefined)(
|
||||
dispatch,
|
||||
getState,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { missingCaseError } from '../../util/missingCaseError.std.js';
|
||||
import { useConversationsActions } from '../ducks/conversations.preload.js';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion.dom.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.js';
|
||||
|
||||
const log = createLogger('ConversationPanel');
|
||||
|
||||
@@ -381,6 +382,10 @@ function PanelElement({
|
||||
);
|
||||
}
|
||||
|
||||
if (panel.type === PanelType.PinnedMessages) {
|
||||
return <SmartPinnedMessagesPanel conversationId={conversationId} />;
|
||||
}
|
||||
|
||||
if (panel.type === PanelType.StickerManager) {
|
||||
return <SmartStickerManager />;
|
||||
}
|
||||
@@ -399,6 +404,7 @@ function getPanelKey(panel: PanelRenderType): string {
|
||||
case PanelType.GroupPermissions:
|
||||
case PanelType.GroupV1Members:
|
||||
case PanelType.NotificationSettings:
|
||||
case PanelType.PinnedMessages:
|
||||
case PanelType.StickerManager:
|
||||
return panel.type;
|
||||
case PanelType.MessageDetails:
|
||||
|
||||
102
ts/state/smart/PinnedMessagesPanel.preload.tsx
Normal file
102
ts/state/smart/PinnedMessagesPanel.preload.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import type { AciString } from '@signalapp/mock-server/src/types.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { PinnedMessagesPanel } from '../../components/conversation/pinned-messages/PinnedMessagesPanel.dom.js';
|
||||
import type { SmartTimelineItemProps } from './TimelineItem.preload.js';
|
||||
import { SmartTimelineItem } from './TimelineItem.preload.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import { isAciString } from '../../util/isAciString.std.js';
|
||||
|
||||
export type SmartPinnedMessagesPanelProps = Readonly<{
|
||||
conversationId: string;
|
||||
}>;
|
||||
|
||||
function renderTimelineItem(props: SmartTimelineItemProps) {
|
||||
return <SmartTimelineItem {...props} />;
|
||||
}
|
||||
|
||||
const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
||||
createSelector(
|
||||
(state: StateType) => state.conversations,
|
||||
conversations => {
|
||||
const selectedConversationId =
|
||||
conversations.selectedConversationId ?? null;
|
||||
if (selectedConversationId == null) {
|
||||
throw new Error();
|
||||
}
|
||||
const messageIds =
|
||||
conversations.messagesByConversation[selectedConversationId]
|
||||
?.messageIds ?? [];
|
||||
|
||||
const ourAci = itemStorage.user.getCheckedAci();
|
||||
|
||||
return messageIds
|
||||
.map(messageId => {
|
||||
return conversations.messagesLookup[messageId] ?? null;
|
||||
})
|
||||
.filter(message => {
|
||||
return message.type === 'incoming' || message.type === 'outgoing';
|
||||
})
|
||||
.slice(-10)
|
||||
.map((message, messageIndex): PinnedMessage => {
|
||||
let messageSenderAci: AciString;
|
||||
if (message.type === 'outgoing') {
|
||||
messageSenderAci = ourAci;
|
||||
} else {
|
||||
strictAssert(
|
||||
isAciString(message.sourceServiceId),
|
||||
'sourceServiceId must be aci string for incoming message'
|
||||
);
|
||||
messageSenderAci = message.sourceServiceId;
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageIndex as PinnedMessageId,
|
||||
conversationId: selectedConversationId,
|
||||
messageId: message.id,
|
||||
messageSentAt: message.sent_at,
|
||||
messageSenderAci,
|
||||
pinnedByAci: ourAci,
|
||||
pinnedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
|
||||
props: SmartPinnedMessagesPanelProps
|
||||
) {
|
||||
const i18n = useSelector(getIntl);
|
||||
const conversationSelector = useSelector(getConversationByIdSelector);
|
||||
const conversation = conversationSelector(props.conversationId);
|
||||
|
||||
strictAssert(
|
||||
conversation,
|
||||
'<SmartPinnedMessagesPanel> expected a conversation to be found'
|
||||
);
|
||||
|
||||
const mockPinnedMessages = useSelector(mockSelectPinnedMessages);
|
||||
|
||||
return (
|
||||
<PinnedMessagesPanel
|
||||
i18n={i18n}
|
||||
conversation={conversation}
|
||||
pinnedMessages={mockPinnedMessages}
|
||||
renderTimelineItem={renderTimelineItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -58,6 +58,7 @@ function renderItem({
|
||||
containerElementRef,
|
||||
containerWidthBreakpoint,
|
||||
conversationId,
|
||||
interactivity,
|
||||
isBlocked,
|
||||
isGroup,
|
||||
isOldestTimelineItem,
|
||||
@@ -71,6 +72,7 @@ function renderItem({
|
||||
containerElementRef={containerElementRef}
|
||||
containerWidthBreakpoint={containerWidthBreakpoint}
|
||||
conversationId={conversationId}
|
||||
interactivity={interactivity}
|
||||
isBlocked={isBlocked}
|
||||
isGroup={isGroup}
|
||||
isOldestTimelineItem={isOldestTimelineItem}
|
||||
|
||||
@@ -39,11 +39,13 @@ import { renderAudioAttachment } from './renderAudioAttachment.preload.js';
|
||||
import { renderReactionPicker } from './renderReactionPicker.dom.js';
|
||||
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation.dom.js';
|
||||
import { TargetedMessageSource } from '../ducks/conversationsEnums.std.js';
|
||||
import type { MessageInteractivity } from '../../components/conversation/Message.dom.js';
|
||||
|
||||
export type SmartTimelineItemProps = {
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
conversationId: string;
|
||||
interactivity: MessageInteractivity;
|
||||
isBlocked: boolean;
|
||||
isGroup: boolean;
|
||||
isOldestTimelineItem: boolean;
|
||||
@@ -67,6 +69,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
containerElementRef,
|
||||
containerWidthBreakpoint,
|
||||
conversationId,
|
||||
interactivity,
|
||||
isBlocked,
|
||||
isGroup,
|
||||
isOldestTimelineItem,
|
||||
@@ -209,6 +212,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
shouldRenderDateHeader={shouldRenderDateHeader}
|
||||
showEditHistoryModal={showEditHistoryModal}
|
||||
i18n={i18n}
|
||||
interactivity={interactivity}
|
||||
interactionMode={interactionMode}
|
||||
isBlocked={isBlocked}
|
||||
isGroup={isGroup}
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum PanelType {
|
||||
GroupV1Members = 'GroupV1Members',
|
||||
MessageDetails = 'MessageDetails',
|
||||
NotificationSettings = 'NotificationSettings',
|
||||
PinnedMessages = 'PinnedMessages',
|
||||
StickerManager = 'StickerManager',
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ export type PanelRequestType = ReadonlyDeep<
|
||||
| { type: PanelType.GroupV1Members }
|
||||
| { type: PanelType.MessageDetails; args: { messageId: string } }
|
||||
| { type: PanelType.NotificationSettings }
|
||||
| { type: PanelType.PinnedMessages }
|
||||
| { type: PanelType.StickerManager }
|
||||
>;
|
||||
|
||||
@@ -57,5 +59,6 @@ export type PanelRenderType = ReadonlyDeep<
|
||||
args: { message: ReadonlyMessageAttributesType };
|
||||
}
|
||||
| { type: PanelType.NotificationSettings }
|
||||
| { type: PanelType.PinnedMessages }
|
||||
| { type: PanelType.StickerManager }
|
||||
>;
|
||||
|
||||
@@ -47,6 +47,10 @@ export function getConversationTitleForPanelType(
|
||||
return i18n('icu:ConversationDetails--notifications');
|
||||
}
|
||||
|
||||
if (panelType === PanelType.PinnedMessages) {
|
||||
return i18n('icu:PinnedMessagesPanel__Title');
|
||||
}
|
||||
|
||||
if (panelType === PanelType.StickerManager) {
|
||||
return '';
|
||||
}
|
||||
|
||||
26
ts/util/isInternalFeaturesEnabled.dom.ts
Normal file
26
ts/util/isInternalFeaturesEnabled.dom.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import {
|
||||
Environment,
|
||||
getEnvironment,
|
||||
isMockEnvironment,
|
||||
} from '../environment.std.js';
|
||||
import * as RemoteConfig from '../RemoteConfig.dom.js';
|
||||
|
||||
/**
|
||||
* This should be reserved for internal-only features that are focused on
|
||||
* debugging and development, and will never be enabled in production.
|
||||
*/
|
||||
export function isInternalFeaturesEnabled(): boolean {
|
||||
const env = getEnvironment();
|
||||
|
||||
if (
|
||||
env === Environment.Development ||
|
||||
env === Environment.Test ||
|
||||
isMockEnvironment()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return RemoteConfig.isEnabled('desktop.internalUser');
|
||||
}
|
||||
@@ -1950,6 +1950,13 @@
|
||||
"updated": "2025-11-06T20:28:00.760Z",
|
||||
"reasonDetail": "Ref for timer"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx",
|
||||
"line": " const containerElementRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2025-11-20T18:33:59.075Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/fun/FunGif.dom.tsx",
|
||||
|
||||
Reference in New Issue
Block a user