Integrate pinned messages bar/panel

This commit is contained in:
Jamie
2025-12-15 10:14:20 -08:00
committed by GitHub
parent 8edbe6ac78
commit acc9fd604f
26 changed files with 768 additions and 338 deletions

View File

@@ -462,6 +462,10 @@ const renderMiniPlayer = () => (
<div>If active, this is where smart mini player would be</div>
);
const renderPinnedMessagesBar = () => (
<div>If active, this is where the smart pinned messages bar would be</div>
);
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
discardMessages: action('discardMessages'),
getPreferredBadge: () => undefined,
@@ -482,6 +486,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
scrollToIndex: overrideProps.scrollToIndex ?? null,
scrollToIndexCounter: 0,
shouldShowMiniPlayer: Boolean(overrideProps.shouldShowMiniPlayer),
shouldShowPinnedMessagesBar: false,
totalUnseen: overrideProps.totalUnseen ?? 0,
oldestUnseenIndex: overrideProps.oldestUnseenIndex ?? 0,
invitedContactsForNewlyCreatedGroup:
@@ -494,6 +499,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
renderItem,
renderHeroRow,
renderMiniPlayer,
renderPinnedMessagesBar,
renderTypingBubble,
renderCollidingAvatars,
renderContactSpoofingReviewDialog,

View File

