diff --git a/ts/components/conversation/ConversationHeader.dom.stories.tsx b/ts/components/conversation/ConversationHeader.dom.stories.tsx index 58e503be8d..9df50c15cb 100644 --- a/ts/components/conversation/ConversationHeader.dom.stories.tsx +++ b/ts/components/conversation/ConversationHeader.dom.stories.tsx @@ -103,7 +103,6 @@ const commonProps: PropsType = { shouldShowMiniPlayer: false, renderMiniPlayer, - shouldShowPinnedMessagesBar: false, renderPinnedMessagesBar, }; @@ -600,10 +599,7 @@ export function WithJustMiniPlayer(): React.JSX.Element { } export function WithJustPinnedMessagesBar(): React.JSX.Element { - const props: PropsType = { - ...commonProps, - shouldShowPinnedMessagesBar: true, - }; + const props: PropsType = commonProps; const theme = useContext(StorybookThemeContext); return ; @@ -613,7 +609,6 @@ export function WithMinPlayerAndPinnedMessagesBar(): React.JSX.Element { const props: PropsType = { ...commonProps, shouldShowMiniPlayer: true, - shouldShowPinnedMessagesBar: true, }; const theme = useContext(StorybookThemeContext); diff --git a/ts/components/conversation/ConversationHeader.dom.tsx b/ts/components/conversation/ConversationHeader.dom.tsx index e95573d5b5..16f37b6972 100644 --- a/ts/components/conversation/ConversationHeader.dom.tsx +++ b/ts/components/conversation/ConversationHeader.dom.tsx @@ -149,7 +149,6 @@ export type PropsDataType = { shouldShowMiniPlayer: boolean; renderMiniPlayer: RenderMiniPlayer; - shouldShowPinnedMessagesBar: boolean; renderPinnedMessagesBar: RenderPinnedMessagesBar; }; @@ -243,7 +242,6 @@ export const ConversationHeader = memo(function ConversationHeader({ shouldShowMiniPlayer, renderMiniPlayer, - shouldShowPinnedMessagesBar, renderPinnedMessagesBar, }: PropsType): React.JSX.Element | null { // Comes from a third-party dependency @@ -465,7 +463,6 @@ export const ConversationHeader = memo(function ConversationHeader({ renderCollidingAvatars={renderCollidingAvatars} shouldShowMiniPlayer={shouldShowMiniPlayer} renderMiniPlayer={renderMiniPlayer} - shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar} renderPinnedMessagesBar={renderPinnedMessagesBar} /> @@ -1140,8 +1137,6 @@ function ConversationSubheader(props: { shouldShowMiniPlayer: boolean; renderMiniPlayer: RenderMiniPlayer; - - shouldShowPinnedMessagesBar: boolean; renderPinnedMessagesBar: RenderPinnedMessagesBar; }) { const { i18n } = props; @@ -1194,9 +1189,7 @@ function ConversationSubheader(props: { )} {props.shouldShowMiniPlayer && props.renderMiniPlayer({ shouldFlow: true })} - {!props.shouldShowMiniPlayer && - props.shouldShowPinnedMessagesBar && - props.renderPinnedMessagesBar()} + {!props.shouldShowMiniPlayer && props.renderPinnedMessagesBar()} ); } diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx index a8114566c0..d0de241f47 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx @@ -1,11 +1,11 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ForwardedRef, ReactNode } from 'react'; -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; import { Tabs } from 'radix-ui'; +import { AnimatePresence, motion } from 'framer-motion'; import type { LocalizerType } from '../../../types/I18N.std.js'; import { tw } from '../../../axo/tw.dom.js'; -import { strictAssert } from '../../../util/assert.std.js'; import { AxoIconButton } from '../../../axo/AxoIconButton.dom.js'; import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.js'; import { AriaClickable } from '../../../axo/AriaClickable.dom.js'; @@ -20,6 +20,24 @@ import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js'; import { stripNewlinesForLeftPane } from '../../../util/stripNewlinesForLeftPane.std.js'; +enum Direction { + None = 0, + Backwards = -1, + Forwards = 1, +} + +// This `usePrevious()` hook is safe in React concurrent mode and doesn't break +// when rendered multiple times with the same values in `` +function usePrevious(value: T): T | null { + const [current, setCurrent] = useState(value); + const [previous, setPrevious] = useState(null); + if (current !== value) { + setCurrent(value); + setPrevious(current); + } + return previous; +} + export type PinMessageText = Readonly<{ body: string; bodyRanges: HydratedBodyRangesType; @@ -69,7 +87,7 @@ export type Pin = Readonly<{ export type PinnedMessagesBarProps = Readonly<{ i18n: LocalizerType; pins: ReadonlyArray; - current: PinnedMessageId; + current: PinnedMessageId | null; onCurrentChange: (current: PinnedMessageId) => void; onPinGoTo: (messageId: string) => void; onPinRemove: (messageId: string) => void; @@ -80,9 +98,43 @@ export type PinnedMessagesBarProps = Readonly<{ export const PinnedMessagesBar = memo(function PinnedMessagesBar( props: PinnedMessagesBarProps ) { - const { i18n, onCurrentChange } = props; + const { i18n, pins, current, onCurrentChange } = props; - strictAssert(props.pins.length > 0, 'Must have at least one pin'); + const currentEntry = useMemo(() => { + if (current == null) { + return null; + } + + for (let index = 0; index < pins.length; index += 1) { + const value = pins[index]; + if (value.id === current) { + return { index, value }; + } + } + + throw new Error( + `Current pin ${current} is missing from pins (${pins.length})` + ); + }, [pins, current]); + + const currentIndex = currentEntry?.index; + const previousIndex = usePrevious(currentIndex); + + const direction = useMemo(() => { + if (previousIndex == null || currentIndex == null) { + return Direction.None; + } + + if (previousIndex < currentIndex) { + return Direction.Forwards; + } + + if (previousIndex > currentIndex) { + return Direction.Backwards; + } + + return Direction.None; + }, [currentIndex, previousIndex]); const handleValueChange = useCallback( (value: string) => { @@ -91,117 +143,184 @@ export const PinnedMessagesBar = memo(function PinnedMessagesBar( [onCurrentChange] ); - if (props.pins.length === 1) { - const pin = props.pins.at(0); - strictAssert(pin != null, 'Missing pin'); - return ( - - - - ); - } - return ( - - - - {props.pins.map(pin => { - return ( - - - - ); - })} - - + + {currentEntry != null && ( + + + + + {props.pins.map(pin => { + return ( + + + + ); + })} + + + + + + )} + ); }); -function Container(props: { +function Bar(props: { i18n: LocalizerType; pinsCount: number; children: ReactNode; }) { const { i18n } = props; + return ( + + {props.pinsCount > 0 && ( + + + {props.children} + + + )} + + ); +} + +function Row(props: { children: ReactNode }) { + return ( + + {props.children} + + ); +} + +function HiddenTrigger(props: { + i18n: LocalizerType; + currentPin: Pin; + onPinGoTo: (messageId: string) => void; +}) { + const { i18n, currentPin, onPinGoTo } = props; + + const handlePinGoToCurrent = useCallback(() => { + onPinGoTo(currentPin.message.id); + }, [onPinGoTo, currentPin]); return ( -
- - {props.children} - -
+ + ); +} + +function ContentWrapper(props: { children: ReactNode }) { + return ( +
+ {props.children} +
); } function TabsList(props: { i18n: LocalizerType; pins: ReadonlyArray; - current: PinnedMessageId; + current: PinnedMessageId | null; onCurrentChange: (current: PinnedMessageId) => void; }) { const { i18n } = props; - strictAssert(props.pins.length >= 2, 'Too few pins for tabs'); - strictAssert(props.pins.length <= 3, 'Too many pins for tabs'); + if (props.pins.length < 2) { + return null; + } return ( - - {props.pins.map((pin, pinIndex) => { - return ( - - ); - })} + + + {props.pins.map((pin, pinIndex) => { + return ( + + ); + })} + ); @@ -239,26 +358,70 @@ function TabTrigger(props: { } type ContentProps = Readonly<{ + i18n: LocalizerType; + pin: Pin; + direction: Direction; + pinsCount: number; +}>; + +const Content = forwardRef(function Content( + { i18n, pin, direction, pinsCount, ...forwardedProps }: ContentProps, + ref: ForwardedRef +): React.JSX.Element { + const thumbnailUrl = useMemo(() => { + return getThumbnailUrl(pin.message); + }, [pin.message]); + + return ( +
+ {thumbnailUrl != null && } +
+

+ +

+

+ +

+
+
+ ); +}); + +function PinActionsMenu(props: { i18n: LocalizerType; pin: Pin; onPinGoTo: (messageId: string) => void; onPinRemove: (messageId: string) => void; onPinsShowAll: () => void; canPinMessages: boolean; -}>; +}) { + const { i18n, pin, onPinGoTo, onPinRemove, onPinsShowAll, canPinMessages } = + props; -const Content = forwardRef(function Content( - { - i18n, - pin, - onPinGoTo, - onPinRemove, - onPinsShowAll, - canPinMessages, - ...forwardedProps - }: ContentProps, - ref: ForwardedRef -): React.JSX.Element { const handlePinGoTo = useCallback(() => { onPinGoTo(pin.message.id); }, [onPinGoTo, pin.message.id]); @@ -271,70 +434,39 @@ const Content = forwardRef(function Content( onPinsShowAll(); }, [onPinsShowAll]); - const thumbnailUrl = useMemo(() => { - return getThumbnailUrl(pin.message); - }, [pin.message]); - return ( -
- {thumbnailUrl != null && } -
-

- -

-

- -

- -
- - - - - - - {canPinMessages && ( - - {i18n('icu:PinnedMessagesBar__ActionsMenu__UnpinMessage')} - + + + + - {i18n('icu:PinnedMessagesBar__ActionsMenu__GoToMessage')} + /> + + + {canPinMessages && ( + + {i18n('icu:PinnedMessagesBar__ActionsMenu__UnpinMessage')} - - {i18n('icu:PinnedMessagesBar__ActionsMenu__SeeAllMessages')} - - - - -
+ )} + + {i18n('icu:PinnedMessagesBar__ActionsMenu__GoToMessage')} + + + {i18n('icu:PinnedMessagesBar__ActionsMenu__SeeAllMessages')} + + + + ); -}); +} function getThumbnailUrl(message: PinMessage): string | null { // Never render a thumbnail if its view-once media diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 3815afd901..922c5300e3 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -319,6 +319,13 @@ export const getConversationMessages = createSelector( } ); +export const getConversationIsReady: StateSelector = createSelector( + getConversationMessages, + conversationMessages => { + return conversationMessages != null; + } +); + export const getPinnedMessages: StateSelector> = createSelector(getConversationMessages, conversationMessages => { return conversationMessages?.pinnedMessages ?? []; diff --git a/ts/state/smart/ConversationHeader.preload.tsx b/ts/state/smart/ConversationHeader.preload.tsx index 69aee8ede8..c806c65438 100644 --- a/ts/state/smart/ConversationHeader.preload.tsx +++ b/ts/state/smart/ConversationHeader.preload.tsx @@ -39,7 +39,6 @@ import { getConversationSelector, getHasPanelOpen, isMissingRequiredProfileSharing as getIsMissingRequiredProfileSharing, - getPinnedMessages, getSelectedMessageIds, } from '../selectors/conversations.dom.js'; import { getHasStoriesSelector } from '../selectors/stories2.dom.js'; @@ -138,9 +137,6 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ const activeAudioPlayer = useSelector(selectAudioPlayerActive); const shouldShowMiniPlayer = activeAudioPlayer != null; - const pinnedMessages = useSelector(getPinnedMessages); - const shouldShowPinnedMessagesBar = pinnedMessages.length > 0; - const { destroyMessages, leaveGroup, @@ -350,7 +346,6 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ renderCollidingAvatars={renderCollidingAvatars} shouldShowMiniPlayer={shouldShowMiniPlayer} renderMiniPlayer={renderMiniPlayer} - shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar} renderPinnedMessagesBar={renderPinnedMessagesBar} acknowledgeGroupMemberNameCollisions={ acknowledgeGroupMemberNameCollisions diff --git a/ts/state/smart/PinnedMessagesBar.preload.tsx b/ts/state/smart/PinnedMessagesBar.preload.tsx index 2ed521416a..990ff22a91 100644 --- a/ts/state/smart/PinnedMessagesBar.preload.tsx +++ b/ts/state/smart/PinnedMessagesBar.preload.tsx @@ -17,6 +17,7 @@ import { getSelectedConversationId, getPinnedMessages, getMessages, + getConversationIsReady, } from '../selectors/conversations.dom.js'; import { strictAssert } from '../../util/assert.std.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; @@ -372,6 +373,7 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { const conversation = conversationSelector(conversationId); strictAssert(conversation != null, 'Missing conversation'); + const conversationIsReady = useSelector(getConversationIsReady); const pins = useSelector(selectPins); const canPinMessages = getCanPinMessages(conversation); @@ -459,8 +461,8 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { setCurrent(nextCurrent); }); - if (current == null) { - return; + if (!conversationIsReady) { + return null; } return (