Rework conversation panel layout and sub-header styles

This commit is contained in:
Jamie
2026-01-06 11:14:26 -05:00
committed by GitHub
parent 7a2008a48c
commit fd4a55ef42
18 changed files with 692 additions and 585 deletions
+8 -14
View File
@@ -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;
}
}
@@ -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;
@@ -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;
}
-1
View File
@@ -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';
+10
View File
@@ -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 {
+2
View File
@@ -9,6 +9,8 @@ import type {
} from '../types/Util.std.js';
import { strictAssert } from '../util/assert.std.js';
export type I18nComponentParts = ReadonlyArray<string | React.JSX.Element>;
export type Props<Key extends keyof ICUJSXMessageParamsByKeyType> = {
/** The translation string id */
id: Key;
@@ -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<ComponentProps<typeof ConversationHeader>, 'theme'>;
}>;
const alice = getDefaultConversation();
const bob = getDefaultConversation();
const renderCollidingAvatars = () => (
<CollidingAvatars i18n={i18n} conversations={[alice, bob]} />
);
const renderMiniPlayer = () => (
<div>If active, this is where smart mini player would be</div>
);
const renderPinnedMessagesBar = () => (
<div>If active, this is where the smart pinned messages bar would be</div>
);
const 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 <ConversationHeader {...props} theme={theme} />;
}
export function WithSameNameInDirectConversationWarning(): React.JSX.Element {
const props: PropsType = {
...commonProps,
contactSpoofingWarning: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversationId: '123',
},
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
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 <ConversationHeader {...props} theme={theme} />;
}
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 <ConversationHeader {...props} theme={theme} />;
}
export function WithJustMiniPlayer(): React.JSX.Element {
const props: PropsType = {
...commonProps,
shouldShowMiniPlayer: true,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function WithJustPinnedMessagesBar(): React.JSX.Element {
const props: PropsType = {
...commonProps,
shouldShowPinnedMessagesBar: true,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function WithMinPlayerAndPinnedMessagesBar(): React.JSX.Element {
const props: PropsType = {
...commonProps,
shouldShowMiniPlayer: true,
shouldShowPinnedMessagesBar: true,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
@@ -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<GroupNameCollisionsWithIdsByTitle>
) => void;
export type ReviewConversationNameCollission = () => void;
export type PropsDataType = {
addedByName: ContactNameData | null;
badge?: BadgeType;
@@ -109,6 +142,15 @@ export type PropsDataType = {
outgoingCallButtonStyle: OutgoingCallButtonStyle;
sharedGroupNames: ReadonlyArray<string>;
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<HTMLDivElement>(null);
@@ -276,106 +332,110 @@ export const ConversationHeader = memo(function ConversationHeader({
>
{measureRef => (
<div
className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow,
})}
className={tw('flex flex-col shadow-elevation-1')}
ref={measureRef}
>
<HeaderContent
conversation={conversation}
badge={badge ?? null}
hasStories={hasStories ?? null}
headerRef={headerRef}
i18n={i18n}
sharedGroupNames={sharedGroupNames}
theme={theme}
onViewUserStories={onViewUserStories}
onViewConversationDetails={onViewConversationDetails}
isSignalConversation={isSignalConversation ?? false}
/>
{!isSmsOnlyOrUnregistered && !isSignalConversation && (
<OutgoingCallButtons
<div
className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow,
})}
>
<HeaderContent
conversation={conversation}
hasActiveCall={hasActiveCall}
badge={badge ?? null}
hasStories={hasStories ?? null}
headerRef={headerRef}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall}
onOutgoingVideoCall={onOutgoingVideoCall}
outgoingCallButtonStyle={outgoingCallButtonStyle}
/>
)}
<button
type="button"
onClick={onSearchInConversation}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--search'
)}
aria-label={i18n('icu:search')}
/>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger disabled={isSelectMode}>
<button
type="button"
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--more'
)}
aria-label={i18n('icu:moreInfo')}
/>
</AxoDropdownMenu.Trigger>
<HeaderDropdownMenuContent
i18n={i18n}
conversation={conversation}
isMissingMandatoryProfileSharing={
isMissingMandatoryProfileSharing ?? false
}
isSelectMode={isSelectMode}
isSignalConversation={isSignalConversation ?? false}
onChangeDisappearingMessages={
onConversationDisappearingMessagesChange
}
onChangeMuteExpiration={onConversationMuteExpirationChange}
onConversationAccept={onConversationAccept}
onConversationArchive={onConversationArchive}
onConversationBlock={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
onConversationDelete={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
onConversationDeleteMessages={() => {
setHasDeleteMessagesConfirmation(true);
}}
onConversationLeaveGroup={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true);
} else {
setHasLeaveGroupConfirmation(true);
}
}}
onConversationMarkUnread={onConversationMarkUnread}
onConversationPin={onConversationPin}
onConversationReportAndMaybeBlock={() => {
setMessageRequestState(
MessageRequestState.reportingAndMaybeBlocking
);
}}
onConversationUnarchive={onConversationUnarchive}
onConversationUnblock={() => {
setMessageRequestState(MessageRequestState.unblocking);
}}
onConversationUnpin={onConversationUnpin}
onSelectModeEnter={onSelectModeEnter}
onSetupCustomDisappearingTimeout={() => {
setHasCustomDisappearingTimeoutModal(true);
}}
onShowMembers={onShowMembers}
onViewAllMedia={onViewAllMedia}
sharedGroupNames={sharedGroupNames}
theme={theme}
onViewUserStories={onViewUserStories}
onViewConversationDetails={onViewConversationDetails}
isSignalConversation={isSignalConversation ?? false}
/>
</AxoDropdownMenu.Root>
{!isSmsOnlyOrUnregistered && !isSignalConversation && (
<OutgoingCallButtons
conversation={conversation}
hasActiveCall={hasActiveCall}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall}
onOutgoingVideoCall={onOutgoingVideoCall}
outgoingCallButtonStyle={outgoingCallButtonStyle}
/>
)}
<button
type="button"
onClick={onSearchInConversation}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--search'
)}
aria-label={i18n('icu:search')}
/>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger disabled={isSelectMode}>
<button
type="button"
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--more'
)}
aria-label={i18n('icu:moreInfo')}
/>
</AxoDropdownMenu.Trigger>
<HeaderDropdownMenuContent
i18n={i18n}
conversation={conversation}
isMissingMandatoryProfileSharing={
isMissingMandatoryProfileSharing ?? false
}
isSelectMode={isSelectMode}
isSignalConversation={isSignalConversation ?? false}
onChangeDisappearingMessages={
onConversationDisappearingMessagesChange
}
onChangeMuteExpiration={onConversationMuteExpirationChange}
onConversationAccept={onConversationAccept}
onConversationArchive={onConversationArchive}
onConversationBlock={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
onConversationDelete={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
onConversationDeleteMessages={() => {
setHasDeleteMessagesConfirmation(true);
}}
onConversationLeaveGroup={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true);
} else {
setHasLeaveGroupConfirmation(true);
}
}}
onConversationMarkUnread={onConversationMarkUnread}
onConversationPin={onConversationPin}
onConversationReportAndMaybeBlock={() => {
setMessageRequestState(
MessageRequestState.reportingAndMaybeBlocking
);
}}
onConversationUnarchive={onConversationUnarchive}
onConversationUnblock={() => {
setMessageRequestState(MessageRequestState.unblocking);
}}
onConversationUnpin={onConversationUnpin}
onSelectModeEnter={onSelectModeEnter}
onSetupCustomDisappearingTimeout={() => {
setHasCustomDisappearingTimeoutModal(true);
}}
onShowMembers={onShowMembers}
onViewAllMedia={onViewAllMedia}
onViewConversationDetails={onViewConversationDetails}
/>
</AxoDropdownMenu.Root>
</div>
<MessageRequestActionsConfirmation
i18n={i18n}
@@ -393,6 +453,21 @@ export const ConversationHeader = memo(function ConversationHeader({
deleteConversation={onConversationDelete}
onChangeState={setMessageRequestState}
/>
<ConversationSubheader
i18n={i18n}
contactSpoofingWarning={contactSpoofingWarning}
conversationId={conversation.id}
acknowledgeGroupMemberNameCollisions={
acknowledgeGroupMemberNameCollisions
}
reviewConversationNameCollision={reviewConversationNameCollision}
renderCollidingAvatars={renderCollidingAvatars}
shouldShowMiniPlayer={shouldShowMiniPlayer}
renderMiniPlayer={renderMiniPlayer}
shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar}
renderPinnedMessagesBar={renderPinnedMessagesBar}
/>
</div>
)}
</SizeObserver>
@@ -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 && (
<DirectConversationWithSameTitleWarning
i18n={i18n}
reviewConversationNameCollision={
props.reviewConversationNameCollision
}
onDismissDirectContactSpoofingWarning={
handleDismissDirectContactSpoofingWarning
}
/>
)}
{renderableContactSpoofingWarning.type ===
ContactSpoofingType.MultipleGroupMembersWithSameTitle && (
<MultipleGroupMembersWithSameTitleWarning
i18n={i18n}
conversationId={props.conversationId}
contactSpoofingWarning={renderableContactSpoofingWarning}
acknowledgeGroupMemberNameCollisions={
props.acknowledgeGroupMemberNameCollisions
}
reviewConversationNameCollision={
props.reviewConversationNameCollision
}
renderCollidingAvatars={props.renderCollidingAvatars}
/>
)}
</>
)}
{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 (
<TimelineWarning
i18n={i18n}
onClose={props.onDismissDirectContactSpoofingWarning}
>
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-name--link"
components={{
// This is a render props, not a component
// eslint-disable-next-line react/no-unstable-nested-components
reviewRequestLink: parts => (
<TimelineWarningLink
onClick={props.reviewConversationNameCollision}
>
{parts}
</TimelineWarningLink>
),
}}
/>
</TimelineWarning>
);
}
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 (
<TimelineWarningLink onClick={reviewConversationNameCollision}>
{parts}
</TimelineWarningLink>
);
},
[reviewConversationNameCollision]
);
if (numberOfSharedNames === 1) {
return (
<TimelineWarning
i18n={i18n}
onClose={handleClose}
customInfo={
conversationIds.length >= 2 ? (
<TimelineWarningCustomInfo>
{renderCollidingAvatars({ conversationIds })}
</TimelineWarningCustomInfo>
) : null
}
>
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-name-in-group--link"
components={{
count: conversationIds.length,
reviewRequestLink,
}}
/>
</TimelineWarning>
);
}
return (
<TimelineWarning i18n={i18n} onClose={handleClose}>
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-names-in-group--link"
components={{
count: numberOfSharedNames,
reviewRequestLink,
}}
/>
</TimelineWarning>
);
}
@@ -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 = () => (
<CollidingAvatars i18n={i18n} conversations={[alice, bob]} />
);
const renderMiniPlayer = () => (
<div>If active, this is where smart mini player would be</div>
);
const renderPinnedMessagesBar = () => (
<div>If active, this is where the smart pinned messages bar would be</div>
);
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
discardMessages: action('discardMessages'),
@@ -485,23 +467,17 @@ const useProps = (overrideProps: Partial<PropsType> = {}): 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 <Timeline {...props} />;
}
export function WithSameNameInDirectConversationWarning(): React.JSX.Element {
const props = useProps({
warning: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
// Just to pacify type-script
safeConversationId: '123',
},
items: [],
});
return <Timeline {...props} />;
}
export function WithSameNameInGroupConversationWarning(): React.JSX.Element {
const props = useProps({
warning: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
acknowledgedGroupNameCollisions: {},
groupNameCollisions: {
Alice: times(2, () => uuid()),
},
},
items: [],
});
return <Timeline {...props} />;
}
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 <Timeline {...props} />;
}
export function WithJustMiniPlayer(): React.JSX.Element {
const props = useProps({
shouldShowMiniPlayer: true,
items: [],
});
return <Timeline {...props} />;
}
+2 -198
View File
@@ -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<ConversationType>;
selectedMessageId?: string;
shouldShowMiniPlayer: boolean;
shouldShowPinnedMessagesBar: boolean;
warning?: WarningType;
hasContactSpoofingReview: boolean | undefined;
discardMessages: (
@@ -123,9 +101,6 @@ type PropsHousekeepingType = {
theme: ThemeType;
updateVisibleMessages?: (messageIds: Array<string>) => void;
renderCollidingAvatars: (_: {
conversationIds: ReadonlyArray<string>;
}) => 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<GroupNameCollisionsWithIdsByTitle>
) => 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<
<TimelineFloatingHeader
i18n={i18n}
isLoading={isLoadingMessages}
style={
lastMeasuredWarningHeight
? { marginTop: lastMeasuredWarningHeight }
: undefined
}
timestamp={oldestPartiallyVisibleMessageTimestamp}
visible={
(hasRecentlyScrolled || isLoadingMessages) &&
@@ -1086,118 +1036,6 @@ export class Timeline extends React.Component<
);
}
const warning = Timeline.getWarning(this.props, this.state);
let headerElements: ReactNode;
if (warning || shouldShowMiniPlayer || shouldShowPinnedMessagesBar) {
let text: ReactNode | undefined;
let icon: ReactNode | undefined;
let onClose: () => void;
if (warning) {
icon = (
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
);
switch (warning.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
text = (
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-name--link"
components={{
// This is a render props, not a component
// eslint-disable-next-line react/no-unstable-nested-components
reviewRequestLink: parts => (
<TimelineWarning.Link
onClick={reviewConversationNameCollision}
>
{parts}
</TimelineWarning.Link>
),
}}
/>
);
onClose = () => {
this.setState({
hasDismissedDirectContactSpoofingWarning: true,
});
};
break;
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
const { groupNameCollisions } = warning;
const numberOfSharedNames = Object.keys(groupNameCollisions).length;
const reviewRequestLink = (
parts: Array<string | React.JSX.Element>
): React.JSX.Element => (
<TimelineWarning.Link onClick={reviewConversationNameCollision}>
{parts}
</TimelineWarning.Link>
);
if (numberOfSharedNames === 1) {
const [conversationIds] = [...Object.values(groupNameCollisions)];
if (conversationIds.length >= 2) {
icon = (
<TimelineWarning.CustomInfo>
{renderCollidingAvatars({ conversationIds })}
</TimelineWarning.CustomInfo>
);
}
text = (
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-name-in-group--link"
components={{
count: conversationIds.length,
reviewRequestLink,
}}
/>
);
} else {
text = (
<I18n
i18n={i18n}
id="icu:ContactSpoofing__same-names-in-group--link"
components={{
count: numberOfSharedNames,
reviewRequestLink,
}}
/>
);
}
onClose = () => {
acknowledgeGroupMemberNameCollisions(id, groupNameCollisions);
};
break;
}
default:
throw missingCaseError(warning);
}
}
headerElements = (
<SizeObserver
onSizeChange={size => {
this.setState({ lastMeasuredWarningHeight: size.height });
}}
>
{measureRef => (
<TimelineWarnings ref={measureRef}>
{shouldShowMiniPlayer && renderMiniPlayer({ shouldFlow: true })}
{!shouldShowMiniPlayer &&
shouldShowPinnedMessagesBar &&
renderPinnedMessagesBar()}
{text && (
<TimelineWarning i18n={i18n} onClose={onClose}>
{icon}
<TimelineWarning.Text>{text}</TimelineWarning.Text>
</TimelineWarning>
)}
</TimelineWarnings>
)}
</SizeObserver>
);
}
let contactSpoofingReviewDialog: ReactNode;
if (hasContactSpoofingReview) {
contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({
@@ -1237,8 +1075,6 @@ export class Timeline extends React.Component<
onKeyDown={this.#handleKeyDown}
ref={ref}
>
{headerElements}
{floatingHeader}
<main
@@ -1261,14 +1097,7 @@ export class Timeline extends React.Component<
: undefined
}
>
{haveOldest && (
<>
{Timeline.getWarning(this.props, this.state) && (
<div style={{ height: lastMeasuredWarningHeight }} />
)}
{renderHeroRow(id)}
</>
)}
{haveOldest && renderHeroRow(id)}
{messageNodes}
@@ -1318,31 +1147,6 @@ export class Timeline extends React.Component<
</ScrollerLockContext.Provider>
);
}
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(
@@ -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<PropsType>): React.JSX.Element {
export function TimelineWarning(
props: TimelineWarningProps
): React.JSX.Element {
const { i18n } = props;
return (
<div className={CLASS_NAME}>
{children}
{props.customInfo}
{props.customInfo == null && (
<div className={ICON_CONTAINER_CLASS_NAME}>
<div className={GENERIC_ICON_CLASS_NAME} />
</div>
)}
<div className={TEXT_CLASS_NAME}>{props.children}</div>
<button
aria-label={i18n('icu:close')}
className={CLOSE_BUTTON_CLASS_NAME}
onClick={onClose}
onClick={props.onClose}
type="button"
/>
</div>
);
}
function IconContainer({
children,
}: Readonly<{ children: ReactNode }>): React.JSX.Element {
return <div className={ICON_CONTAINER_CLASS_NAME}>{children}</div>;
}
TimelineWarning.IconContainer = IconContainer;
function GenericIcon() {
return <div className={GENERIC_ICON_CLASS_NAME} />;
}
TimelineWarning.GenericIcon = GenericIcon;
function Text({
children,
}: Readonly<{ children: ReactNode }>): React.JSX.Element {
return <div className={TEXT_CLASS_NAME}>{children}</div>;
}
TimelineWarning.Text = Text;
type LinkProps = {
type TimelineWarningLinkProps = Readonly<{
children: ReactNode;
onClick: () => void;
};
}>;
function Link({ children, onClick }: Readonly<LinkProps>): React.JSX.Element {
export function TimelineWarningLink(
props: TimelineWarningLinkProps
): React.JSX.Element {
return (
<button className={LINK_CLASS_NAME} onClick={onClick} type="button">
{children}
<button className={LINK_CLASS_NAME} onClick={props.onClick} type="button">
{props.children}
</button>
);
}
TimelineWarning.Link = Link;
export type TimelineWarningCustomInfoProps = Readonly<{ children: ReactNode }>;
function CustomInfo({
children,
}: Readonly<{ children: ReactNode }>): React.JSX.Element {
return <div className="module-TimelineWarning__custom_info">{children}</div>;
export function TimelineWarningCustomInfo(
props: TimelineWarningCustomInfoProps
): React.JSX.Element {
return (
<div className="module-TimelineWarning__custom_info">{props.children}</div>
);
}
TimelineWarning.CustomInfo = CustomInfo;
@@ -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<HTMLDivElement, PropsType>(
function TimelineWarningsInner({ children }, ref) {
return (
<div className={CLASS_NAME} ref={ref}>
{children}
</div>
);
}
);
@@ -161,8 +161,10 @@ function Container(props: {
>
<AriaClickable.Root
className={tw(
'flex h-14 items-center bg-background-primary py-2.5 pe-3 select-none',
'flex h-14 items-center py-2.5 pe-3 select-none',
'rounded-xs',
'bg-legacy-conversation-header-bg',
'border-t-[0.5px] border-t-border-primary',
'outline-0 outline-border-focused',
'data-[focused]:outline-[2.5px]',
props.pinsCount === 1 && 'ps-4'
+97
View File
@@ -2,6 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { isEmpty } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import type { TimelineItemType } from '../../components/conversation/TimelineItem.dom.js';
import type { StateType } from '../reducer.preload.js';
@@ -12,6 +15,8 @@ import {
getMessages,
getCachedConversationMemberColorsSelector,
getPinnedMessagesMessageIds,
getSafeConversationWithSameTitle,
getConversationByServiceIdSelector,
} from './conversations.dom.js';
import { getAccountSelector } from './accounts.std.js';
import {
@@ -26,6 +31,16 @@ 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 type { StateSelector } from '../types.std.js';
import { ContactSpoofingType } from '../../util/contactSpoofing.std.js';
import {
dehydrateCollisionsWithConversations,
getCollisionsFromMemberships,
type GroupNameCollisionsWithIdsByTitle,
} from '../../util/groupMemberNameCollisions.std.js';
import type { ConversationType } from '../ducks/conversations.preload.js';
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { getGroupMemberships } from '../../util/getGroupMemberships.dom.js';
const getTimelineItem = (
state: StateType,
@@ -92,3 +107,85 @@ export const useTimelineItem = (
return useProxySelector(getTimelineItem, messageId, contactNameColors);
};
export type DirectConversationWithSameTitleContactSpoofingWarning =
ReadonlyDeep<{
type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversationId: string;
}>;
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<ContactSpoofingWarningSelector> =
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);
}
};
}
);
+2 -2
View File
@@ -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<string>;
}>;
export const SmartCollidingAvatars = memo(function SmartCollidingAvatars({
conversationIds,
}: PropsType) {
}: SmartCollidingAvatarsProps) {
const i18n = useSelector(getIntl);
const getConversation = useSelector(getConversationSelector);
@@ -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 <SmartCollidingAvatars {...props} />;
}
function renderMiniPlayer(props: SmartMiniPlayerProps): React.JSX.Element {
return <SmartMiniPlayer {...props} />;
}
function renderPinnedMessagesBar(): React.JSX.Element {
return <SmartPinnedMessagesBar />;
}
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}
/>
);
});
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from '../selectors/audioPlayer.preload.js';
import { getIntl } from '../selectors/user.std.js';
type Props = Pick<DumbProps, 'shouldFlow'>;
export type SmartMiniPlayerProps = Pick<DumbProps, 'shouldFlow'>;
/**
* Wires the dispatch props and shows/hides the MiniPlayer
@@ -25,7 +25,7 @@ type Props = Pick<DumbProps, 'shouldFlow'>;
*/
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);
+1 -112
View File
@@ -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 <SmartCollidingAvatars {...props} />;
}
function renderContactSpoofingReviewDialog(
props: SmartContactSpoofingReviewDialogPropsType
): React.JSX.Element {
@@ -101,70 +71,13 @@ function renderContactSpoofingReviewDialog(
function renderHeroRow(id: string): React.JSX.Element {
return <SmartHeroRow id={id} />;
}
function renderMiniPlayer(options: { shouldFlow: boolean }): React.JSX.Element {
return <SmartMiniPlayer {...options} />;
}
function renderPinnedMessagesBar(): React.JSX.Element {
return <SmartPinnedMessagesBar />;
}
function renderTypingBubble(conversationId: string): React.JSX.Element {
return <SmartTypingBubble conversationId={conversationId} />;
}
const getWarning = (
conversation: ReadonlyDeep<ConversationType>,
state: Readonly<StateType>
): 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 (
<Timeline
acknowledgeGroupMemberNameCollisions={
acknowledgeGroupMemberNameCollisions
}
clearInvitedServiceIdsForNewlyCreatedGroup={
clearInvitedServiceIdsForNewlyCreatedGroup
}
@@ -288,28 +184,21 @@ export const SmartTimeline = memo(function SmartTimeline({
AttachmentDownloadManager.updateVisibleTimelineMessages
}
oldestUnseenIndex={oldestUnseenIndex}
renderCollidingAvatars={renderCollidingAvatars}
renderContactSpoofingReviewDialog={renderContactSpoofingReviewDialog}
renderHeroRow={renderHeroRow}
renderItem={renderItem}
renderMiniPlayer={renderMiniPlayer}
renderPinnedMessagesBar={renderPinnedMessagesBar}
renderTypingBubble={renderTypingBubble}
reviewConversationNameCollision={reviewConversationNameCollision}
scrollToIndex={scrollToIndex}
scrollToIndexCounter={scrollToIndexCounter}
scrollToOldestUnreadMention={scrollToOldestUnreadMention}
setCenterMessage={setCenterMessage}
setIsNearBottom={setIsNearBottom}
shouldShowMiniPlayer={shouldShowMiniPlayer}
shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar}
targetedMessageId={targetedMessageId}
targetMessage={targetMessage}
theme={theme}
totalUnseen={totalUnseen}
unreadCount={unreadCount}
unreadMentionsCount={unreadMentionsCount}
warning={warning}
/>
);
});