@@ -103,6 +103,7 @@ type PropsHousekeepingType = {
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
selectedMessageId?: string;
shouldShowMiniPlayer: boolean;
shouldShowPinnedMessagesBar: boolean;
warning?: WarningType;
hasContactSpoofingReview: boolean | undefined;
@@ -143,6 +144,7 @@ type PropsHousekeepingType = {
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}) => JSX.Element;
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
renderPinnedMessagesBar: () => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
};
@@ -936,10 +938,12 @@ export class Timeline extends React.Component<
renderHeroRow,
renderItem,
renderMiniPlayer,
renderPinnedMessagesBar,
renderTypingBubble,
reviewConversationNameCollision,
scrollToOldestUnreadMention,
shouldShowMiniPlayer,
shouldShowPinnedMessagesBar,
theme,
totalUnseen,
unreadCount,
@@ -1086,7 +1090,7 @@ export class Timeline extends React.Component<
const warning = Timeline.getWarning(this.props, this.state);
let headerElements: ReactNode;
if (warning || shouldShowMiniPlayer) {
if (warning || shouldShowMiniPlayer || shouldShowPinnedMessagesBar) {
let text: ReactChild | undefined;
let icon: ReactChild | undefined;
let onClose: () => void;
@@ -1180,7 +1184,10 @@ export class Timeline extends React.Component<
>
{measureRef => (
<TimelineWarnings ref={measureRef}>
{renderMiniPlayer({ shouldFlow: true })}
{shouldShowMiniPlayer && renderMiniPlayer({ shouldFlow: true })}
{!shouldShowMiniPlayer &&
shouldShowPinnedMessagesBar &&
renderPinnedMessagesBar()}
{text && (
<TimelineWarning i18n={i18n} onClose={onClose}>
{icon}

View File

@@ -63,9 +63,6 @@ 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,
@@ -728,23 +725,6 @@ 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

@@ -64,7 +64,7 @@ const PIN_3: Pin = {
bodyRanges: [],
},
attachment: {
type: 'photo',
type: 'image',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
},
@@ -133,15 +133,15 @@ export function Variants(): JSX.Element {
message={{ text: { body: SHORT_TEXT, bodyRanges: [] } }}
/>
<Variant
title="Photo attachment with text"
title="Image attachment with text"
message={{
text: { body: SHORT_TEXT, bodyRanges: [] },
attachment: { type: 'photo', url: IMAGE_URL },
attachment: { type: 'image', url: IMAGE_URL },
}}
/>
<Variant
title="Photo attachment"
message={{ attachment: { type: 'photo', url: IMAGE_URL } }}
title="Image attachment"
message={{ attachment: { type: 'image', url: IMAGE_URL } }}
/>
<Variant
title="Video attachment with text"
@@ -158,10 +158,7 @@ export function Variants(): JSX.Element {
title="Voice message"
message={{ attachment: { type: 'voiceMessage' } }}
/>
<Variant
title="GIF message"
message={{ attachment: { type: 'gif', url: IMAGE_URL } }}
/>
<Variant title="GIF message" message={{ attachment: { type: 'gif' } }} />
<Variant
title="File"
message={{ attachment: { type: 'file', name: 'project.zip' } }}
@@ -172,10 +169,6 @@ export function Variants(): JSX.Element {
/>
<Variant title="Sticker" message={{ sticker: true }} />
<Variant title="Contact" message={{ contact: { name: 'Tyler' } }} />
<Variant
title="Address"
message={{ contact: { address: '742 Evergreen Terrace' } }}
/>
<Variant title="Payment" message={{ payment: true }} />
</Stack>
);

View File

@@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
import { Tabs } from 'radix-ui';
import type { LocalizerType } from '../../../types/I18N.std.js';
import { tw } from '../../../axo/tw.dom.js';
@@ -24,34 +24,41 @@ export type PinMessageText = Readonly<{
bodyRanges: HydratedBodyRangesType;
}>;
export type PinMessageAttachment = Readonly<{
type: 'photo' | 'video' | 'voiceMessage' | 'gif' | 'file';
name?: string;
url?: string;
export type PinMessageAttachment = Readonly<
| { type: 'image'; url: string | null }
| { type: 'video'; url: string | null }
| { type: 'voiceMessage' }
| { type: 'gif' }
| { type: 'file'; name: string | null }
>;
export type PinMessageContact = Readonly<{
name: string | null;
}>;
export type PinMessagePoll = Readonly<{
question: string;
}>;
export type PinMessage = Readonly<{
id: string;
text?: PinMessageText;
attachment?: PinMessageAttachment;
contact?: {
name?: string;
address?: string;
};
payment?: true;
poll?: {
question: string;
};
sticker?: true;
text?: PinMessageText | null;
attachment?: PinMessageAttachment | null;
contact?: PinMessageContact | null;
payment?: boolean;
poll?: PinMessagePoll | null;
sticker?: boolean;
}>;
export type PinSender = Readonly<{
id: string;
title: string;
isMe: boolean;
}>;
export type Pin = Readonly<{
id: PinnedMessageId;
sender: {
id: string;
title: string;
isMe: boolean;
};
sender: PinSender;
message: PinMessage;
}>;
@@ -60,8 +67,8 @@ export type PinnedMessagesBarProps = Readonly<{
pins: ReadonlyArray<Pin>;
current: PinnedMessageId;
onCurrentChange: (current: PinnedMessageId) => void;
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
onPinGoTo: (messageId: string) => void;
onPinRemove: (messageId: string) => void;
onPinsShowAll: () => void;
}>;
@@ -222,22 +229,32 @@ function TabTrigger(props: {
);
}
function Content(props: {
i18n: LocalizerType;
pin: Pin;
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
onPinsShowAll: () => void;
}) {
const { i18n, pin, onPinGoTo, onPinRemove, onPinsShowAll } = props;
const Content = forwardRef(function Content(
props: {
i18n: LocalizerType;
pin: Pin;
onPinGoTo: (messageId: string) => void;
onPinRemove: (messageId: string) => void;
onPinsShowAll: () => void;
},
ref: ForwardedRef<HTMLDivElement>
): JSX.Element {
const {
i18n,
pin,
onPinGoTo,
onPinRemove,
onPinsShowAll,
...forwardedProps
} = props;
const handlePinGoTo = useCallback(() => {
onPinGoTo(pin.id);
}, [onPinGoTo, pin.id]);
onPinGoTo(pin.message.id);
}, [onPinGoTo, pin.message.id]);
const handlePinRemove = useCallback(() => {
onPinRemove(pin.id);
}, [onPinRemove, pin.id]);
onPinRemove(pin.message.id);
}, [onPinRemove, pin.message.id]);
const handlePinsShowAll = useCallback(() => {
onPinsShowAll();
@@ -248,7 +265,11 @@ function Content(props: {
}, [pin.message]);
return (
<div className={tw('flex min-w-0 flex-1 flex-row items-center')}>
<div
ref={ref}
{...forwardedProps}
className={tw('flex min-w-0 flex-1 flex-row items-center')}
>
{thumbnailUrl != null && <ImageThumbnail url={thumbnailUrl} />}
<div className={tw('min-w-0 flex-1')}>
<h1 className={tw('type-body-small font-semibold text-label-primary')}>
@@ -297,14 +318,14 @@ function Content(props: {
</AriaClickable.SubWidget>
</div>
);
}
});
function getThumbnailUrl(message: PinMessage): string | null {
if (message.attachment == null) {
return null;
}
if (
message.attachment.type === 'photo' ||
message.attachment.type === 'image' ||
message.attachment.type === 'video'
) {
return message.attachment.url ?? null;
@@ -353,23 +374,13 @@ function getMessagePreviewIcon(
};
}
}
if (message.contact?.name != null) {
if (message.contact != null) {
return {
symbol: 'person-circle',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Contact'
),
label: message.contact.name ?? i18n('icu:unknownContact'),
};
}
if (message.contact?.address != null) {
return {
symbol: 'location',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Address'
),
};
}
if (message.payment != null) {
if (message.payment) {
return {
symbol: 'creditcard',
label: i18n(
@@ -383,7 +394,7 @@ function getMessagePreviewIcon(
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll'),
};
}
if (message.sticker != null) {
if (message.sticker) {
return {
symbol: 'sticker',
label: i18n(
@@ -402,7 +413,7 @@ function getMessagePreviewText(
return <MessageTextPreview i18n={i18n} text={message.text} />;
}
if (message.attachment != null) {
if (message.attachment.type === 'photo') {
if (message.attachment.type === 'image') {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Photo');
}
if (message.attachment.type === 'video') {
@@ -417,14 +428,11 @@ function getMessagePreviewText(
if (message.attachment.type === 'file') {
return <UserText text={message.attachment.name ?? ''} />;
}
throw missingCaseError(message.attachment.type);
throw missingCaseError(message.attachment);
}
if (message.contact?.name != null) {
return <UserText text={message.contact.name} />;
}
if (message.contact?.address != null) {
return <UserText text={message.contact.address} />;
}
if (message.payment != null) {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Payment');
}

View File

@@ -12,7 +12,7 @@ import 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 { PinnedMessageRenderData } 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';
@@ -30,7 +30,7 @@ import { AxoButton } from '../../../axo/AxoButton.dom.js';
export type PinnedMessagesPanelProps = Readonly<{
i18n: LocalizerType;
conversation: ConversationType;
pinnedMessages: ReadonlyArray<PinnedMessage>;
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
renderTimelineItem: (props: SmartTimelineItemProps) => JSX.Element;
}>;
@@ -60,7 +60,7 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
const next = props.pinnedMessages[pinnedMessageIndex + 1];
const prev = props.pinnedMessages[pinnedMessageIndex - 1];
return (
<Fragment key={pinnedMessage.id}>
<Fragment key={pinnedMessage.pinnedMessage.id}>
{props.renderTimelineItem({
containerElementRef,
containerWidthBreakpoint,
@@ -69,9 +69,9 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
isBlocked: props.conversation.isBlocked ?? false,
isGroup: props.conversation.type === 'group',
isOldestTimelineItem: pinnedMessageIndex === 0,
messageId: pinnedMessage.messageId,
nextMessageId: next?.messageId,
previousMessageId: prev?.messageId,
messageId: pinnedMessage.message.id,
nextMessageId: next?.message.id,
previousMessageId: prev?.message.id,
unreadIndicatorPlacement: undefined,
})}
</Fragment>

View File

@@ -93,7 +93,7 @@ export async function onPinnedMessageAdd(
}
}
window.reduxActions.conversations.onPinnedMessagesChanged(
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
targetConversation.id
);
}
@@ -138,7 +138,7 @@ export async function onPinnedMessageRemove(
`Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}`
);
window.reduxActions.conversations.onPinnedMessagesChanged(
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
targetConversationId
);
}

View File

@@ -69,12 +69,14 @@ import type {
PinnedMessage,
PinnedMessageId,
PinnedMessageParams,
PinnedMessageRenderData,
} from '../types/PinnedMessage.std.js';
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
import type {
RemoteMegaphoneId,
RemoteMegaphoneType,
} from '../types/Megaphone.std.js';
import { QueryFragment, sqlJoin } from './util.std.js';
export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never };
@@ -191,6 +193,12 @@ export const MESSAGE_COLUMNS = [
...MESSAGE_NON_PRIMARY_KEY_COLUMNS,
] as const;
export const MESSAGE_COLUMNS_FRAGMENTS = MESSAGE_COLUMNS.map(
column => new QueryFragment(column, [])
);
export const MESSAGE_COLUMNS_SELECT = sqlJoin(MESSAGE_COLUMNS_FRAGMENTS);
export type MessageTypeUnhydrated = {
json: string;
@@ -980,7 +988,7 @@ type ReadableInterface = {
getPinnedMessagesForConversation: (
conversationId: string
) => ReadonlyArray<PinnedMessage>;
) => ReadonlyArray<PinnedMessageRenderData>;
getNextExpiringPinnedMessageAcrossConversations: () => PinnedMessage | null;
getMessagesNeedingUpgrade: (

View File

@@ -49,7 +49,7 @@ import { isNormalNumber } from '../util/isNormalNumber.std.js';
import { isNotNil } from '../util/isNotNil.std.js';
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.js';
import { updateSchema } from './migrations/index.node.js';
import type { JSONRows } from './util.std.js';
import type { JSONRows, QueryFragment } from './util.std.js';
import {
batchMultiVarQuery,
bulkAdd,
@@ -68,7 +68,6 @@ import {
sqlConstant,
sqlFragment,
sqlJoin,
QueryFragment,
convertOptionalBooleanToInteger,
} from './util.std.js';
import {
@@ -199,6 +198,8 @@ import type {
import {
AttachmentDownloadSource,
MESSAGE_COLUMNS,
MESSAGE_COLUMNS_FRAGMENTS,
MESSAGE_COLUMNS_SELECT,
MESSAGE_ATTACHMENT_COLUMNS,
MESSAGE_NON_PRIMARY_KEY_COLUMNS,
} from './Interface.std.js';
@@ -783,10 +784,6 @@ export const DataWriter: ServerWritableInterface = {
runCorruptionChecks,
};
const MESSAGE_COLUMNS_FRAGMENTS = MESSAGE_COLUMNS.map(
column => new QueryFragment(column, [])
);
function rowToConversation(row: ConversationRow): ConversationType {
const { expireTimerVersion } = row;
const parsedJson = JSON.parse(row.json);
@@ -3534,7 +3531,7 @@ function getUnreadReactionsAndMarkRead(
return db
.prepare(
`
UPDATE reactions
UPDATE reactions
INDEXED BY reactions_unread
SET unread = 0
WHERE
@@ -3782,7 +3779,7 @@ function getRecentStoryReplies(
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
SELECT
${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)}
${MESSAGE_COLUMNS_SELECT}
FROM messages
WHERE
(${messageId ?? null} IS NULL OR id IS NOT ${messageId ?? null}) AND

View File

@@ -5,21 +5,65 @@ import type {
PinnedMessage,
PinnedMessageId,
PinnedMessageParams,
PinnedMessageRenderData,
} from '../../types/PinnedMessage.std.js';
import { strictAssert } from '../../util/assert.std.js';
import type { ReadableDB, WritableDB } from '../Interface.std.js';
import { hydrateMessage } from '../hydration.std.js';
import type {
MessageTypeUnhydrated,
MessageType,
ReadableDB,
WritableDB,
} from '../Interface.std.js';
import { sql } from '../util.std.js';
function _getMessageById(
db: ReadableDB,
messageId: string
): MessageType | null {
const [query, params] = sql`
SELECT * FROM messages
WHERE id = ${messageId}
`;
const row = db.prepare(query).get<MessageTypeUnhydrated>(params);
if (row == null) {
return null;
}
return hydrateMessage(db, row);
}
function _getPinnedMessageRenderData(
db: ReadableDB,
pinnedMessage: PinnedMessage
): PinnedMessageRenderData {
const message = _getMessageById(db, pinnedMessage.messageId);
strictAssert(
message != null,
`Missing message ${pinnedMessage.messageId} for pinned message ${pinnedMessage.id}`
);
return { pinnedMessage, message };
}
export function getPinnedMessagesForConversation(
db: ReadableDB,
conversationId: string
): ReadonlyArray<PinnedMessage> {
const [query, params] = sql`
SELECT * FROM pinnedMessages
WHERE conversationId = ${conversationId}
ORDER BY pinnedAt DESC
`;
return db.prepare(query).all<PinnedMessage>(params);
): ReadonlyArray<PinnedMessageRenderData> {
return db.transaction(() => {
const [query, params] = sql`
SELECT * FROM pinnedMessages
WHERE conversationId = ${conversationId}
ORDER BY pinnedAt DESC
`;
return db
.prepare(query)
.all<PinnedMessage>(params)
.map(pinnedMessage => {
return _getPinnedMessageRenderData(db, pinnedMessage);
});
})();
}
function _getPinnedMessageByMessageId(

View File

@@ -27,6 +27,7 @@ import { actions as mediaGallery } from './ducks/mediaGallery.preload.js';
import { actions as nav } from './ducks/nav.std.js';
import { actions as network } from './ducks/network.dom.js';
import { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
import { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
import { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
import { actions as search } from './ducks/search.preload.js';
import { actions as stickers } from './ducks/stickers.preload.js';
@@ -65,6 +66,7 @@ export const actionCreators: ReduxActions = {
nav,
network,
notificationProfiles,
pinnedMessages,
safetyNumber,
search,
stickers,

View File

@@ -96,7 +96,6 @@ import {
getPendingAvatarDownloadSelector,
getAllConversations,
getActivePanel,
getSelectedConversationId,
} from '../selectors/conversations.dom.js';
import { getIntl } from '../selectors/user.std.js';
import type {
@@ -244,10 +243,7 @@ import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { enqueuePollVoteForSend as enqueuePollVoteForSendHelper } from '../../polls/enqueuePollVoteForSend.preload.js';
import { updateChatFolderStateOnTargetConversationChanged } from './chatFolders.preload.js';
import type { PinnedMessage } from '../../types/PinnedMessage.std.js';
import type { StateThunk } from '../types.std.js';
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
const {
chunk,
@@ -508,7 +504,7 @@ export type ConversationMessageType = ReadonlyDeep<{
export type ConversationPreloadDataType = ReadonlyDeep<{
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
pinnedMessages: ReadonlyArray<PinnedMessage>;
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
metrics: MessageMetricsType;
unboundedFetch: boolean;
}>;
@@ -642,7 +638,6 @@ export type ConversationsStateType = ReadonlyDeep<{
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
pinnedMessages: ReadonlyArray<PinnedMessage>;
lastCenterMessageByConversation: LastCenterMessageByConversationType;
@@ -716,7 +711,9 @@ export const SET_PENDING_REQUESTED_AVATAR_DOWNLOAD =
'conversations/SET_PENDING_REQUESTED_AVATAR_DOWNLOAD';
export const SET_PROFILE_UPDATE_ERROR =
'conversations/SET_PROFILE_UPDATE_ERROR';
const REPLACE_PINNED_MESSAGES = 'conversations/REPLACE_PINNED_MESSAGES';
export const ADD_PRELOAD_DATA = 'conversations/ADD_PRELOAD_DATA';
export const CONSUME_PRELOAD_DATA = 'conversations/CONSUME_PRELOAD_DATA';
export const MESSAGES_RESET = 'conversations/MESSAGES_RESET';
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
@@ -952,7 +949,7 @@ export type RepairOldestMessageActionType = ReadonlyDeep<{
};
}>;
export type MessagesResetActionType = ReadonlyDeep<{
type: 'MESSAGES_RESET';
type: typeof MESSAGES_RESET;
payload: MessagesResetDataType;
}>;
export type SetMessageLoadingStateActionType = ReadonlyDeep<{
@@ -1097,19 +1094,12 @@ type ReplaceAvatarsActionType = ReadonlyDeep<{
avatars: ReadonlyArray<AvatarDataType>;
};
}>;
type ReplacePinnedMessagesActionType = ReadonlyDeep<{
type: typeof REPLACE_PINNED_MESSAGES;
payload: {
conversationId: string;
pinnedMessages: ReadonlyArray<PinnedMessage>;
};
}>;
export type AddPreloadDataActionType = ReadonlyDeep<{
type: 'ADD_PRELOAD_DATA';
type: typeof ADD_PRELOAD_DATA;
payload: ConversationPreloadDataType;
}>;
export type ConsumePreloadDataActionType = ReadonlyDeep<{
type: 'CONSUME_PRELOAD_DATA';
type: typeof CONSUME_PRELOAD_DATA;
payload: {
conversationId: string;
};
@@ -1159,7 +1149,6 @@ export type ConversationActionType =
| RepairNewestMessageActionType
| RepairOldestMessageActionType
| ReplaceAvatarsActionType
| ReplacePinnedMessagesActionType
| ReviewConversationNameCollisionActionType
| ScrollToMessageActionType
| SetPendingRequestedAvatarDownloadActionType
@@ -1255,9 +1244,6 @@ export const actions = {
onArchive,
onMarkUnread,
onMoveToInbox,
onPinnedMessagesChanged,
onPinnedMessageAdd,
onPinnedMessageRemove,
onUndoArchive,
openGiftBadge,
popPanelForConversation,
@@ -3408,7 +3394,7 @@ function messagesReset({
}
return {
type: 'MESSAGES_RESET',
type: MESSAGES_RESET,
payload: {
unboundedFetch: unboundedFetch ?? false,
conversationId,
@@ -3432,7 +3418,7 @@ function addPreloadData(
}
return {
type: 'ADD_PRELOAD_DATA',
type: ADD_PRELOAD_DATA,
payload: preloadData,
};
}
@@ -3440,7 +3426,7 @@ function consumePreloadData(
conversationId: string
): ConsumePreloadDataActionType {
return {
type: 'CONSUME_PRELOAD_DATA',
type: CONSUME_PRELOAD_DATA,
payload: {
conversationId,
},
@@ -5117,107 +5103,6 @@ function startAvatarDownload(
};
}
function getMessageAuthorAci(
message: ReadonlyMessageAttributesType
): AciString {
if (isIncoming(message)) {
strictAssert(
isAciString(message.sourceServiceId),
'Message sourceServiceId must be an ACI'
);
return message.sourceServiceId;
}
return itemStorage.user.getCheckedAci();
}
type PinnedMessageTarget = ReadonlyDeep<{
conversationId: string;
targetMessageId: string;
targetAuthorAci: AciString;
targetSentTimestamp: number;
}>;
async function getPinnedMessageTarget(
targetMessageId: string
): Promise<PinnedMessageTarget> {
const message = await DataReader.getMessageById(targetMessageId);
if (message == null) {
throw new Error('getPinnedMessageTarget: Target message not found');
}
return {
conversationId: message.conversationId,
targetMessageId: message.id,
targetAuthorAci: getMessageAuthorAci(message),
targetSentTimestamp: message.sent_at,
};
}
function onPinnedMessagesChanged(
conversationId: string
): StateThunk<ReplacePinnedMessagesActionType> {
return async (dispatch, getState) => {
const selectedConversationId = getSelectedConversationId(getState());
if (
selectedConversationId == null ||
selectedConversationId !== conversationId
) {
return;
}
const pinnedMessages =
await DataReader.getPinnedMessagesForConversation(conversationId);
dispatch({
type: REPLACE_PINNED_MESSAGES,
payload: {
conversationId,
pinnedMessages,
},
});
};
}
function onPinnedMessageAdd(
targetMessageId: string,
pinDurationSeconds: DurationInSeconds | null
): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.PinMessage,
...target,
pinDurationSeconds,
});
const pinnedMessagesLimit = getPinnedMessagesLimit();
const pinnedAt = Date.now();
const expiresAt = getPinnedMessageExpiresAt(pinnedAt, pinDurationSeconds);
await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
conversationId: target.conversationId,
messageId: target.targetMessageId,
expiresAt,
pinnedAt,
});
dispatch(onPinnedMessagesChanged(target.conversationId));
};
}
function onPinnedMessageRemove(targetMessageId: string): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.UnpinMessage,
...target,
});
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
dispatch(onPinnedMessagesChanged(target.conversationId));
};
}
// Reducer
export function getEmptyState(): ConversationsStateType {
@@ -5231,7 +5116,6 @@ export function getEmptyState(): ConversationsStateType {
lastCenterMessageByConversation: {},
messagesByConversation: {},
messagesLookup: {},
pinnedMessages: [],
targetedMessage: undefined,
targetedMessageCounter: 0,
targetedMessageSource: undefined,
@@ -5638,7 +5522,6 @@ function updateMessageLookup(
conversationId,
messages,
metrics,
pinnedMessages,
scrollToMessageId,
unboundedFetch,
}: MessagesResetDataType
@@ -5681,7 +5564,6 @@ function updateMessageLookup(
targetedMessage: scrollToMessageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Reset,
pinnedMessages,
}
: {}),
messagesLookup: {
@@ -5999,7 +5881,6 @@ export function reducer(
messagesByConversation: omit(state.messagesByConversation, [
conversationId,
]),
pinnedMessages: [],
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
@@ -6431,16 +6312,16 @@ export function reducer(
};
}
if (action.type === 'MESSAGES_RESET') {
if (action.type === MESSAGES_RESET) {
return updateMessageLookup(state, action.payload);
}
if (action.type === 'ADD_PRELOAD_DATA') {
if (action.type === ADD_PRELOAD_DATA) {
return {
...state,
preloadData: action.payload,
};
}
if (action.type === 'CONSUME_PRELOAD_DATA') {
if (action.type === CONSUME_PRELOAD_DATA) {
const { preloadData, selectedConversationId } = state;
const { conversationId } = action.payload;
if (!preloadData) {
@@ -7593,21 +7474,6 @@ export function reducer(
};
}
if (action.type === REPLACE_PINNED_MESSAGES) {
// Discard new pinned messages if the `selectedConversationId` has changed.
if (
state.selectedConversationId == null ||
action.payload.conversationId !== state.selectedConversationId
) {
return state;
}
return {
...state,
pinnedMessages: action.payload.pinnedMessages,
};
}
if (
action.type === CHANGE_LOCATION &&
action.payload.selectedLocation.tab === NavTab.Chats

View File

@@ -0,0 +1,299 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
import { strictAssert } from '../../util/assert.std.js';
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
import type { ReadonlyMessageAttributesType } from '../../model-types.js';
import type { AciString } from '../../types/ServiceId.std.js';
import { isIncoming } from '../selectors/message.preload.js';
import { isAciString } from '../../util/isAciString.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import type { StateThunk } from '../types.std.js';
import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue.preload.js';
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
import { getSelectedConversationId } from '../selectors/conversations.dom.js';
import type {
AddPreloadDataActionType,
ConsumePreloadDataActionType,
ConversationUnloadedActionType,
MessageChangedActionType,
MessagesResetActionType,
TargetedConversationChangedActionType,
} from './conversations.preload.js';
import {
ADD_PRELOAD_DATA,
CONSUME_PRELOAD_DATA,
CONVERSATION_UNLOADED,
MESSAGE_CHANGED,
TARGETED_CONVERSATION_CHANGED,
} from './conversations.preload.js';
type PreloadData = ReadonlyDeep<{
conversationId: string;
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
}>;
export type PinnedMessagesState = ReadonlyDeep<{
preloadData: PreloadData | null;
conversationId: string | null;
pinnedMessages: ReadonlyArray<PinnedMessageRenderData> | null;
}>;
const PINNED_MESSAGES_REPLACE = 'pinnedMessages/PINNED_MESSAGES_REPLACE';
export type PinnedMessagesReplace = ReadonlyDeep<{
type: typeof PINNED_MESSAGES_REPLACE;
payload: {
conversationId: string;
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
};
}>;
export type PinnedMessagesAction = ReadonlyDeep<PinnedMessagesReplace>;
export function getEmptyState(): PinnedMessagesState {
return {
preloadData: null,
conversationId: null,
pinnedMessages: null,
};
}
function getMessageAuthorAci(
message: ReadonlyMessageAttributesType
): AciString {
if (isIncoming(message)) {
strictAssert(
isAciString(message.sourceServiceId),
'Message sourceServiceId must be an ACI'
);
return message.sourceServiceId;
}
return itemStorage.user.getCheckedAci();
}
type PinnedMessageTarget = ReadonlyDeep<{
conversationId: string;
targetMessageId: string;
targetAuthorAci: AciString;
targetSentTimestamp: number;
}>;
async function getPinnedMessageTarget(
targetMessageId: string
): Promise<PinnedMessageTarget> {
const message = await DataReader.getMessageById(targetMessageId);
if (message == null) {
throw new Error('getPinnedMessageTarget: Target message not found');
}
return {
conversationId: message.conversationId,
targetMessageId: message.id,
targetAuthorAci: getMessageAuthorAci(message),
targetSentTimestamp: message.sent_at,
};
}
function onPinnedMessagesChanged(
conversationId: string
): StateThunk<PinnedMessagesReplace> {
return async (dispatch, getState) => {
const selectedConversationId = getSelectedConversationId(getState());
if (
selectedConversationId == null ||
selectedConversationId !== conversationId
) {
return;
}
const pinnedMessages =
await DataReader.getPinnedMessagesForConversation(conversationId);
dispatch({
type: PINNED_MESSAGES_REPLACE,
payload: {
conversationId,
pinnedMessages,
},
});
};
}
function onPinnedMessageAdd(
targetMessageId: string,
pinDurationSeconds: DurationInSeconds | null
): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.PinMessage,
...target,
pinDurationSeconds,
});
const pinnedMessagesLimit = getPinnedMessagesLimit();
const pinnedAt = Date.now();
const expiresAt = getPinnedMessageExpiresAt(pinnedAt, pinDurationSeconds);
await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
conversationId: target.conversationId,
messageId: target.targetMessageId,
expiresAt,
pinnedAt,
});
dispatch(onPinnedMessagesChanged(target.conversationId));
};
}
function onPinnedMessageRemove(targetMessageId: string): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.UnpinMessage,
...target,
});
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
dispatch(onPinnedMessagesChanged(target.conversationId));
};
}
export const actions = {
onPinnedMessagesChanged,
onPinnedMessageAdd,
onPinnedMessageRemove,
};
export const usePinnedMessagesActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function updateMessageInPinnedMessages(
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>,
message: ReadonlyMessageAttributesType
): ReadonlyArray<PinnedMessageRenderData> {
return pinnedMessages.map(pinnedMessage => {
if (pinnedMessage.message.id === message.id) {
return { ...pinnedMessage, message };
}
return pinnedMessage;
});
}
export function reducer(
state: PinnedMessagesState = getEmptyState(),
action:
| PinnedMessagesAction
| MessageChangedActionType
| TargetedConversationChangedActionType
| ConversationUnloadedActionType
| AddPreloadDataActionType
| ConsumePreloadDataActionType
| MessagesResetActionType
): PinnedMessagesState {
switch (action.type) {
case PINNED_MESSAGES_REPLACE:
if (state.conversationId !== action.payload.conversationId) {
return state;
}
return {
...state,
pinnedMessages: action.payload.pinnedMessages,
};
case TARGETED_CONVERSATION_CHANGED: {
const conversationId = action.payload.conversationId ?? null;
return {
...state,
preloadData:
conversationId != null &&
state.preloadData != null &&
state.preloadData.conversationId === conversationId
? state.preloadData
: null,
conversationId,
pinnedMessages: null,
};
}
case CONVERSATION_UNLOADED:
if (state.conversationId !== action.payload.conversationId) {
return state;
}
return {
...state,
conversationId: null,
pinnedMessages: null,
};
case ADD_PRELOAD_DATA:
return {
...state,
preloadData: {
conversationId: action.payload.conversationId,
pinnedMessages: action.payload.pinnedMessages,
},
};
case CONSUME_PRELOAD_DATA:
if (state.preloadData == null) {
return state;
}
if (state.preloadData.conversationId === action.payload.conversationId) {
return {
...state,
preloadData: null,
conversationId: state.preloadData.conversationId,
pinnedMessages: state.preloadData.pinnedMessages,
};
}
return {
...state,
preloadData: null,
};
case MESSAGE_CHANGED: {
let nextState = state;
if (
nextState.conversationId === action.payload.conversationId &&
nextState.pinnedMessages != null
) {
nextState = {
...nextState,
pinnedMessages: updateMessageInPinnedMessages(
nextState.pinnedMessages,
action.payload.data
),
};
}
if (
nextState.preloadData != null &&
nextState.preloadData.conversationId === action.payload.id
) {
nextState = {
...nextState,
preloadData: {
...nextState.preloadData,
pinnedMessages: updateMessageInPinnedMessages(
nextState.preloadData.pinnedMessages,
action.payload.data
),
},
};
}
return nextState;
}
default:
return state;
}
}

View File

@@ -30,6 +30,7 @@ import { getEmptyState as mediaGalleryEmptyState } from './ducks/mediaGallery.pr
import { getEmptyState as navEmptyState } from './ducks/nav.std.js';
import { getEmptyState as networkEmptyState } from './ducks/network.dom.js';
import { getEmptyState as notificationProfilesEmptyState } from './ducks/notificationProfiles.preload.js';
import { getEmptyState as pinnedMessagesEmptyState } from './ducks/pinnedMessages.preload.js';
import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions.preload.js';
import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber.preload.js';
import { getEmptyState as searchEmptyState } from './ducks/search.preload.js';
@@ -172,6 +173,7 @@ function getEmptyState(): StateType {
nav: navEmptyState(),
network: networkEmptyState(),
notificationProfiles: notificationProfilesEmptyState(),
pinnedMessages: pinnedMessagesEmptyState(),
preferredReactions: preferredReactionsEmptyState(),
safetyNumber: safetyNumberEmptyState(),
search: searchEmptyState(),

View File

@@ -91,6 +91,10 @@ export function initializeRedux(data: ReduxInitData): void {
),
nav: bindActionCreators(actionCreators.nav, store.dispatch),
network: bindActionCreators(actionCreators.network, store.dispatch),
pinnedMessages: bindActionCreators(
actionCreators.pinnedMessages,
store.dispatch
),
notificationProfiles: bindActionCreators(
actionCreators.notificationProfiles,
store.dispatch

View File

@@ -29,6 +29,7 @@ import { reducer as mediaGallery } from './ducks/mediaGallery.preload.js';
import { reducer as nav } from './ducks/nav.std.js';
import { reducer as network } from './ducks/network.dom.js';
import { reducer as notificationProfiles } from './ducks/notificationProfiles.preload.js';
import { reducer as pinnedMessages } from './ducks/pinnedMessages.preload.js';
import { reducer as preferredReactions } from './ducks/preferredReactions.preload.js';
import { reducer as safetyNumber } from './ducks/safetyNumber.preload.js';
import { reducer as search } from './ducks/search.preload.js';
@@ -67,6 +68,7 @@ export const reducer = combineReducers({
nav,
network,
notificationProfiles,
pinnedMessages,
preferredReactions,
safetyNumber,
search,

View File

@@ -231,14 +231,7 @@ export const getTargetedMessageSource = createSelector(
return state.targetedMessageSource;
}
);
export const getPinnedMessageIds = createSelector(
getConversations,
(state: ConversationsStateType): ReadonlyArray<string> | null => {
return state.pinnedMessages.map(pinnedMessage => {
return pinnedMessage.messageId;
});
}
);
export const getSelectedMessageIds = createSelector(
getConversations,
(state: ConversationsStateType): ReadonlyArray<string> | undefined => {

View File

@@ -103,7 +103,6 @@ import {
getMessages,
getCachedConversationMemberColorsSelector,
getContactNameColor,
getPinnedMessageIds,
} from './conversations.dom.js';
import {
getIntl,
@@ -169,6 +168,7 @@ import { LONG_MESSAGE } from '../../types/MIME.std.js';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification.dom.js';
import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js';
import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js';
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
@@ -206,7 +206,7 @@ export type GetPropsForBubbleOptions = Readonly<{
ourPni: PniString | undefined;
targetedMessageId?: string;
targetedMessageCounter?: number;
pinnedMessageIds: ReadonlyArray<string> | null;
pinnedMessagesMessageIds: ReadonlyArray<string> | null;
selectedMessageIds: ReadonlyArray<string> | undefined;
regionCode?: string;
callSelector: CallSelectorType;
@@ -778,7 +778,7 @@ export type GetPropsForMessageOptions = Pick<
| 'ourNumber'
| 'targetedMessageId'
| 'targetedMessageCounter'
| 'pinnedMessageIds'
| 'pinnedMessagesMessageIds'
| 'selectedMessageIds'
| 'regionCode'
| 'accountSelector'
@@ -857,7 +857,7 @@ function getTextDirection(body?: string): TextDirection {
export const getPropsForMessage = (
message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
): MessagePropsType => {
const attachmentDroppedDueToSize = message.attachments?.some(
item => item.wasTooBig
);
@@ -881,7 +881,7 @@ export const getPropsForMessage = (
regionCode,
targetedMessageId,
targetedMessageCounter,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
contactNameColors,
defaultConversationColor,
@@ -900,7 +900,7 @@ export const getPropsForMessage = (
const activeCallConversationId = activeCall?.conversationId;
const isTargeted = message.id === targetedMessageId;
const isPinned = pinnedMessageIds?.includes(message.id) ?? false;
const isPinned = pinnedMessagesMessageIds?.includes(message.id) ?? false;
const isSelected = selectedMessageIds?.includes(message.id) ?? false;
const isSelectMode = selectedMessageIds != null;
@@ -1011,7 +1011,7 @@ export const getMessagePropsSelector = createSelector(
getAccountSelector,
getCachedConversationMemberColorsSelector,
getTargetedMessage,
getPinnedMessageIds,
getPinnedMessagesMessageIds,
getSelectedMessageIds,
getDefaultConversationColor,
(
@@ -1024,7 +1024,7 @@ export const getMessagePropsSelector = createSelector(
accountSelector,
cachedConversationMemberColorsSelector,
targetedMessage,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
defaultConversationColor
) =>
@@ -1043,7 +1043,7 @@ export const getMessagePropsSelector = createSelector(
regionCode,
targetedMessageCounter: targetedMessage?.counter,
targetedMessageId: targetedMessage?.id,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
defaultConversationColor,
});
@@ -2403,7 +2403,7 @@ export function canForward(message: ReadonlyMessageAttributesType): boolean {
}
export function canPinMessages(conversation: ConversationType): boolean {
return canEditGroupInfo(conversation);
return conversation.type === 'direct' || canEditGroupInfo(conversation);
}
export function getLastChallengeError(
@@ -2445,7 +2445,7 @@ export const getMessageDetails = createSelector(
getUserPNI,
getUserConversationId,
getUserNumber,
getPinnedMessageIds,
getPinnedMessagesMessageIds,
getSelectedMessageIds,
getDefaultConversationColor,
getHasUnidentifiedDeliveryIndicators,
@@ -2460,7 +2460,7 @@ export const getMessageDetails = createSelector(
ourPni,
ourConversationId,
ourNumber,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
defaultConversationColor,
hasUnidentifiedDeliveryIndicators
@@ -2595,7 +2595,7 @@ export const getMessageDetails = createSelector(
ourConversationId,
ourNumber,
regionCode,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
defaultConversationColor,
}),

View File

@@ -0,0 +1,39 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
import type { PinnedMessagesState } from '../ducks/pinnedMessages.preload.js';
import type { StateType } from '../reducer.preload.js';
import type { StateSelector } from '../types.std.js';
import { getSelectedConversationId } from './conversations.dom.js';
import { softAssert } from '../../util/assert.std.js';
export function getPinnedMessagesState(state: StateType): PinnedMessagesState {
return state.pinnedMessages;
}
export const getPinnedMessages: StateSelector<
ReadonlyArray<PinnedMessageRenderData>
> = createSelector(
getPinnedMessagesState,
getSelectedConversationId,
(state, selectedConversationId) => {
const expectedConversationId = selectedConversationId ?? null;
if (expectedConversationId !== state.conversationId) {
softAssert(
false,
'getPinnedMessages: State is not in sync with the selected conversation'
);
return [];
}
return state.pinnedMessages ?? [];
}
);
export const getPinnedMessagesMessageIds: StateSelector<ReadonlyArray<string>> =
createSelector(getPinnedMessages, pinnedMessages => {
return pinnedMessages.map(pinnedMessage => {
return pinnedMessage.message.id;
});
});

View File

@@ -11,7 +11,6 @@ import {
getSelectedMessageIds,
getMessages,
getCachedConversationMemberColorsSelector,
getPinnedMessageIds,
} from './conversations.dom.js';
import { getAccountSelector } from './accounts.std.js';
import {
@@ -26,6 +25,7 @@ import { getActiveCall, getCallSelector } from './calling.std.js';
import { getPropsForBubble } from './message.preload.js';
import { getCallHistorySelector } from './callHistory.std.js';
import { useProxySelector } from '../../hooks/useProxySelector.std.js';
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
const getTimelineItem = (
state: StateType,
@@ -54,7 +54,7 @@ const getTimelineItem = (
const callHistorySelector = getCallHistorySelector(state);
const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state);
const pinnedMessageIds = getPinnedMessageIds(state);
const pinnedMessagesMessageIds = getPinnedMessagesMessageIds(state);
const selectedMessageIds = getSelectedMessageIds(state);
const defaultConversationColor = getDefaultConversationColor(state);
@@ -72,7 +72,7 @@ const getTimelineItem = (
callHistorySelector,
activeCall,
accountSelector,
pinnedMessageIds,
pinnedMessagesMessageIds,
selectedMessageIds,
defaultConversationColor,
});

View File

@@ -0,0 +1,208 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { getIntl } from '../selectors/user.std.js';
import { getSelectedConversationId } from '../selectors/conversations.dom.js';
import { strictAssert } from '../../util/assert.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import type {
Pin,
PinMessage,
PinMessageAttachment,
PinMessageContact,
PinMessagePoll,
PinMessageText,
PinSender,
} from '../../components/conversation/pinned-messages/PinnedMessagesBar.dom.js';
import { PinnedMessagesBar } from '../../components/conversation/pinned-messages/PinnedMessagesBar.dom.js';
import { PanelType } from '../../types/Panels.std.js';
import type { PinnedMessageId } from '../../types/PinnedMessage.std.js';
import {
getMessagePropsSelector,
type MessagePropsType,
} from '../selectors/message.preload.js';
import * as Attachment from '../../util/Attachment.std.js';
import * as MIME from '../../types/MIME.std.js';
import * as EmbeddedContact from '../../types/EmbeddedContact.std.js';
import type { StateSelector } from '../types.std.js';
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
function getPinMessageAttachment(
props: MessagePropsType
): PinMessageAttachment | null {
const attachment = props.attachments?.at(0);
if (attachment == null) {
return null;
}
const { contentType } = attachment;
if (contentType === MIME.IMAGE_GIF || Attachment.isGIF([attachment])) {
return { type: 'gif' };
}
if (Attachment.isImage([attachment])) {
return { type: 'image', url: attachment.thumbnail?.url ?? null };
}
if (Attachment.isVideo([attachment])) {
return { type: 'video', url: attachment.thumbnail?.url ?? null };
}
if (Attachment.isVoiceMessage(attachment)) {
return { type: 'voiceMessage' };
}
return { type: 'file', name: attachment.fileName ?? null };
}
function getPinMessageText(props: MessagePropsType): PinMessageText | null {
if (props.text == null) {
return null;
}
return { body: props.text, bodyRanges: props.bodyRanges ?? [] };
}
function getPinMessageContact(
props: MessagePropsType
): PinMessageContact | null {
if (props.contact == null) {
return null;
}
return {
name: EmbeddedContact.getDisplayName(props.contact) ?? null,
};
}
function isPinMessagePayment(props: MessagePropsType): boolean {
return props.payment != null;
}
function getPinMessagePoll(props: MessagePropsType): PinMessagePoll | null {
if (props.poll == null) {
return null;
}
return {
question: props.poll.question,
};
}
function isPinMessageSticker(props: MessagePropsType): boolean {
return props.isSticker ?? false;
}
function getPinMessage(props: MessagePropsType): PinMessage {
return {
id: props.id,
text: getPinMessageText(props),
attachment: getPinMessageAttachment(props),
contact: getPinMessageContact(props),
payment: isPinMessagePayment(props),
poll: getPinMessagePoll(props),
sticker: isPinMessageSticker(props),
};
}
function getPinSender(props: MessagePropsType): PinSender {
return {
id: props.author.id,
title: props.author.title,
isMe: props.author.isMe,
};
}
const selectPins: StateSelector<ReadonlyArray<Pin>> = createSelector(
getPinnedMessages,
getMessagePropsSelector,
(pinnedMessages, messagePropsSelector) => {
return pinnedMessages.map((pinnedMessageRenderData): Pin => {
const { pinnedMessage, message } = pinnedMessageRenderData;
const messageProps = messagePropsSelector(message);
return {
id: pinnedMessage.id,
sender: getPinSender(messageProps),
message: getPinMessage(messageProps),
};
});
}
);
export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
const i18n = useSelector(getIntl);
const conversationId = useSelector(getSelectedConversationId);
const pins = useSelector(selectPins);
strictAssert(
conversationId != null,
'PinnedMessagesBar should only be rendered in selected conversation'
);
const { pushPanelForConversation, scrollToMessage } =
useConversationsActions();
const { onPinnedMessageRemove } = usePinnedMessagesActions();
const [current, setCurrent] = useState(() => {
return pins.at(0)?.id ?? null;
});
const isCurrentOutOfDate = useMemo(() => {
if (current == null) {
if (pins.length > 0) {
return true;
}
return false;
}
const hasMatch = pins.some(pin => {
return pin.id === current;
});
return !hasMatch;
}, [current, pins]);
if (isCurrentOutOfDate) {
setCurrent(pins.at(0)?.id ?? null);
}
const handleCurrentChange = useCallback(
(pinnedMessageId: PinnedMessageId) => {
setCurrent(pinnedMessageId);
},
[]
);
const handlePinGoTo = useCallback(
(messageId: string) => {
scrollToMessage(conversationId, messageId);
},
[scrollToMessage, conversationId]
);
const handlePinRemove = useCallback(
(messageId: string) => {
onPinnedMessageRemove(messageId);
},
[onPinnedMessageRemove]
);
const handlePinsShowAll = useCallback(() => {
pushPanelForConversation({
type: PanelType.PinnedMessages,
});
}, [pushPanelForConversation]);
if (current == null) {
return;
}
return (
<PinnedMessagesBar
i18n={i18n}
pins={pins}
current={current}
onCurrentChange={handleCurrentChange}
onPinGoTo={handlePinGoTo}
onPinRemove={handlePinRemove}
onPinsShowAll={handlePinsShowAll}
/>
);
});

View File

@@ -3,19 +3,13 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
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 { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
export type SmartPinnedMessagesPanelProps = Readonly<{
conversationId: string;
@@ -25,39 +19,6 @@ 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 ?? [];
return messageIds
.map(messageId => {
return conversations.messagesLookup[messageId] ?? null;
})
.filter(message => {
return message.type === 'incoming' || message.type === 'outgoing';
})
.slice(-10)
.map((message, messageIndex): PinnedMessage => {
return {
id: messageIndex as PinnedMessageId,
conversationId: selectedConversationId,
messageId: message.id,
pinnedAt: Date.now(),
expiresAt: null,
};
});
}
);
export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
props: SmartPinnedMessagesPanelProps
) {
@@ -70,13 +31,13 @@ export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
'<SmartPinnedMessagesPanel> expected a conversation to be found'
);
const mockPinnedMessages = useSelector(mockSelectPinnedMessages);
const pinnedMessages = useSelector(getPinnedMessages);
return (
<PinnedMessagesPanel
i18n={i18n}
conversation={conversation}
pinnedMessages={mockPinnedMessages}
pinnedMessages={pinnedMessages}
renderTimelineItem={renderTimelineItem}
/>
);

View File

@@ -47,6 +47,8 @@ import {
import { SmartTypingBubble } from './TypingBubble.preload.js';
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager.preload.js';
import { isInFullScreenCall as getIsInFullScreenCall } from '../selectors/calling.std.js';
import { SmartPinnedMessagesBar } from './PinnedMessagesBar.preload.js';
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
const { isEmpty } = lodash;
@@ -102,6 +104,9 @@ function renderHeroRow(id: string): JSX.Element {
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
return <SmartMiniPlayer {...options} />;
}
function renderPinnedMessagesBar(): JSX.Element {
return <SmartPinnedMessagesBar />;
}
function renderTypingBubble(conversationId: string): JSX.Element {
return <SmartTypingBubble conversationId={conversationId} />;
}
@@ -177,6 +182,7 @@ export const SmartTimeline = memo(function SmartTimeline({
const isInFullScreenCall = useSelector(getIsInFullScreenCall);
const conversation = conversationSelector(id);
const conversationMessages = conversationMessagesSelector(id);
const pinnedMessages = useSelector(getPinnedMessages);
const warning = useSelector(
useCallback(
@@ -214,6 +220,7 @@ export const SmartTimeline = memo(function SmartTimeline({
);
const shouldShowMiniPlayer = activeAudioPlayer != null;
const shouldShowPinnedMessagesBar = pinnedMessages.length > 0;
const {
acceptedMessageRequest,
isBlocked = false,
@@ -288,6 +295,7 @@ export const SmartTimeline = memo(function SmartTimeline({
renderHeroRow={renderHeroRow}
renderItem={renderItem}
renderMiniPlayer={renderMiniPlayer}
renderPinnedMessagesBar={renderPinnedMessagesBar}
renderTypingBubble={renderTypingBubble}
reviewConversationNameCollision={reviewConversationNameCollision}
scrollToIndex={scrollToIndex}
@@ -296,6 +304,7 @@ export const SmartTimeline = memo(function SmartTimeline({
setCenterMessage={setCenterMessage}
setIsNearBottom={setIsNearBottom}
shouldShowMiniPlayer={shouldShowMiniPlayer}
shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar}
targetedMessageId={targetedMessageId}
targetMessage={targetMessage}
theme={theme}

View File

@@ -40,6 +40,7 @@ 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';
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
export type SmartTimelineItemProps = {
containerElementRef: RefObject<HTMLElement>;
@@ -131,8 +132,6 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
messageExpanded,
onPinnedMessageAdd,
onPinnedMessageRemove,
openGiftBadge,
pushPanelForConversation,
retryDeleteForEveryone,
@@ -152,6 +151,9 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
toggleSelectMessage,
} = useConversationsActions();
const { onPinnedMessageAdd, onPinnedMessageRemove } =
usePinnedMessagesActions();
const {
endPoll,
reactToMessage,

View File

@@ -31,6 +31,7 @@ import type { actions as mediaGallery } from './ducks/mediaGallery.preload.js';
import type { actions as nav } from './ducks/nav.std.js';
import type { actions as network } from './ducks/network.dom.js';
import type { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
import type { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
import type { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
import type { actions as search } from './ducks/search.preload.js';
import type { actions as stickers } from './ducks/stickers.preload.js';
@@ -68,6 +69,7 @@ export type ReduxActions = {
nav: typeof nav;
network: typeof network;
notificationProfiles: typeof notificationProfiles;
pinnedMessages: typeof pinnedMessages;
safetyNumber: typeof safetyNumber;
search: typeof search;
stickers: typeof stickers;

View File

@@ -2,8 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { AciString } from '@signalapp/mock-server/src/types.js';
import type { MessageAttributesType } from '../model-types.js';
import type { ConversationType } from '../state/ducks/conversations.preload.js';
import type { ReadonlyMessageAttributesType } from '../model-types.js';
export type PinnedMessageId = number & { PinnedMessageId: never };
@@ -19,8 +18,7 @@ export type PinnedMessageParams = Omit<PinnedMessage, 'id'>;
export type PinnedMessageRenderData = Readonly<{
pinnedMessage: PinnedMessage;
sender: ConversationType;
message: MessageAttributesType;
message: ReadonlyMessageAttributesType;
}>;
export type SendPinMessageType = Readonly<{