From acc9fd604fe1b47ef6f2cf35c650d5ae5c3be88a Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:14:20 -0800 Subject: [PATCH] Integrate pinned messages bar/panel --- .../conversation/Timeline.dom.stories.tsx | 6 + ts/components/conversation/Timeline.dom.tsx | 11 +- .../ConversationDetails.dom.tsx | 20 -- .../PinnedMessagesBar.dom.stories.tsx | 19 +- .../pinned-messages/PinnedMessagesBar.dom.tsx | 124 ++++---- .../PinnedMessagesPanel.dom.tsx | 12 +- ts/messageModifiers/PinnedMessages.preload.ts | 4 +- ts/sql/Interface.std.ts | 10 +- ts/sql/Server.node.ts | 13 +- ts/sql/server/pinnedMessages.std.ts | 60 +++- ts/state/actions.preload.ts | 2 + ts/state/ducks/conversations.preload.ts | 162 +--------- ts/state/ducks/pinnedMessages.preload.ts | 299 ++++++++++++++++++ ts/state/getInitialState.preload.ts | 2 + ts/state/initializeRedux.preload.ts | 4 + ts/state/reducer.preload.ts | 2 + ts/state/selectors/conversations.dom.ts | 9 +- ts/state/selectors/message.preload.ts | 26 +- ts/state/selectors/pinnedMessages.dom.ts | 39 +++ ts/state/selectors/timeline.preload.ts | 6 +- ts/state/smart/PinnedMessagesBar.preload.tsx | 208 ++++++++++++ .../smart/PinnedMessagesPanel.preload.tsx | 45 +-- ts/state/smart/Timeline.preload.tsx | 9 + ts/state/smart/TimelineItem.preload.tsx | 6 +- ts/state/types.std.ts | 2 + ts/types/PinnedMessage.std.ts | 6 +- 26 files changed, 768 insertions(+), 338 deletions(-) create mode 100644 ts/state/ducks/pinnedMessages.preload.ts create mode 100644 ts/state/selectors/pinnedMessages.dom.ts create mode 100644 ts/state/smart/PinnedMessagesBar.preload.tsx diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index a3f4468e9a..4c7dd1e682 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -462,6 +462,10 @@ const renderMiniPlayer = () => (
If active, this is where smart mini player would be
); +const renderPinnedMessagesBar = () => ( +
If active, this is where the smart pinned messages bar would be
+); + const useProps = (overrideProps: Partial = {}): PropsType => ({ discardMessages: action('discardMessages'), getPreferredBadge: () => undefined, @@ -482,6 +486,7 @@ const useProps = (overrideProps: Partial = {}): 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 => ({ renderItem, renderHeroRow, renderMiniPlayer, + renderPinnedMessagesBar, renderTypingBubble, renderCollidingAvatars, renderContactSpoofingReviewDialog, diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index efe1a03c0a..4351d6dd97 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -103,6 +103,7 @@ type PropsHousekeepingType = { invitedContactsForNewlyCreatedGroup: Array; 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 => ( - {renderMiniPlayer({ shouldFlow: true })} + {shouldShowMiniPlayer && renderMiniPlayer({ shouldFlow: true })} + {!shouldShowMiniPlayer && + shouldShowPinnedMessagesBar && + renderPinnedMessagesBar()} {text && ( {icon} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx index ad301affd8..f4652b08d1 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx @@ -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({ )} )} - {isInternalFeaturesEnabled() && ( - - - pushPanelForConversation({ - type: PanelType.PinnedMessages, - }) - } - icon={ -
- -
- } - label="View all pinned messages" - /> -
- )} {isGroup && ( - + - ); diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx index a39038c867..df250fbed1 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx @@ -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; 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 +): 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 ( -
+
{thumbnailUrl != null && }

@@ -297,14 +318,14 @@ function Content(props: {

); -} +}); 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 ; } 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 ; } - throw missingCaseError(message.attachment.type); + throw missingCaseError(message.attachment); } if (message.contact?.name != null) { return ; } - if (message.contact?.address != null) { - return ; - } if (message.payment != null) { return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Payment'); } diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx index 44f4ac36f5..d90a069dc6 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx @@ -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; + pinnedMessages: ReadonlyArray; 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 ( - + {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, })} diff --git a/ts/messageModifiers/PinnedMessages.preload.ts b/ts/messageModifiers/PinnedMessages.preload.ts index 6b9bcd9bcd..efb867f1b5 100644 --- a/ts/messageModifiers/PinnedMessages.preload.ts +++ b/ts/messageModifiers/PinnedMessages.preload.ts @@ -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 ); } diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 434e16bf6e..9140b366e6 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -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; + ) => ReadonlyArray; getNextExpiringPinnedMessageAcrossConversations: () => PinnedMessage | null; getMessagesNeedingUpgrade: ( diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 1faa22a134..1bfde6f8e1 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -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 diff --git a/ts/sql/server/pinnedMessages.std.ts b/ts/sql/server/pinnedMessages.std.ts index 29bff26c7f..cb1cdeaa8b 100644 --- a/ts/sql/server/pinnedMessages.std.ts +++ b/ts/sql/server/pinnedMessages.std.ts @@ -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(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 { - const [query, params] = sql` - SELECT * FROM pinnedMessages - WHERE conversationId = ${conversationId} - ORDER BY pinnedAt DESC - `; - return db.prepare(query).all(params); +): ReadonlyArray { + return db.transaction(() => { + const [query, params] = sql` + SELECT * FROM pinnedMessages + WHERE conversationId = ${conversationId} + ORDER BY pinnedAt DESC + `; + + return db + .prepare(query) + .all(params) + .map(pinnedMessage => { + return _getPinnedMessageRenderData(db, pinnedMessage); + }); + })(); } function _getPinnedMessageByMessageId( diff --git a/ts/state/actions.preload.ts b/ts/state/actions.preload.ts index f806716a32..06bdc46086 100644 --- a/ts/state/actions.preload.ts +++ b/ts/state/actions.preload.ts @@ -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, diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 6aba11801a..532944449c 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -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; - pinnedMessages: ReadonlyArray; + pinnedMessages: ReadonlyArray; 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; 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; }; }>; -type ReplacePinnedMessagesActionType = ReadonlyDeep<{ - type: typeof REPLACE_PINNED_MESSAGES; - payload: { - conversationId: string; - pinnedMessages: ReadonlyArray; - }; -}>; 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 { - 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 { - 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 diff --git a/ts/state/ducks/pinnedMessages.preload.ts b/ts/state/ducks/pinnedMessages.preload.ts new file mode 100644 index 0000000000..a9a96b23ff --- /dev/null +++ b/ts/state/ducks/pinnedMessages.preload.ts @@ -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; +}>; + +export type PinnedMessagesState = ReadonlyDeep<{ + preloadData: PreloadData | null; + conversationId: string | null; + pinnedMessages: ReadonlyArray | null; +}>; + +const PINNED_MESSAGES_REPLACE = 'pinnedMessages/PINNED_MESSAGES_REPLACE'; + +export type PinnedMessagesReplace = ReadonlyDeep<{ + type: typeof PINNED_MESSAGES_REPLACE; + payload: { + conversationId: string; + pinnedMessages: ReadonlyArray; + }; +}>; + +export type PinnedMessagesAction = ReadonlyDeep; + +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 { + 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 { + 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, + message: ReadonlyMessageAttributesType +): ReadonlyArray { + 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; + } +} diff --git a/ts/state/getInitialState.preload.ts b/ts/state/getInitialState.preload.ts index fe623900fc..2eb2c511f2 100644 --- a/ts/state/getInitialState.preload.ts +++ b/ts/state/getInitialState.preload.ts @@ -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(), diff --git a/ts/state/initializeRedux.preload.ts b/ts/state/initializeRedux.preload.ts index 5d708f3020..ab75ae6b89 100644 --- a/ts/state/initializeRedux.preload.ts +++ b/ts/state/initializeRedux.preload.ts @@ -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 diff --git a/ts/state/reducer.preload.ts b/ts/state/reducer.preload.ts index 9d88818a33..4c020662ab 100644 --- a/ts/state/reducer.preload.ts +++ b/ts/state/reducer.preload.ts @@ -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, diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 39ab6c0e0a..78c9943bbf 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -231,14 +231,7 @@ export const getTargetedMessageSource = createSelector( return state.targetedMessageSource; } ); -export const getPinnedMessageIds = createSelector( - getConversations, - (state: ConversationsStateType): ReadonlyArray | null => { - return state.pinnedMessages.map(pinnedMessage => { - return pinnedMessage.messageId; - }); - } -); + export const getSelectedMessageIds = createSelector( getConversations, (state: ConversationsStateType): ReadonlyArray | undefined => { diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 416e7ac6b3..cb6ae6672e 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -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 | null; + pinnedMessagesMessageIds: ReadonlyArray | null; selectedMessageIds: ReadonlyArray | 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 => { +): 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, }), diff --git a/ts/state/selectors/pinnedMessages.dom.ts b/ts/state/selectors/pinnedMessages.dom.ts new file mode 100644 index 0000000000..5d6bc17f5f --- /dev/null +++ b/ts/state/selectors/pinnedMessages.dom.ts @@ -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 +> = 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> = + createSelector(getPinnedMessages, pinnedMessages => { + return pinnedMessages.map(pinnedMessage => { + return pinnedMessage.message.id; + }); + }); diff --git a/ts/state/selectors/timeline.preload.ts b/ts/state/selectors/timeline.preload.ts index 7b163cb612..dfc1f1f839 100644 --- a/ts/state/selectors/timeline.preload.ts +++ b/ts/state/selectors/timeline.preload.ts @@ -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, }); diff --git a/ts/state/smart/PinnedMessagesBar.preload.tsx b/ts/state/smart/PinnedMessagesBar.preload.tsx new file mode 100644 index 0000000000..bbc62c8b8f --- /dev/null +++ b/ts/state/smart/PinnedMessagesBar.preload.tsx @@ -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> = 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 ( + + ); +}); diff --git a/ts/state/smart/PinnedMessagesPanel.preload.tsx b/ts/state/smart/PinnedMessagesPanel.preload.tsx index 17d5f84005..e0b1205e09 100644 --- a/ts/state/smart/PinnedMessagesPanel.preload.tsx +++ b/ts/state/smart/PinnedMessagesPanel.preload.tsx @@ -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 ; } -const mockSelectPinnedMessages: StateSelector> = - 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( ' expected a conversation to be found' ); - const mockPinnedMessages = useSelector(mockSelectPinnedMessages); + const pinnedMessages = useSelector(getPinnedMessages); return ( ); diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx index 183ba9527e..9a592a389a 100644 --- a/ts/state/smart/Timeline.preload.tsx +++ b/ts/state/smart/Timeline.preload.tsx @@ -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 ; } +function renderPinnedMessagesBar(): JSX.Element { + return ; +} function renderTypingBubble(conversationId: string): JSX.Element { return ; } @@ -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} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 10a90d15a0..6bbdb536f1 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -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; @@ -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, diff --git a/ts/state/types.std.ts b/ts/state/types.std.ts index be317364be..c3f2cd63e9 100644 --- a/ts/state/types.std.ts +++ b/ts/state/types.std.ts @@ -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; diff --git a/ts/types/PinnedMessage.std.ts b/ts/types/PinnedMessage.std.ts index 75b6797d76..9c6e7cc22e 100644 --- a/ts/types/PinnedMessage.std.ts +++ b/ts/types/PinnedMessage.std.ts @@ -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; export type PinnedMessageRenderData = Readonly<{ pinnedMessage: PinnedMessage; - sender: ConversationType; - message: MessageAttributesType; + message: ReadonlyMessageAttributesType; }>; export type SendPinMessageType = Readonly<{