diff --git a/stylesheets/components/ConversationView.scss b/stylesheets/components/ConversationView.scss index 5f289cfdd8..6acf10c98d 100644 --- a/stylesheets/components/ConversationView.scss +++ b/stylesheets/components/ConversationView.scss @@ -11,15 +11,10 @@ &__pane { display: flex; + position: relative; + flex: 1; flex-direction: column; - height: calc( - 100% - #{variables.$header-height} - var(--title-bar-drag-area-height) - ); - inset-inline-start: 0; overflow-y: auto; - position: absolute; - top: calc(#{variables.$header-height} + var(--title-bar-drag-area-height)); - width: 100%; z-index: variables.$z-index-base; @include mixins.light-theme() { @@ -33,23 +28,21 @@ &__timeline { &--container { + display: flex; flex-grow: 1; margin: 0; - max-width: 100%; - position: relative; z-index: 0; + min-height: 0; } & { - -webkit-padding-start: 0px; - height: 100%; + position: relative; + flex-grow: 1; + min-height: 0; margin: 0; overflow-x: hidden; overflow-y: auto; padding: 0; - position: absolute; - top: 0; - width: 100%; } } @@ -68,5 +61,6 @@ } &__header { + z-index: variables.$z-index-above-base; } } diff --git a/stylesheets/components/TimelineWarning.scss b/stylesheets/components/TimelineWarning.scss index 8d8c20dfab..a439c3b416 100644 --- a/stylesheets/components/TimelineWarning.scss +++ b/stylesheets/components/TimelineWarning.scss @@ -23,10 +23,6 @@ border-top-width: 1px; border-top-style: solid; - &:last-child { - border-bottom-width: 1px; - border-bottom-style: solid; - } @include mixins.light-theme { color: variables.$color-gray-65; diff --git a/stylesheets/components/TimelineWarnings.scss b/stylesheets/components/TimelineWarnings.scss deleted file mode 100644 index d57b7ad4db..0000000000 --- a/stylesheets/components/TimelineWarnings.scss +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -@use '../variables'; - -.module-TimelineWarnings { - inset-inline-start: 0; - position: absolute; - top: 0; - width: 100%; - z-index: variables.$z-index-above-above-base; - - display: flex; - flex-direction: column; -} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index d920d9fdcb..6b88721334 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -196,7 +196,6 @@ @use 'components/TimelineDateHeader.scss'; @use 'components/TimelineFloatingHeader.scss'; @use 'components/TimelineWarning.scss'; -@use 'components/TimelineWarnings.scss'; @use 'components/Toast.scss'; @use 'components/ToastManager.scss'; @use 'components/Waveform.scss'; diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 1282cfcbc5..5c6074514e 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -114,6 +114,16 @@ --color-shadow-elevation-5: light-dark(--alpha(#000 / 20%), --alpha(#000 / 40%)); --color-shadow-outline: light-dark(--alpha(#000 / 12%), /* */ transparent); --color-shadow-highlight: light-dark(/* */ transparent, --alpha(#FFF / 08%)); + + + /** + * Colors/Legacy + * ------------- + * These should all eventually be removed, but in places where we need new + * components to specifically match the colors of older components, we can + * add them here. + */ + --color-legacy-conversation-header-bg: light-dark(#fff, #121212); } @layer theme { diff --git a/ts/components/I18n.dom.tsx b/ts/components/I18n.dom.tsx index a58c68ce74..bbc2c53e08 100644 --- a/ts/components/I18n.dom.tsx +++ b/ts/components/I18n.dom.tsx @@ -9,6 +9,8 @@ import type { } from '../types/Util.std.js'; import { strictAssert } from '../util/assert.std.js'; +export type I18nComponentParts = ReadonlyArray; + export type Props = { /** The translation string id */ id: Key; diff --git a/ts/components/conversation/ConversationHeader.dom.stories.tsx b/ts/components/conversation/ConversationHeader.dom.stories.tsx index 75495c64d1..58e503be8d 100644 --- a/ts/components/conversation/ConversationHeader.dom.stories.tsx +++ b/ts/components/conversation/ConversationHeader.dom.stories.tsx @@ -5,6 +5,8 @@ import type { ComponentProps } from 'react'; import React, { useContext } from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; +import { times } from 'lodash'; +import { v4 as generateUuid } from 'uuid'; import { getDefaultConversation, getDefaultGroup, @@ -19,6 +21,8 @@ import { } from './ConversationHeader.dom.js'; import { gifUrl } from '../../storybook/Fixtures.std.js'; import { ThemeType } from '../../types/Util.std.js'; +import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; +import { CollidingAvatars } from '../CollidingAvatars.dom.js'; export default { title: 'Components/Conversation/ConversationHeader', @@ -31,6 +35,20 @@ type ItemsType = Array<{ props: Omit, 'theme'>; }>; +const alice = getDefaultConversation(); +const bob = getDefaultConversation(); + +const renderCollidingAvatars = () => ( + +); +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 commonConversation = getDefaultConversation(); const commonProps: PropsType = { ...commonConversation, @@ -74,6 +92,19 @@ const commonProps: PropsType = { onViewAllMedia: action('onViewAllMedia'), onViewConversationDetails: action('onViewConversationDetails'), onViewUserStories: action('onViewUserStories'), + + contactSpoofingWarning: null, + acknowledgeGroupMemberNameCollisions: action( + 'acknowledgeGroupMemberNameCollisions' + ), + reviewConversationNameCollision: action('reviewConversationNameCollision'), + renderCollidingAvatars, + + shouldShowMiniPlayer: false, + renderMiniPlayer, + + shouldShowPinnedMessagesBar: false, + renderPinnedMessagesBar, }; export function PrivateConvo(): React.JSX.Element { @@ -511,3 +542,80 @@ export function GroupConversationInCurrentCall(): React.JSX.Element { return ; } + +export function WithSameNameInDirectConversationWarning(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + contactSpoofingWarning: { + type: ContactSpoofingType.DirectConversationWithSameTitle, + safeConversationId: '123', + }, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function WithSameNameInGroupConversationWarning(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + contactSpoofingWarning: { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, + acknowledgedGroupNameCollisions: {}, + groupNameCollisions: { + Alice: times(2, () => generateUuid()), + }, + }, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function WithSameNamesInGroupConversationWarning(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + contactSpoofingWarning: { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, + acknowledgedGroupNameCollisions: {}, + groupNameCollisions: { + Alice: times(2, () => generateUuid()), + Bob: times(3, () => generateUuid()), + }, + }, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function WithJustMiniPlayer(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + shouldShowMiniPlayer: true, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function WithJustPinnedMessagesBar(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + shouldShowPinnedMessagesBar: true, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function WithMinPlayerAndPinnedMessagesBar(): React.JSX.Element { + const props: PropsType = { + ...commonProps, + shouldShowMiniPlayer: true, + shouldShowPinnedMessagesBar: true, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} diff --git a/ts/components/conversation/ConversationHeader.dom.tsx b/ts/components/conversation/ConversationHeader.dom.tsx index e5fa6bba6b..5bd3d3200e 100644 --- a/ts/components/conversation/ConversationHeader.dom.tsx +++ b/ts/components/conversation/ConversationHeader.dom.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { RefObject } from 'react'; import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import type { ReadonlyDeep } from 'type-fest'; import type { BadgeType } from '../../badges/types.std.js'; import { useKeyboardShortcuts, @@ -35,6 +36,22 @@ import { InAnotherCallTooltip } from './InAnotherCallTooltip.dom.js'; import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.js'; import { AxoDropdownMenu } from '../../axo/AxoDropdownMenu.dom.js'; import { strictAssert } from '../../util/assert.std.js'; +import { + TimelineWarning, + TimelineWarningCustomInfo, + TimelineWarningLink, +} from './TimelineWarning.dom.js'; +import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; +import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions.std.js'; +import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions.std.js'; +import type { I18nComponentParts } from '../I18n.dom.js'; +import { I18n } from '../I18n.dom.js'; +import type { SmartCollidingAvatarsProps } from '../../state/smart/CollidingAvatars.dom.js'; +import type { + ContactSpoofingWarning, + MultipleGroupMembersWithSameTitleContactSpoofingWarning, +} from '../../state/selectors/timeline.preload.js'; +import { tw } from '../../axo/tw.dom.js'; function HeaderInfoTitle({ name, @@ -92,6 +109,22 @@ export enum OutgoingCallButtonStyle { Join, } +export type RenderCollidingAvatars = ( + props: SmartCollidingAvatarsProps +) => React.JSX.Element; + +export type RenderMiniPlayer = (options: { + shouldFlow: boolean; +}) => React.JSX.Element; +export type RenderPinnedMessagesBar = () => React.JSX.Element; + +export type AcknowledgeGroupMemberNameCollisions = ( + conversationId: string, + groupNameCollisions: ReadonlyDeep +) => void; + +export type ReviewConversationNameCollission = () => void; + export type PropsDataType = { addedByName: ContactNameData | null; badge?: BadgeType; @@ -109,6 +142,15 @@ export type PropsDataType = { outgoingCallButtonStyle: OutgoingCallButtonStyle; sharedGroupNames: ReadonlyArray; theme: ThemeType; + + contactSpoofingWarning: ContactSpoofingWarning | null; + renderCollidingAvatars: RenderCollidingAvatars; + + shouldShowMiniPlayer: boolean; + renderMiniPlayer: RenderMiniPlayer; + + shouldShowPinnedMessagesBar: boolean; + renderPinnedMessagesBar: RenderPinnedMessagesBar; }; export type PropsActionsType = { @@ -138,6 +180,9 @@ export type PropsActionsType = { onViewAllMedia: () => void; onViewConversationDetails: () => void; onViewUserStories: () => void; + + acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; + reviewConversationNameCollision: ReviewConversationNameCollission; }; export type PropsHousekeepingType = { @@ -189,6 +234,17 @@ export const ConversationHeader = memo(function ConversationHeader({ setLocalDeleteWarningShown, sharedGroupNames, theme, + + contactSpoofingWarning, + acknowledgeGroupMemberNameCollisions, + reviewConversationNameCollision, + renderCollidingAvatars, + + shouldShowMiniPlayer, + renderMiniPlayer, + + shouldShowPinnedMessagesBar, + renderPinnedMessagesBar, }: PropsType): React.JSX.Element | null { // Comes from a third-party dependency const headerRef = useRef(null); @@ -276,106 +332,110 @@ export const ConversationHeader = memo(function ConversationHeader({ > {measureRef => (
- - {!isSmsOnlyOrUnregistered && !isSignalConversation && ( - + - )} -
+ + )} @@ -1052,3 +1127,217 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({ /> ); } + +function ConversationSubheader(props: { + i18n: LocalizerType; + + conversationId: string; + + contactSpoofingWarning: ContactSpoofingWarning | null; + reviewConversationNameCollision: ReviewConversationNameCollission; + acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; + renderCollidingAvatars: RenderCollidingAvatars; + + shouldShowMiniPlayer: boolean; + renderMiniPlayer: RenderMiniPlayer; + + shouldShowPinnedMessagesBar: boolean; + renderPinnedMessagesBar: RenderPinnedMessagesBar; +}) { + const { i18n } = props; + const [ + hasDismissedDirectContactSpoofingWarning, + setHasDismissedDirectContactSpoofingWarning, + ] = useState(false); + + const renderableContactSpoofingWarning = getRenderableContactSpoofingWarning( + props.contactSpoofingWarning, + hasDismissedDirectContactSpoofingWarning + ); + + const handleDismissDirectContactSpoofingWarning = useCallback(() => { + setHasDismissedDirectContactSpoofingWarning(true); + }, []); + + return ( + <> + {renderableContactSpoofingWarning != null && ( + <> + {renderableContactSpoofingWarning.type === + ContactSpoofingType.DirectConversationWithSameTitle && ( + + )} + {renderableContactSpoofingWarning.type === + ContactSpoofingType.MultipleGroupMembersWithSameTitle && ( + + )} + + )} + {props.shouldShowMiniPlayer && + props.renderMiniPlayer({ shouldFlow: true })} + {!props.shouldShowMiniPlayer && + props.shouldShowPinnedMessagesBar && + props.renderPinnedMessagesBar()} + + ); +} + +function getRenderableContactSpoofingWarning( + contactSpoofingWarning: ContactSpoofingWarning | null, + hasDismissedDirectContactSpoofingWarning: boolean +): ContactSpoofingWarning | null { + if (contactSpoofingWarning == null) { + return null; + } + + if ( + contactSpoofingWarning.type === + ContactSpoofingType.DirectConversationWithSameTitle + ) { + const shouldRender = !hasDismissedDirectContactSpoofingWarning; + return shouldRender ? contactSpoofingWarning : null; + } + + if ( + contactSpoofingWarning.type === + ContactSpoofingType.MultipleGroupMembersWithSameTitle + ) { + const shouldRender = hasUnacknowledgedCollisions( + contactSpoofingWarning.acknowledgedGroupNameCollisions, + contactSpoofingWarning.groupNameCollisions + ); + + return shouldRender ? contactSpoofingWarning : null; + } + + throw missingCaseError(contactSpoofingWarning); +} + +function DirectConversationWithSameTitleWarning(props: { + i18n: LocalizerType; + reviewConversationNameCollision: ReviewConversationNameCollission; + onDismissDirectContactSpoofingWarning: () => void; +}) { + const { i18n } = props; + + return ( + + ( + + {parts} + + ), + }} + /> + + ); +} + +function MultipleGroupMembersWithSameTitleWarning(props: { + i18n: LocalizerType; + conversationId: string; + contactSpoofingWarning: MultipleGroupMembersWithSameTitleContactSpoofingWarning; + acknowledgeGroupMemberNameCollisions: AcknowledgeGroupMemberNameCollisions; + reviewConversationNameCollision: ReviewConversationNameCollission; + renderCollidingAvatars: RenderCollidingAvatars; +}) { + const { + i18n, + conversationId, + contactSpoofingWarning, + acknowledgeGroupMemberNameCollisions, + reviewConversationNameCollision, + renderCollidingAvatars, + } = props; + const { groupNameCollisions } = contactSpoofingWarning; + + const numberOfSharedNames = Object.keys(groupNameCollisions).length; + const conversationIds = Object.values(groupNameCollisions).flat(1); + + const handleClose = useCallback(() => { + acknowledgeGroupMemberNameCollisions(conversationId, groupNameCollisions); + }, [ + acknowledgeGroupMemberNameCollisions, + conversationId, + groupNameCollisions, + ]); + + const reviewRequestLink = useCallback( + (parts: I18nComponentParts) => { + return ( + + {parts} + + ); + }, + [reviewConversationNameCollision] + ); + + if (numberOfSharedNames === 1) { + return ( + = 2 ? ( + + {renderCollidingAvatars({ conversationIds })} + + ) : null + } + > + + + ); + } + + return ( + + + + ); +} diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index f20d7651f9..f248e147c0 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import lodash from 'lodash'; import { v4 as uuid } from 'uuid'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; @@ -15,22 +14,15 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext import { ConversationHero } from './ConversationHero.dom.js'; import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.js'; import { TypingBubble } from './TypingBubble.dom.js'; -import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; import { ReadStatus } from '../../messages/MessageReadStatus.std.js'; import type { WidthBreakpoint } from '../_util.std.js'; import { ThemeType } from '../../types/Util.std.js'; import { MessageInteractivity, TextDirection } from './Message.dom.js'; import { PaymentEventKind } from '../../types/Payment.std.js'; import type { PropsData as TimelineMessageProps } from './TimelineMessage.dom.js'; -import { CollidingAvatars } from '../CollidingAvatars.dom.js'; - -const { times } = lodash; const { i18n } = window.SignalContext; -const alice = getDefaultConversation(); -const bob = getDefaultConversation(); - export default { title: 'Components/Conversation/Timeline', argTypes: {}, @@ -455,16 +447,6 @@ const renderTypingBubble = () => ( theme={ThemeType.light} /> ); -const renderCollidingAvatars = () => ( - -); -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'), @@ -485,23 +467,17 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ isNearBottom: null, scrollToIndex: overrideProps.scrollToIndex ?? null, scrollToIndexCounter: 0, - shouldShowMiniPlayer: Boolean(overrideProps.shouldShowMiniPlayer), - shouldShowPinnedMessagesBar: false, totalUnseen: overrideProps.totalUnseen ?? 0, oldestUnseenIndex: overrideProps.oldestUnseenIndex ?? 0, invitedContactsForNewlyCreatedGroup: overrideProps.invitedContactsForNewlyCreatedGroup || [], - warning: overrideProps.warning, hasContactSpoofingReview: false, conversationType: 'direct', id: uuid(), renderItem, renderHeroRow, - renderMiniPlayer, - renderPinnedMessagesBar, renderTypingBubble, - renderCollidingAvatars, renderContactSpoofingReviewDialog, isSomeoneTyping: overrideProps.isSomeoneTyping || false, @@ -595,57 +571,3 @@ export function WithInvitedContactsForANewlyCreatedGroup(): React.JSX.Element { return ; } - -export function WithSameNameInDirectConversationWarning(): React.JSX.Element { - const props = useProps({ - warning: { - type: ContactSpoofingType.DirectConversationWithSameTitle, - - // Just to pacify type-script - safeConversationId: '123', - }, - items: [], - }); - - return ; -} - -export function WithSameNameInGroupConversationWarning(): React.JSX.Element { - const props = useProps({ - warning: { - type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, - acknowledgedGroupNameCollisions: {}, - groupNameCollisions: { - Alice: times(2, () => uuid()), - }, - }, - items: [], - }); - - return ; -} - -export function WithSameNamesInGroupConversationWarning(): React.JSX.Element { - const props = useProps({ - warning: { - type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, - acknowledgedGroupNameCollisions: {}, - groupNameCollisions: { - Alice: times(2, () => uuid()), - Bob: times(3, () => uuid()), - }, - }, - items: [], - }); - - return ; -} - -export function WithJustMiniPlayer(): React.JSX.Element { - const props = useProps({ - shouldShowMiniPlayer: true, - items: [], - }); - - return ; -} diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index 100e69df30..06350e834a 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -6,7 +6,6 @@ import classNames from 'classnames'; import type { ReactNode, RefObject, UIEvent } from 'react'; import React from 'react'; -import type { ReadonlyDeep } from 'type-fest'; import { ScrollDownButton, ScrollDownButtonVariant, @@ -21,14 +20,8 @@ import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary.std. import { WidthBreakpoint } from '../_util.std.js'; import { ErrorBoundary } from './ErrorBoundary.dom.js'; -import { I18n } from '../I18n.dom.js'; -import { TimelineWarning } from './TimelineWarning.dom.js'; -import { TimelineWarnings } from './TimelineWarnings.dom.js'; import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog.dom.js'; -import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog.preload.js'; -import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions.std.js'; -import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions.std.js'; import { TimelineFloatingHeader } from './TimelineFloatingHeader.dom.js'; import { getScrollAnchorBeforeUpdate, @@ -62,18 +55,6 @@ const LOAD_NEWER_THRESHOLD = 5; const DELAY_BEFORE_MARKING_READ_AFTER_FOCUS = SECOND; -export type WarningType = ReadonlyDeep< - | { - type: ContactSpoofingType.DirectConversationWithSameTitle; - safeConversationId: string; - } - | { - type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; - acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle; - groupNameCollisions: GroupNameCollisionsWithIdsByTitle; - } ->; - export type PropsDataType = { haveNewest: boolean; haveOldest: boolean; @@ -102,10 +83,7 @@ type PropsHousekeepingType = { targetedMessageId?: string; invitedContactsForNewlyCreatedGroup: Array; selectedMessageId?: string; - shouldShowMiniPlayer: boolean; - shouldShowPinnedMessagesBar: boolean; - warning?: WarningType; hasContactSpoofingReview: boolean | undefined; discardMessages: ( @@ -123,9 +101,6 @@ type PropsHousekeepingType = { theme: ThemeType; updateVisibleMessages?: (messageIds: Array) => void; - renderCollidingAvatars: (_: { - conversationIds: ReadonlyArray; - }) => React.JSX.Element; renderContactSpoofingReviewDialog: ( props: SmartContactSpoofingReviewDialogPropsType ) => React.JSX.Element; @@ -143,17 +118,11 @@ type PropsHousekeepingType = { previousMessageId: undefined | string; unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; }) => React.JSX.Element; - renderMiniPlayer: (options: { shouldFlow: boolean }) => React.JSX.Element; - renderPinnedMessagesBar: () => React.JSX.Element; + renderTypingBubble: (id: string) => React.JSX.Element; }; export type PropsActionsType = { - // From Model - acknowledgeGroupMemberNameCollisions: ( - conversationId: string, - groupNameCollisions: ReadonlyDeep - ) => void; clearInvitedServiceIdsForNewlyCreatedGroup: () => void; clearTargetedMessage: () => unknown; closeContactSpoofingReview: () => void; @@ -172,7 +141,6 @@ export type PropsActionsType = { messageId: string | undefined ) => void; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => void; - reviewConversationNameCollision: () => void; scrollToOldestUnreadMention: (conversationId: string) => unknown; }; @@ -183,9 +151,7 @@ export type PropsType = PropsDataType & type StateType = { scrollLocked: boolean; scrollLockHeight: number | undefined; - hasDismissedDirectContactSpoofingWarning: boolean; hasRecentlyScrolled: boolean; - lastMeasuredWarningHeight: number; newestBottomVisibleMessageId?: string; oldestPartiallyVisibleMessageId?: string; widthBreakpoint: WidthBreakpoint; @@ -224,10 +190,7 @@ export class Timeline extends React.Component< scrollLocked: false, scrollLockHeight: undefined, hasRecentlyScrolled: true, - hasDismissedDirectContactSpoofingWarning: false, - // These may be swiftly overridden. - lastMeasuredWarningHeight: 0, widthBreakpoint: WidthBreakpoint.Wide, }; @@ -913,7 +876,6 @@ export class Timeline extends React.Component< public override render(): React.JSX.Element | null { const { - acknowledgeGroupMemberNameCollisions, clearInvitedServiceIdsForNewlyCreatedGroup, closeContactSpoofingReview, conversationType, @@ -931,17 +893,11 @@ export class Timeline extends React.Component< items, messageLoadingState, oldestUnseenIndex, - renderCollidingAvatars, renderContactSpoofingReviewDialog, renderHeroRow, renderItem, - renderMiniPlayer, - renderPinnedMessagesBar, renderTypingBubble, - reviewConversationNameCollision, scrollToOldestUnreadMention, - shouldShowMiniPlayer, - shouldShowPinnedMessagesBar, theme, totalUnseen, unreadCount, @@ -951,7 +907,6 @@ export class Timeline extends React.Component< scrollLocked, scrollLockHeight, hasRecentlyScrolled, - lastMeasuredWarningHeight, newestBottomVisibleMessageId, oldestPartiallyVisibleMessageId, widthBreakpoint, @@ -1005,11 +960,6 @@ export class Timeline extends React.Component< void; - if (warning) { - icon = ( - - - - ); - switch (warning.type) { - case ContactSpoofingType.DirectConversationWithSameTitle: - text = ( - ( - - {parts} - - ), - }} - /> - ); - onClose = () => { - this.setState({ - hasDismissedDirectContactSpoofingWarning: true, - }); - }; - break; - case ContactSpoofingType.MultipleGroupMembersWithSameTitle: { - const { groupNameCollisions } = warning; - const numberOfSharedNames = Object.keys(groupNameCollisions).length; - const reviewRequestLink = ( - parts: Array - ): React.JSX.Element => ( - - {parts} - - ); - if (numberOfSharedNames === 1) { - const [conversationIds] = [...Object.values(groupNameCollisions)]; - if (conversationIds.length >= 2) { - icon = ( - - {renderCollidingAvatars({ conversationIds })} - - ); - } - text = ( - - ); - } else { - text = ( - - ); - } - onClose = () => { - acknowledgeGroupMemberNameCollisions(id, groupNameCollisions); - }; - break; - } - default: - throw missingCaseError(warning); - } - } - - headerElements = ( - { - this.setState({ lastMeasuredWarningHeight: size.height }); - }} - > - {measureRef => ( - - {shouldShowMiniPlayer && renderMiniPlayer({ shouldFlow: true })} - {!shouldShowMiniPlayer && - shouldShowPinnedMessagesBar && - renderPinnedMessagesBar()} - {text && ( - - {icon} - {text} - - )} - - )} - - ); - } - let contactSpoofingReviewDialog: ReactNode; if (hasContactSpoofingReview) { contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({ @@ -1237,8 +1075,6 @@ export class Timeline extends React.Component< onKeyDown={this.#handleKeyDown} ref={ref} > - {headerElements} - {floatingHeader}
- {haveOldest && ( - <> - {Timeline.getWarning(this.props, this.state) && ( -
- )} - {renderHeroRow(id)} - - )} + {haveOldest && renderHeroRow(id)} {messageNodes} @@ -1318,31 +1147,6 @@ export class Timeline extends React.Component< ); } - - private static getWarning( - { warning }: PropsType, - state: StateType - ): undefined | WarningType { - if (!warning) { - return undefined; - } - - switch (warning.type) { - case ContactSpoofingType.DirectConversationWithSameTitle: { - const { hasDismissedDirectContactSpoofingWarning } = state; - return hasDismissedDirectContactSpoofingWarning ? undefined : warning; - } - case ContactSpoofingType.MultipleGroupMembersWithSameTitle: - return hasUnacknowledgedCollisions( - warning.acknowledgedGroupNameCollisions, - warning.groupNameCollisions - ) - ? warning - : undefined; - default: - throw missingCaseError(warning); - } - } } function getMessageIdFromElement( diff --git a/ts/components/conversation/TimelineWarning.dom.tsx b/ts/components/conversation/TimelineWarning.dom.tsx index 590b164ddf..e07cfeb45b 100644 --- a/ts/components/conversation/TimelineWarning.dom.tsx +++ b/ts/components/conversation/TimelineWarning.dom.tsx @@ -13,71 +13,57 @@ const TEXT_CLASS_NAME = `${CLASS_NAME}__text`; const LINK_CLASS_NAME = `${TEXT_CLASS_NAME}__link`; const CLOSE_BUTTON_CLASS_NAME = `${CLASS_NAME}__close-button`; -type PropsType = { +type TimelineWarningProps = Readonly<{ + customInfo?: ReactNode; children: ReactNode; i18n: LocalizerType; onClose: () => void; -}; +}>; -export function TimelineWarning({ - children, - i18n, - onClose, -}: Readonly): React.JSX.Element { +export function TimelineWarning( + props: TimelineWarningProps +): React.JSX.Element { + const { i18n } = props; return (
- {children} + {props.customInfo} + {props.customInfo == null && ( +
+
+
+ )} +
{props.children}
); } -function IconContainer({ - children, -}: Readonly<{ children: ReactNode }>): React.JSX.Element { - return
{children}
; -} - -TimelineWarning.IconContainer = IconContainer; - -function GenericIcon() { - return
; -} - -TimelineWarning.GenericIcon = GenericIcon; - -function Text({ - children, -}: Readonly<{ children: ReactNode }>): React.JSX.Element { - return
{children}
; -} - -TimelineWarning.Text = Text; - -type LinkProps = { +type TimelineWarningLinkProps = Readonly<{ children: ReactNode; onClick: () => void; -}; +}>; -function Link({ children, onClick }: Readonly): React.JSX.Element { +export function TimelineWarningLink( + props: TimelineWarningLinkProps +): React.JSX.Element { return ( - ); } -TimelineWarning.Link = Link; +export type TimelineWarningCustomInfoProps = Readonly<{ children: ReactNode }>; -function CustomInfo({ - children, -}: Readonly<{ children: ReactNode }>): React.JSX.Element { - return
{children}
; +export function TimelineWarningCustomInfo( + props: TimelineWarningCustomInfoProps +): React.JSX.Element { + return ( +
{props.children}
+ ); } - -TimelineWarning.CustomInfo = CustomInfo; diff --git a/ts/components/conversation/TimelineWarnings.dom.tsx b/ts/components/conversation/TimelineWarnings.dom.tsx deleted file mode 100644 index baed4f1c4c..0000000000 --- a/ts/components/conversation/TimelineWarnings.dom.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { ReactNode } from 'react'; -import React, { forwardRef } from 'react'; - -const CLASS_NAME = 'module-TimelineWarnings'; - -type PropsType = { - children: ReactNode; -}; - -export const TimelineWarnings = forwardRef( - function TimelineWarningsInner({ children }, ref) { - return ( -
- {children} -
- ); - } -); diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx index dbec9afe77..9501b8a0ef 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx @@ -161,8 +161,10 @@ function Container(props: { > ; + +export type MultipleGroupMembersWithSameTitleContactSpoofingWarning = + ReadonlyDeep<{ + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; + acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle; + groupNameCollisions: GroupNameCollisionsWithIdsByTitle; + }>; + +export type ContactSpoofingWarning = ReadonlyDeep< + | DirectConversationWithSameTitleContactSpoofingWarning + | MultipleGroupMembersWithSameTitleContactSpoofingWarning +>; + +export type ContactSpoofingWarningSelector = ( + conversation: ConversationType +) => ContactSpoofingWarning | null; + +export const getContactSpoofingWarningSelector: StateSelector = + createSelector( + state => state, + rootState => { + return (conversation): ContactSpoofingWarning | null => { + switch (conversation.type) { + case 'direct': + if ( + !conversation.acceptedMessageRequest && + !conversation.isBlocked + ) { + const safeConversation = getSafeConversationWithSameTitle( + rootState, + { + possiblyUnsafeConversation: conversation, + } + ); + + if (safeConversation) { + return { + type: ContactSpoofingType.DirectConversationWithSameTitle, + safeConversationId: safeConversation.id, + }; + } + } + return null; + case 'group': { + if (conversation.left || conversation.groupVersion !== 2) { + return null; + } + + const getConversationByServiceId = + getConversationByServiceIdSelector(rootState); + + const { memberships } = getGroupMemberships( + conversation, + getConversationByServiceId + ); + const groupNameCollisions = + getCollisionsFromMemberships(memberships); + const hasGroupMembersWithSameName = !isEmpty(groupNameCollisions); + if (hasGroupMembersWithSameName) { + return { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, + acknowledgedGroupNameCollisions: + conversation.acknowledgedGroupNameCollisions, + groupNameCollisions: + dehydrateCollisionsWithConversations(groupNameCollisions), + }; + } + + return null; + } + default: + throw missingCaseError(conversation); + } + }; + } + ); diff --git a/ts/state/smart/CollidingAvatars.dom.tsx b/ts/state/smart/CollidingAvatars.dom.tsx index 052900e10b..da4b03dfa9 100644 --- a/ts/state/smart/CollidingAvatars.dom.tsx +++ b/ts/state/smart/CollidingAvatars.dom.tsx @@ -6,13 +6,13 @@ import { CollidingAvatars } from '../../components/CollidingAvatars.dom.js'; import { getIntl } from '../selectors/user.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; -export type PropsType = Readonly<{ +export type SmartCollidingAvatarsProps = Readonly<{ conversationIds: ReadonlyArray; }>; export const SmartCollidingAvatars = memo(function SmartCollidingAvatars({ conversationIds, -}: PropsType) { +}: SmartCollidingAvatarsProps) { const i18n = useSelector(getIntl); const getConversation = useSelector(getConversationSelector); diff --git a/ts/state/smart/ConversationHeader.preload.tsx b/ts/state/smart/ConversationHeader.preload.tsx index 56582b278d..69aee8ede8 100644 --- a/ts/state/smart/ConversationHeader.preload.tsx +++ b/ts/state/smart/ConversationHeader.preload.tsx @@ -39,6 +39,7 @@ import { getConversationSelector, getHasPanelOpen, isMissingRequiredProfileSharing as getIsMissingRequiredProfileSharing, + getPinnedMessages, getSelectedMessageIds, } from '../selectors/conversations.dom.js'; import { getHasStoriesSelector } from '../selectors/stories2.dom.js'; @@ -48,6 +49,27 @@ import { getLocalDeleteWarningShown } from '../selectors/items.dom.js'; import { isConversationEverUnregistered } from '../../util/isConversationUnregistered.dom.js'; import { isDirectConversation } from '../../util/whatTypeOfConversation.dom.js'; import type { DurationInSeconds } from '../../util/durations/index.std.js'; +import { selectAudioPlayerActive } from '../selectors/audioPlayer.preload.js'; +import type { SmartCollidingAvatarsProps } from './CollidingAvatars.dom.js'; +import { SmartCollidingAvatars } from './CollidingAvatars.dom.js'; +import type { SmartMiniPlayerProps } from './MiniPlayer.preload.js'; +import { SmartMiniPlayer } from './MiniPlayer.preload.js'; +import { SmartPinnedMessagesBar } from './PinnedMessagesBar.preload.js'; +import { getContactSpoofingWarningSelector } from '../selectors/timeline.preload.js'; + +function renderCollidingAvatars( + props: SmartCollidingAvatarsProps +): React.JSX.Element { + return ; +} + +function renderMiniPlayer(props: SmartMiniPlayerProps): React.JSX.Element { + return ; +} + +function renderPinnedMessagesBar(): React.JSX.Element { + return ; +} export type OwnProps = { id: string; @@ -108,6 +130,17 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ const activeCall = useSelector(getActiveCallState); const hasActiveCall = Boolean(activeCall); + const contactSpoofingWarningSelector = useSelector( + getContactSpoofingWarningSelector + ); + const contactSpoofingWarning = contactSpoofingWarningSelector(conversation); + + const activeAudioPlayer = useSelector(selectAudioPlayerActive); + const shouldShowMiniPlayer = activeAudioPlayer != null; + + const pinnedMessages = useSelector(getPinnedMessages); + const shouldShowPinnedMessagesBar = pinnedMessages.length > 0; + const { destroyMessages, leaveGroup, @@ -124,6 +157,8 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ blockConversation, reportSpam, deleteConversation, + acknowledgeGroupMemberNameCollisions, + reviewConversationNameCollision, } = useConversationsActions(); const { onOutgoingAudioCallInConversation, @@ -311,6 +346,16 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ setLocalDeleteWarningShown={setLocalDeleteWarningShown} sharedGroupNames={conversation.sharedGroupNames} theme={theme} + contactSpoofingWarning={contactSpoofingWarning} + renderCollidingAvatars={renderCollidingAvatars} + shouldShowMiniPlayer={shouldShowMiniPlayer} + renderMiniPlayer={renderMiniPlayer} + shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar} + renderPinnedMessagesBar={renderPinnedMessagesBar} + acknowledgeGroupMemberNameCollisions={ + acknowledgeGroupMemberNameCollisions + } + reviewConversationNameCollision={reviewConversationNameCollision} /> ); }); diff --git a/ts/state/smart/MiniPlayer.preload.tsx b/ts/state/smart/MiniPlayer.preload.tsx index 71cb82085e..69197f1e17 100644 --- a/ts/state/smart/MiniPlayer.preload.tsx +++ b/ts/state/smart/MiniPlayer.preload.tsx @@ -15,7 +15,7 @@ import { } from '../selectors/audioPlayer.preload.js'; import { getIntl } from '../selectors/user.std.js'; -type Props = Pick; +export type SmartMiniPlayerProps = Pick; /** * Wires the dispatch props and shows/hides the MiniPlayer @@ -25,7 +25,7 @@ type Props = Pick; */ export const SmartMiniPlayer = memo(function SmartMiniPlayer({ shouldFlow, -}: Props): React.JSX.Element | null { +}: SmartMiniPlayerProps): React.JSX.Element | null { const i18n = useSelector(getIntl); const active = useSelector(selectAudioPlayerActive); const getVoiceNoteTitle = useSelector(selectVoiceNoteTitle); diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx index 7efb1ebd4a..f10695244b 100644 --- a/ts/state/smart/Timeline.preload.tsx +++ b/ts/state/smart/Timeline.preload.tsx @@ -1,46 +1,25 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import lodash from 'lodash'; import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; -import type { ReadonlyDeep } from 'type-fest'; -import type { WarningType as TimelineWarningType } from '../../components/conversation/Timeline.dom.js'; import { Timeline } from '../../components/conversation/Timeline.dom.js'; -import { ContactSpoofingType } from '../../util/contactSpoofing.std.js'; -import { getGroupMemberships } from '../../util/getGroupMemberships.dom.js'; -import { - dehydrateCollisionsWithConversations, - getCollisionsFromMemberships, -} from '../../util/groupMemberNameCollisions.std.js'; -import { missingCaseError } from '../../util/missingCaseError.std.js'; import { useCallingActions } from '../ducks/calling.preload.js'; -import { - useConversationsActions, - type ConversationType, -} from '../ducks/conversations.preload.js'; -import type { StateType } from '../reducer.preload.js'; -import { selectAudioPlayerActive } from '../selectors/audioPlayer.preload.js'; +import { useConversationsActions } from '../ducks/conversations.preload.js'; import { getPreferredBadgeSelector } from '../selectors/badges.preload.js'; import { - getConversationByServiceIdSelector, getConversationMessagesSelector, getConversationSelector, getHasContactSpoofingReview, getInvitedContactsForNewlyCreatedGroup, getMessages, - getSafeConversationWithSameTitle, getSelectedConversationId, getTargetedMessage, - getPinnedMessages, } from '../selectors/conversations.dom.js'; import { getIntl, getTheme } from '../selectors/user.std.js'; -import type { PropsType as SmartCollidingAvatarsPropsType } from './CollidingAvatars.dom.js'; -import { SmartCollidingAvatars } from './CollidingAvatars.dom.js'; import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog.preload.js'; import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog.preload.js'; import { SmartHeroRow } from './HeroRow.preload.js'; -import { SmartMiniPlayer } from './MiniPlayer.preload.js'; import { SmartTimelineItem, type SmartTimelineItemProps, @@ -48,9 +27,6 @@ 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'; - -const { isEmpty } = lodash; type ExternalProps = { id: string; @@ -86,12 +62,6 @@ function renderItem({ ); } -function renderCollidingAvatars( - props: SmartCollidingAvatarsPropsType -): React.JSX.Element { - return ; -} - function renderContactSpoofingReviewDialog( props: SmartContactSpoofingReviewDialogPropsType ): React.JSX.Element { @@ -101,70 +71,13 @@ function renderContactSpoofingReviewDialog( function renderHeroRow(id: string): React.JSX.Element { return ; } -function renderMiniPlayer(options: { shouldFlow: boolean }): React.JSX.Element { - return ; -} -function renderPinnedMessagesBar(): React.JSX.Element { - return ; -} function renderTypingBubble(conversationId: string): React.JSX.Element { return ; } -const getWarning = ( - conversation: ReadonlyDeep, - state: Readonly -): undefined | TimelineWarningType => { - switch (conversation.type) { - case 'direct': - if (!conversation.acceptedMessageRequest && !conversation.isBlocked) { - const safeConversation = getSafeConversationWithSameTitle(state, { - possiblyUnsafeConversation: conversation, - }); - - if (safeConversation) { - return { - type: ContactSpoofingType.DirectConversationWithSameTitle, - safeConversationId: safeConversation.id, - }; - } - } - return undefined; - case 'group': { - if (conversation.left || conversation.groupVersion !== 2) { - return undefined; - } - - const getConversationByServiceId = - getConversationByServiceIdSelector(state); - - const { memberships } = getGroupMemberships( - conversation, - getConversationByServiceId - ); - const groupNameCollisions = getCollisionsFromMemberships(memberships); - const hasGroupMembersWithSameName = !isEmpty(groupNameCollisions); - if (hasGroupMembersWithSameName) { - return { - type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, - acknowledgedGroupNameCollisions: - conversation.acknowledgedGroupNameCollisions, - groupNameCollisions: - dehydrateCollisionsWithConversations(groupNameCollisions), - }; - } - - return undefined; - } - default: - throw missingCaseError(conversation); - } -}; - export const SmartTimeline = memo(function SmartTimeline({ id, }: ExternalProps) { - const activeAudioPlayer = useSelector(selectAudioPlayerActive); const conversationMessagesSelector = useSelector( getConversationMessagesSelector ); @@ -182,19 +95,8 @@ 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( - (state: StateType) => { - return getWarning(conversation, state); - }, - [conversation] - ) - ); const { - acknowledgeGroupMemberNameCollisions, clearInvitedServiceIdsForNewlyCreatedGroup, clearTargetedMessage, closeContactSpoofingReview, @@ -203,7 +105,6 @@ export const SmartTimeline = memo(function SmartTimeline({ loadNewestMessages, loadOlderMessages, markMessageRead, - reviewConversationNameCollision, scrollToOldestUnreadMention, setCenterMessage, setIsNearBottom, @@ -218,8 +119,6 @@ export const SmartTimeline = memo(function SmartTimeline({ [messages] ); - const shouldShowMiniPlayer = activeAudioPlayer != null; - const shouldShowPinnedMessagesBar = pinnedMessages.length > 0; const { acceptedMessageRequest, isBlocked = false, @@ -251,9 +150,6 @@ export const SmartTimeline = memo(function SmartTimeline({ return ( ); });