Spam Reporting UI changes

This commit is contained in:
Jamie Kyle 2024-03-12 09:29:31 -07:00 committed by GitHub
parent e031d136a1
commit 8387f938eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 2711 additions and 807 deletions

View file

@ -108,7 +108,7 @@ export default {
blockConversation: action('blockConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
deleteConversation: action('deleteConversation'),
title: '',
conversationName: getDefaultConversation(),
// GroupV1 Disabled Actions
showGV2MigrationDialog: action('showGV2MigrationDialog'),
// GroupV2

View file

@ -1,7 +1,7 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
@ -43,6 +43,7 @@ import type { AciString } from '../types/ServiceId';
import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload';
import type {
ConversationRemovalStage,
ConversationType,
PushPanelForConversationActionType,
ShowConversationType,
@ -73,16 +74,16 @@ import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d';
export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean;
removalStage?: 'justNotification' | 'messageRequest';
acceptedMessageRequest: boolean | null;
removalStage: ConversationRemovalStage | null;
addAttachment: (
conversationId: string,
attachment: InMemoryAttachmentDraftType
) => unknown;
announcementsOnly?: boolean;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
announcementsOnly: boolean | null;
areWeAdmin: boolean | null;
areWePending: boolean | null;
areWePendingApproval: boolean | null;
cancelRecording: () => unknown;
completeRecording: (
conversationId: string,
@ -93,29 +94,29 @@ export type OwnProps = Readonly<{
) => HydratedBodyRangesType | undefined;
conversationId: string;
discardEditMessage: (id: string) => unknown;
draftEditMessage?: DraftEditMessageType;
draftEditMessage: DraftEditMessageType | null;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number;
groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2;
groupVersion: 1 | 2 | null;
i18n: LocalizerType;
imageToBlurHash: typeof imageToBlurHash;
isDisabled: boolean;
isFetchingUUID?: boolean;
isFetchingUUID: boolean | null;
isFormattingEnabled: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSignalConversation?: boolean;
lastEditableMessageId?: string;
isGroupV1AndDisabled: boolean | null;
isMissingMandatoryProfileSharing: boolean | null;
isSignalConversation: boolean | null;
lastEditableMessageId: string | null;
recordingState: RecordingState;
messageCompositionId: string;
shouldHidePopovers?: boolean;
isSMSOnly?: boolean;
left?: boolean;
shouldHidePopovers: boolean | null;
isSMSOnly: boolean | null;
left: boolean | null;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewType;
linkPreviewResult: LinkPreviewType | null;
onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown;
platform: string;
@ -149,15 +150,15 @@ export type OwnProps = Readonly<{
voiceNoteAttachment?: InMemoryAttachmentDraftType;
}
): unknown;
quotedMessageId?: string;
quotedMessageProps?: ReadonlyDeep<
quotedMessageId: string | null;
quotedMessageProps: null | ReadonlyDeep<
Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
>
>;
quotedMessageAuthorAci?: AciString;
quotedMessageSentAt?: number;
quotedMessageAuthorAci: AciString | null;
quotedMessageSentAt: number | null;
removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
@ -210,6 +211,7 @@ export type Props = Pick<
| 'blessedPacks'
| 'recentStickers'
| 'clearInstalledStickerPack'
| 'showIntroduction'
| 'clearShowIntroduction'
| 'showPickerHint'
| 'clearShowPickerHint'
@ -220,7 +222,7 @@ export type Props = Pick<
pushPanelForConversation: PushPanelForConversationActionType;
} & OwnProps;
export function CompositionArea({
export const CompositionArea = memo(function CompositionArea({
// Base props
addAttachment,
conversationId,
@ -291,6 +293,7 @@ export function CompositionArea({
recentStickers,
clearInstalledStickerPack,
sendStickerMessage,
showIntroduction,
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
@ -301,14 +304,18 @@ export function CompositionArea({
conversationType,
groupVersion,
isBlocked,
isHidden,
isReported,
isMissingMandatoryProfileSharing,
left,
removalStage,
acceptConversation,
blockConversation,
reportSpam,
blockAndReportSpam,
deleteConversation,
title,
conversationName,
addedByName,
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
showGV2MigrationDialog,
@ -356,8 +363,8 @@ export function CompositionArea({
bodyRanges,
message,
// sent timestamp for the quote
quoteSentAt: quotedMessageSentAt,
quoteAuthorAci: quotedMessageAuthorAci,
quoteSentAt: quotedMessageSentAt ?? undefined,
quoteAuthorAci: quotedMessageAuthorAci ?? undefined,
targetMessageId: editedMessageId,
});
} else {
@ -469,12 +476,7 @@ export function CompositionArea({
) {
inputApiRef.current.reset();
}
}, [
messageCompositionId,
sendCounter,
previousMessageCompositionId,
previousSendCounter,
]);
}, [messageCompositionId, sendCounter, previousMessageCompositionId, previousSendCounter]);
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
@ -504,7 +506,7 @@ export function CompositionArea({
inputApiRef.current?.setContents(
draftEditMessageBody ?? '',
draftBodyRanges,
draftBodyRanges ?? undefined,
true
);
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
@ -520,7 +522,11 @@ export function CompositionArea({
return;
}
inputApiRef.current?.setContents(draftText, draftBodyRanges, true);
inputApiRef.current?.setContents(
draftText,
draftBodyRanges ?? undefined,
true
);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]);
const handleToggleLarge = useCallback(() => {
@ -637,6 +643,7 @@ export function CompositionArea({
onPickSticker={(packId, stickerId) =>
sendStickerMessage(conversationId, { packId, stickerId })
}
showIntroduction={showIntroduction}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
@ -735,16 +742,19 @@ export function CompositionArea({
) {
return (
<MessageRequestActions
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId}
addedByName={addedByName}
conversationType={conversationType}
deleteConversation={deleteConversation}
conversationId={conversationId}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isHidden={removalStage !== undefined}
title={title}
isHidden={isHidden}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
@ -788,14 +798,18 @@ export function CompositionArea({
) {
return (
<MandatoryProfileSharingActions
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
deleteConversation={deleteConversation}
conversationName={conversationName}
i18n={i18n}
title={title}
isBlocked={isBlocked}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/>
);
}
@ -993,7 +1007,7 @@ export function CompositionArea({
platform={platform}
sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
skinTone={skinTone}
skinTone={skinTone ?? null}
sortedGroupMembers={sortedGroupMembers}
theme={theme}
/>
@ -1031,4 +1045,4 @@ export function CompositionArea({
/>
</div>
);
}
});

View file

@ -21,30 +21,38 @@ export default {
args: {},
} satisfies Meta<Props>;
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
disabled: overrideProps.disabled ?? false,
draftText: overrideProps.draftText || undefined,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
large: overrideProps.large ?? false,
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
skinTone: overrideProps.skinTone ?? undefined,
theme: React.useContext(StorybookThemeContext),
});
const useProps = (overrideProps: Partial<Props> = {}): Props => {
const conversation = getDefaultConversation();
return {
i18n,
conversationId: conversation.id,
disabled: overrideProps.disabled ?? false,
draftText: overrideProps.draftText ?? null,
draftEditMessage: overrideProps.draftEditMessage ?? null,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
large: overrideProps.large ?? false,
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
skinTone: overrideProps.skinTone ?? null,
theme: React.useContext(StorybookThemeContext),
inputApi: null,
shouldHidePopovers: null,
linkPreviewResult: null,
};
};
export function Default(): JSX.Element {
const props = useProps();

View file

@ -96,22 +96,22 @@ export type InputApi = {
export type Props = Readonly<{
children?: React.ReactNode;
conversationId?: string;
conversationId: string | null;
i18n: LocalizerType;
disabled?: boolean;
draftEditMessage?: DraftEditMessageType;
draftEditMessage: DraftEditMessageType | null;
getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>;
large: boolean | null;
inputApi: React.MutableRefObject<InputApi | undefined> | null;
isFormattingEnabled: boolean;
sendCounter: number;
skinTone?: EmojiPickDataType['skinTone'];
draftText?: string;
draftBodyRanges?: HydratedBodyRangesType;
skinTone: NonNullable<EmojiPickDataType['skinTone']> | null;
draftText: string | null;
draftBodyRanges: HydratedBodyRangesType | null;
moduleClassName?: string;
theme: ThemeType;
placeholder?: string;
sortedGroupMembers?: ReadonlyArray<ConversationType>;
sortedGroupMembers: ReadonlyArray<ConversationType> | null;
scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(options: {
@ -132,11 +132,11 @@ export type Props = Readonly<{
): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
platform: string;
shouldHidePopovers?: boolean;
shouldHidePopovers: boolean | null;
getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown;
linkPreviewLoading?: boolean;
linkPreviewResult?: LinkPreviewType;
linkPreviewResult: LinkPreviewType | null;
onCloseLinkPreview?(conversationId: string): unknown;
}>;
@ -562,7 +562,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onEditorStateChange({
bodyRanges,
caretLocation: selection ? selection.index : undefined,
conversationId,
conversationId: conversationId ?? undefined,
messageText: text,
sendCounter,
});
@ -612,7 +612,7 @@ export function CompositionInput(props: Props): React.ReactElement {
React.useEffect(() => {
const emojiCompletion = emojiCompletionRef.current;
if (emojiCompletion === undefined || skinTone === undefined) {
if (emojiCompletion == null || skinTone == null) {
return;
}

View file

@ -19,7 +19,7 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType;
bodyRanges: HydratedBodyRangesType | null;
i18n: LocalizerType;
isFormattingEnabled: boolean;
maxLength?: number;
@ -153,6 +153,17 @@ export function CompositionTextArea({
scrollerRef={scrollerRef}
sendCounter={0}
theme={theme}
skinTone={skinTone ?? null}
// These do not apply in the forward modal because there isn't
// strictly one conversation
conversationId={null}
sortedGroupMembers={null}
// we don't edit in this context
draftEditMessage={null}
// rendered in the forward modal
linkPreviewResult={null}
// Panels appear behind this modal
shouldHidePopovers={null}
/>
<div className="CompositionTextArea__emoji">
<EmojiButton

View file

@ -470,7 +470,7 @@ function ForwardMessageEditor({
) : null}
<RenderCompositionTextArea
bodyRanges={draft.bodyRanges}
bodyRanges={draft.bodyRanges ?? null}
draftText={draft.messageBody ?? ''}
onChange={onChange}
onSubmit={onSubmit}

View file

@ -9,6 +9,7 @@ import type {
EditHistoryMessagesType,
FormattingWarningDataType,
ForwardMessagesPropsType,
MessageRequestActionsConfirmationPropsType,
SafetyNumberChangedBlockingDataType,
SendEditWarningDataType,
UserNotFoundModalStateType,
@ -59,6 +60,9 @@ export type PropsType = {
// ForwardMessageModal
forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessagesModal: () => JSX.Element;
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
renderMessageRequestActionsConfirmation: () => JSX.Element;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
@ -130,6 +134,9 @@ export function GlobalModalContainer({
// ForwardMessageModal
forwardMessagesProps,
renderForwardMessagesModal,
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps,
renderMessageRequestActionsConfirmation,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
@ -223,6 +230,10 @@ export function GlobalModalContainer({
return renderForwardMessagesModal();
}
if (messageRequestActionsConfirmationProps) {
return renderMessageRequestActionsConfirmation();
}
if (isProfileEditorVisible) {
return renderProfileEditor();
}

View file

@ -176,13 +176,12 @@ export function MediaEditor({
const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false);
const [caption, setCaption] = useState(draftText ?? '');
const [captionBodyRanges, setCaptionBodyRanges] = useState<
DraftBodyRanges | undefined
>(draftBodyRanges);
const [captionBodyRanges, setCaptionBodyRanges] =
useState<DraftBodyRanges | null>(draftBodyRanges);
const conversationSelector = useSelector(getConversationSelector);
const hydratedBodyRanges = useMemo(
() => hydrateRanges(captionBodyRanges, conversationSelector),
() => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector),
[captionBodyRanges, conversationSelector]
);
@ -1297,7 +1296,7 @@ export function MediaEditor({
<div className="MediaEditor__tools--input dark-theme">
<CompositionInput
draftText={caption}
draftBodyRanges={hydratedBodyRanges}
draftBodyRanges={hydratedBodyRanges ?? null}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
@ -1308,6 +1307,7 @@ export function MediaEditor({
setCaptionBodyRanges(bodyRanges);
setCaption(messageText);
}}
skinTone={skinTone ?? null}
onPickEmoji={onPickEmoji}
onSubmit={noop}
onTextTooLong={onTextTooLong}
@ -1316,6 +1316,16 @@ export function MediaEditor({
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark}
// Only needed for state updates and we need to override those
conversationId={null}
// Cannot enter media editor while editing
draftEditMessage={null}
// We don't use the large editor mode
large={null}
// panels do not appear over the media editor
shouldHidePopovers={null}
// link previews not displayed with media
linkPreviewResult={null}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
@ -1394,7 +1404,7 @@ export function MediaEditor({
contentType: IMAGE_PNG,
data,
caption: caption !== '' ? caption : undefined,
captionBodyRanges,
captionBodyRanges: captionBodyRanges ?? undefined,
blurHash,
});
}}

View file

@ -7,6 +7,7 @@ import classNames from 'classnames';
import { noop } from 'lodash';
import { animated } from '@react-spring/web';
import { v4 as uuid } from 'uuid';
import type { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
import type { Theme } from '../util/theme';
@ -37,6 +38,7 @@ type PropsType = {
title?: ReactNode;
useFocusTrap?: boolean;
padded?: boolean;
['aria-describedby']?: string;
};
export type ModalPropsType = PropsType & {
@ -65,6 +67,7 @@ export function Modal({
hasFooterDivider = false,
noTransform = false,
padded = true,
'aria-describedby': ariaDescribedBy,
}: Readonly<ModalPropsType>): JSX.Element | null {
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(
onClose,
@ -132,6 +135,7 @@ export function Modal({
padded={padded}
hasHeaderDivider={hasHeaderDivider}
hasFooterDivider={hasFooterDivider}
aria-describedby={ariaDescribedBy}
>
{children}
</ModalPage>
@ -173,6 +177,7 @@ export function ModalPage({
padded = true,
hasHeaderDivider = false,
hasFooterDivider = false,
'aria-describedby': ariaDescribedBy,
}: ModalPageProps): JSX.Element {
const modalRef = useRef<HTMLDivElement | null>(null);
@ -188,6 +193,8 @@ export function ModalPage({
);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
const [id] = useState(() => uuid());
useScrollObserver(bodyRef, bodyInnerRef, scroll => {
setScrolled(isScrolled(scroll));
setScrolledToBottom(isScrolledToBottom(scroll));
@ -198,7 +205,7 @@ export function ModalPage({
<>
{/* We don't want the click event to propagate to its container node. */}
{/* eslint-disable-next-line max-len */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<div
className={classNames(
getClassName(''),
@ -209,6 +216,10 @@ export function ModalPage({
hasFooterDivider && getClassName('--footer-divider')
)}
ref={modalRef}
role="dialog"
tabIndex={-1}
aria-labelledby={title ? `${id}-title` : undefined}
aria-describedby={ariaDescribedBy}
onClick={event => {
event.stopPropagation();
}}
@ -234,6 +245,7 @@ export function ModalPage({
)}
{title && (
<h1
id={`${id}-title`}
className={classNames(
getClassName('__title'),
hasXButton ? getClassName('__title--with-x-button') : null

View file

@ -0,0 +1,24 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import { setupI18n } from '../util/setupI18n';
import type { SafetyTipsModalProps } from './SafetyTipsModal';
import { SafetyTipsModal } from './SafetyTipsModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SafetyTipsModal',
component: SafetyTipsModal,
args: {
i18n,
onClose: action('onClose'),
},
} satisfies ComponentMeta<SafetyTipsModalProps>;
export function Default(args: SafetyTipsModalProps): JSX.Element {
return <SafetyTipsModal {...args} />;
}

View file

@ -0,0 +1,216 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UIEvent } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import uuid from 'uuid';
import type { LocalizerType } from '../types/I18N';
import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button';
import { useReducedMotion } from '../hooks/useReducedMotion';
export type SafetyTipsModalProps = Readonly<{
i18n: LocalizerType;
onClose(): void;
}>;
export function SafetyTipsModal({
i18n,
onClose,
}: SafetyTipsModalProps): JSX.Element {
const pages = useMemo(() => {
return [
{
key: 'crypto',
title: i18n('icu:SafetyTipsModal__TipTitle--Crypto'),
description: i18n('icu:SafetyTipsModal__TipDescription--Crypto'),
imageUrl: 'images/safety-tips/safety-tip-crypto.png',
},
{
key: 'vague',
title: i18n('icu:SafetyTipsModal__TipTitle--Vague'),
description: i18n('icu:SafetyTipsModal__TipDescription--Vague'),
imageUrl: 'images/safety-tips/safety-tip-vague.png',
},
{
key: 'links',
title: i18n('icu:SafetyTipsModal__TipTitle--Links'),
description: i18n('icu:SafetyTipsModal__TipDescription--Links'),
imageUrl: 'images/safety-tips/safety-tip-links.png',
},
{
key: 'business',
title: i18n('icu:SafetyTipsModal__TipTitle--Business'),
description: i18n('icu:SafetyTipsModal__TipDescription--Business'),
imageUrl: 'images/safety-tips/safety-tip-business.png',
},
];
}, [i18n]);
const [modalId] = useState(() => uuid());
const [cardWrapperId] = useState(() => uuid());
function getCardIdForPage(pageIndex: number) {
return `${cardWrapperId}_${pages[pageIndex].key}`;
}
const maxPageIndex = pages.length - 1;
const [pageIndex, setPageIndexInner] = useState(0);
const reducedMotion = useReducedMotion();
const scrollEndTimer = useRef<NodeJS.Timeout | null>(null);
const [hasPageIndexChanged, setHasPageIndexChanged] = useState(false);
function setPageIndex(nextPageIndex: number) {
setPageIndexInner(nextPageIndex);
setHasPageIndexChanged(true);
}
function clearScrollEndTimer() {
if (scrollEndTimer.current != null) {
clearTimeout(scrollEndTimer.current);
scrollEndTimer.current = null;
}
}
useEffect(() => {
return () => {
clearScrollEndTimer();
};
}, []);
function scrollToPageIndex(nextPageIndex: number) {
clearScrollEndTimer();
setPageIndex(nextPageIndex);
document.getElementById(getCardIdForPage(nextPageIndex))?.scrollIntoView({
inline: 'center',
behavior: reducedMotion ? 'instant' : 'smooth',
});
}
function handleScroll(event: UIEvent) {
clearScrollEndTimer();
const { scrollWidth, scrollLeft, clientWidth } = event.currentTarget;
const maxScrollLeft = scrollWidth - clientWidth;
const absScrollLeft = Math.abs(scrollLeft);
const percentScrolled = absScrollLeft / maxScrollLeft;
const scrolledPageIndex = Math.round(percentScrolled * maxPageIndex);
scrollEndTimer.current = setTimeout(() => {
setPageIndex(scrolledPageIndex);
}, 100);
}
return (
<Modal
i18n={i18n}
modalName="SafetyTipsModal"
moduleClassName="SafetyTipsModal"
noMouseClose
hasXButton
padded={false}
title={i18n('icu:SafetyTipsModal__Title')}
onClose={onClose}
aria-describedby={`${modalId}-description`}
modalFooter={
<>
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Previous"
variant={ButtonVariant.SecondaryAffirmative}
aria-disabled={pageIndex === 0}
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === 0) {
return;
}
scrollToPageIndex(pageIndex - 1);
}}
>
{i18n('icu:SafetyTipsModal__Button--Previous')}
</Button>
{pageIndex < maxPageIndex ? (
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Next"
variant={ButtonVariant.Primary}
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === maxPageIndex) {
return;
}
scrollToPageIndex(pageIndex + 1);
}}
>
{i18n('icu:SafetyTipsModal__Button--Next')}
</Button>
) : (
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Next"
variant={ButtonVariant.Primary}
onClick={onClose}
>
{i18n('icu:SafetyTipsModal__Button--Done')}
</Button>
)}
</>
}
>
<p className="SafetyTipsModal__Description" id={`${modalId}-description`}>
{i18n('icu:SafetyTipsModal__Description')}
</p>
<div>
<div
className="SafetyTipsModal__CardWrapper"
id={cardWrapperId}
aria-live={hasPageIndexChanged ? 'assertive' : undefined}
aria-atomic
onScroll={handleScroll}
>
{pages.map((page, index) => {
const isCurrentPage = pageIndex === index;
return (
<div
id={getCardIdForPage(index)}
key={page.key}
className="SafetyTipsModal__Card"
aria-hidden={!isCurrentPage}
>
<img
role="presentation"
alt=""
className="SafetyTipsModal__CardImage"
src={page.imageUrl}
width={664}
height={314}
/>
<h2 className="SafetyTipsModal__CardTitle">{page.title}</h2>
<p className="SafetyTipsModal__CardDescription">
{page.description}
</p>
</div>
);
})}
</div>
<div className="SafetyTipsModal__Dots">
{pages.map((page, index) => {
const isCurrentPage = pageIndex === index;
return (
<button
key={page.key}
className="SafetyTipsModal__DotsButton"
type="button"
aria-controls={cardWrapperId}
aria-current={isCurrentPage ? 'step' : undefined}
onClick={() => {
scrollToPageIndex(index);
}}
>
<div className="SafetyTipsModal__DotsButtonLabel">
{i18n('icu:SafetyTipsModal__DotLabel', {
page: index + 1,
})}
</div>
</button>
);
})}
</div>
</div>
</Modal>
);
}

View file

@ -96,7 +96,11 @@ export type PropsType = {
> &
Pick<
MediaEditorPropsType,
'isFormattingEnabled' | 'onPickEmoji' | 'onTextTooLong' | 'platform'
| 'isFormattingEnabled'
| 'onPickEmoji'
| 'onTextTooLong'
| 'platform'
| 'sortedGroupMembers'
>;
export function StoryCreator({
@ -139,6 +143,7 @@ export function StoryCreator({
setMyStoriesToAllSignalConnections,
signalConnections,
skinTone,
sortedGroupMembers,
theme,
toggleGroupsForStorySend,
toggleSignalConnectionsModal,
@ -272,6 +277,9 @@ export function StoryCreator({
platform={platform}
recentStickers={recentStickers}
skinTone={skinTone}
sortedGroupMembers={sortedGroupMembers}
draftText={null}
draftBodyRanges={null}
/>
)}
{!file && (

View file

@ -258,8 +258,15 @@ export function StoryViewsNRepliesModal({
}
platform={platform}
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
skinTone={skinTone ?? null}
sortedGroupMembers={sortedGroupMembers ?? null}
theme={ThemeType.dark}
conversationId={null}
draftBodyRanges={null}
draftEditMessage={null}
large={null}
shouldHidePopovers={null}
linkPreviewResult={null}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"

View file

@ -121,6 +121,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.PinnedConversationsFull };
case ToastType.ReactionFailed:
return { toastType: ToastType.ReactionFailed };
case ToastType.ReportedSpam:
return { toastType: ToastType.ReportedSpam };
case ToastType.ReportedSpamAndBlocked:
return { toastType: ToastType.ReportedSpamAndBlocked };
case ToastType.StickerPackInstallFailed:

View file

@ -371,6 +371,14 @@ export function renderToast({
return <Toast onClose={hideToast}>{i18n('icu:Reactions--error')}</Toast>;
}
if (toastType === ToastType.ReportedSpam) {
return (
<Toast onClose={hideToast}>
{i18n('icu:MessageRequests--report-spam-success-toast')}
</Toast>
);
}
if (toastType === ToastType.ReportedSpamAndBlocked) {
return (
<Toast onClose={hideToast}>

View file

@ -1,21 +1,47 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { Emojify } from './Emojify';
import type { ContactNameColorType } from '../../types/Colors';
import { getClassNamesFor } from '../../util/getClassNamesFor';
import type { ConversationType } from '../../state/ducks/conversations';
import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation';
export type PropsType = {
export type ContactNameData = {
contactNameColor?: ContactNameColorType;
firstName?: string;
isSignalConversation?: boolean;
isMe?: boolean;
title: string;
};
export function useContactNameData(
conversation: ConversationType | null,
contactNameColor?: ContactNameColorType
): ContactNameData | null {
const { firstName, title, isMe } = conversation ?? {};
const isSignalConversation =
conversation != null ? getIsSignalConversation(conversation) : null;
return useMemo(() => {
if (title == null || isSignalConversation == null) {
return null;
}
return {
contactNameColor,
firstName,
isSignalConversation,
isMe,
title,
};
}, [contactNameColor, firstName, isSignalConversation, isMe, title]);
}
export type PropsType = ContactNameData & {
module?: string;
preferFirstName?: boolean;
title: string;
onClick?: VoidFunction;
};

View file

@ -21,6 +21,7 @@ export default {
const getCommonProps = () => ({
acceptConversation: action('acceptConversation'),
reportSpam: action('reportSpam'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
conversationId: 'some-conversation-id',

View file

@ -50,6 +50,7 @@ export type ReviewPropsType = Readonly<
export type PropsType = {
conversationId: string;
acceptConversation: (conversationId: string) => unknown;
reportSpam: (conversationId: string) => unknown;
blockAndReportSpam: (conversationId: string) => unknown;
blockConversation: (conversationId: string) => unknown;
deleteConversation: (conversationId: string) => unknown;
@ -75,6 +76,7 @@ enum ConfirmationStateType {
export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
const {
acceptConversation,
reportSpam,
blockAndReportSpam,
blockConversation,
conversationId,
@ -111,19 +113,23 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
case ConfirmationStateType.ConfirmingBlock:
return (
<MessageRequestActionsConfirmation
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
addedByName={affectedConversation}
conversationId={affectedConversation.id}
conversationType="direct"
deleteConversation={deleteConversation}
conversationType={affectedConversation.type}
conversationName={affectedConversation}
i18n={i18n}
title={affectedConversation.title}
isBlocked={affectedConversation.isBlocked ?? false}
isReported={affectedConversation.isReported ?? false}
state={
type === ConfirmationStateType.ConfirmingDelete
? MessageRequestState.deleting
: MessageRequestState.blocking
}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
onChangeState={messageRequestState => {
switch (messageRequestState) {
case MessageRequestState.blocking:
@ -138,10 +144,12 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
affectedConversation,
});
break;
case MessageRequestState.reportingAndMaybeBlocking:
case MessageRequestState.acceptedOptions:
case MessageRequestState.unblocking:
assertDev(
false,
'Got unexpected MessageRequestState.unblocking state. Clearing confiration state'
`Got unexpected MessageRequestState.${MessageRequestState[messageRequestState]} state. Clearing confiration state`
);
setConfirmationState(undefined);
break;

View file

@ -29,8 +29,15 @@ type ItemsType = Array<{
props: Omit<ComponentProps<typeof ConversationHeader>, 'theme'>;
}>;
const commonConversation = getDefaultConversation();
const commonProps = {
...getDefaultConversation(),
...commonConversation,
conversationId: commonConversation.id,
conversationType: commonConversation.type,
conversationName: commonConversation,
addedByName: null,
isBlocked: commonConversation.isBlocked ?? false,
isReported: commonConversation.isReported ?? false,
cannotLeaveBecauseYouAreLastAdmin: false,
showBackButton: false,
@ -59,6 +66,12 @@ const commonProps = {
setMuteExpiration: action('onSetMuteNotifications'),
setPinned: action('setPinned'),
viewUserStories: action('viewUserStories'),
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
reportSpam: action('reportSpam'),
deleteConversation: action('deleteConversation'),
};
export function PrivateConvo(): JSX.Element {

View file

@ -41,6 +41,12 @@ import { PanelType } from '../../types/Panels';
import { UserText } from '../UserText';
import { Alert } from '../Alert';
import { SizeObserver } from '../../hooks/useSizeObserver';
import type { MessageRequestActionsConfirmationBaseProps } from './MessageRequestActionsConfirmation';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import type { ContactNameData } from './ContactName';
export enum OutgoingCallButtonStyle {
None,
@ -60,6 +66,8 @@ export type PropsDataType = {
isSelectMode: boolean;
isSignalConversation?: boolean;
theme: ThemeType;
addedByName: ContactNameData | null;
conversationName: ContactNameData;
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
@ -72,6 +80,8 @@ export type PropsDataType = {
| 'groupVersion'
| 'id'
| 'isArchived'
| 'isBlocked'
| 'isReported'
| 'isMe'
| 'isPinned'
| 'isVerified'
@ -81,6 +91,7 @@ export type PropsDataType = {
| 'name'
| 'phoneNumber'
| 'profileName'
| 'removalStage'
| 'sharedGroupNames'
| 'title'
| 'type'
@ -106,7 +117,7 @@ export type PropsActionsType = {
setMuteExpiration: (conversationId: string, seconds: number) => void;
setPinned: (conversationId: string, value: boolean) => void;
viewUserStories: ViewUserStoriesActionCreatorType;
};
} & MessageRequestActionsConfirmationBaseProps;
export type PropsHousekeepingType = {
i18n: LocalizerType;
@ -127,6 +138,7 @@ type StateType = {
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: boolean;
isNarrow: boolean;
modalState: ModalState;
messageRequestState: MessageRequestState;
};
const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item';
@ -149,6 +161,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: false,
isNarrow: false,
modalState: ModalState.NothingOpen,
messageRequestState: MessageRequestState.default,
};
this.menuTriggerRef = React.createRef();
@ -156,6 +169,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
this.showMenuBound = this.showMenu.bind(this);
}
private handleMessageRequestStateChange = (
state: MessageRequestState
): void => {
this.setState({ messageRequestState: state });
};
private showMenu(event: React.MouseEvent<HTMLButtonElement>): void {
if (this.menuTriggerRef.current) {
this.menuTriggerRef.current.handleContextClick(event);
@ -328,6 +347,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderMenu(triggerId: string): ReactNode {
const {
acceptConversation,
acceptedMessageRequest,
canChangeTimer,
cannotLeaveBecauseYouAreLastAdmin,
@ -336,6 +356,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
i18n,
id,
isArchived,
isBlocked,
isMissingMandatoryProfileSharing,
isPinned,
isSignalConversation,
@ -431,6 +452,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem
onClick={() =>
this.setState({ hasDeleteMessagesConfirmation: true })
@ -491,98 +513,164 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
</MenuItem>
);
});
return createPortal(
<ContextMenu id={triggerId} rtl={isRTL}>
{disableTimerChanges ? null : (
<SubMenu hoverDelay={1} title={disappearingTitle} rtl={!isRTL}>
{expireDurations}
</SubMenu>
)}
<SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}>
{muteOptions.map(item => (
{!acceptedMessageRequest && (
<>
{!isBlocked && (
<MenuItem
onClick={() => {
this.setState({
messageRequestState: MessageRequestState.blocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
)}
{isBlocked && (
<MenuItem
onClick={() => {
this.setState({
messageRequestState: MessageRequestState.unblocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Unblock')}
</MenuItem>
)}
{!isBlocked && (
<MenuItem onClick={acceptConversation}>
{i18n('icu:ConversationHeader__MenuItem--Accept')}
</MenuItem>
)}
<MenuItem
key={item.name}
disabled={item.disabled}
onClick={() => {
setMuteExpiration(id, item.value);
this.setState({
messageRequestState:
MessageRequestState.reportingAndMaybeBlocking,
});
}}
>
{item.name}
{i18n('icu:ConversationHeader__MenuItem--ReportSpam')}
</MenuItem>
))}
</SubMenu>
{!isGroup || hasGV2AdminEnabled ? (
<MenuItem
onClick={() =>
pushPanelForConversation({
type: PanelType.ConversationDetails,
})
}
>
{isGroup
? i18n('icu:showConversationDetails')
: i18n('icu:showConversationDetails--direct')}
</MenuItem>
) : null}
<MenuItem
onClick={() => pushPanelForConversation({ type: PanelType.AllMedia })}
>
{i18n('icu:viewRecentMedia')}
</MenuItem>
<MenuItem divider />
<MenuItem
onClick={() => {
toggleSelectMode(true);
}}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
<MenuItem divider />
{!markedUnread ? (
<MenuItem onClick={() => onMarkUnread(id)}>
{i18n('icu:markUnread')}
</MenuItem>
) : null}
{isPinned ? (
<MenuItem onClick={() => setPinned(id, false)}>
{i18n('icu:unpinConversation')}
</MenuItem>
) : (
<MenuItem onClick={() => setPinned(id, true)}>
{i18n('icu:pinConversation')}
</MenuItem>
)}
{isArchived ? (
<MenuItem onClick={() => onMoveToInbox(id)}>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={() => onArchive(id)}>
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem
onClick={() => this.setState({ hasDeleteMessagesConfirmation: true })}
>
{i18n('icu:deleteMessagesInConversation')}
</MenuItem>
{isGroup && (
<MenuItem
onClick={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
<MenuItem
onClick={() => {
this.setState({
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true,
messageRequestState: MessageRequestState.deleting,
});
} else {
this.setState({ hasLeaveGroupConfirmation: true });
}
}}
>
{i18n(
'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title'
}}
>
{i18n('icu:ConversationHeader__MenuItem--DeleteChat')}
</MenuItem>
</>
)}
{acceptedMessageRequest && (
<>
{disableTimerChanges ? null : (
<SubMenu hoverDelay={1} title={disappearingTitle} rtl={!isRTL}>
{expireDurations}
</SubMenu>
)}
</MenuItem>
<SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}>
{muteOptions.map(item => (
<MenuItem
key={item.name}
disabled={item.disabled}
onClick={() => {
setMuteExpiration(id, item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
{!isGroup || hasGV2AdminEnabled ? (
<MenuItem
onClick={() =>
pushPanelForConversation({
type: PanelType.ConversationDetails,
})
}
>
{isGroup
? i18n('icu:showConversationDetails')
: i18n('icu:showConversationDetails--direct')}
</MenuItem>
) : null}
<MenuItem
onClick={() =>
pushPanelForConversation({ type: PanelType.AllMedia })
}
>
{i18n('icu:viewRecentMedia')}
</MenuItem>
<MenuItem divider />
<MenuItem
onClick={() => {
toggleSelectMode(true);
}}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
<MenuItem divider />
{!markedUnread ? (
<MenuItem onClick={() => onMarkUnread(id)}>
{i18n('icu:markUnread')}
</MenuItem>
) : null}
{isPinned ? (
<MenuItem onClick={() => setPinned(id, false)}>
{i18n('icu:unpinConversation')}
</MenuItem>
) : (
<MenuItem onClick={() => setPinned(id, true)}>
{i18n('icu:pinConversation')}
</MenuItem>
)}
{isArchived ? (
<MenuItem onClick={() => onMoveToInbox(id)}>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={() => onArchive(id)}>
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem
onClick={() => {
this.setState({
messageRequestState: MessageRequestState.blocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
<MenuItem
onClick={() =>
this.setState({ hasDeleteMessagesConfirmation: true })
}
>
{i18n('icu:deleteMessagesInConversation')}
</MenuItem>
{isGroup && (
<MenuItem
onClick={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
this.setState({
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true,
});
} else {
this.setState({ hasLeaveGroupConfirmation: true });
}
}}
>
{i18n(
'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title'
)}
</MenuItem>
)}
</>
)}
</ContextMenu>,
document.body
@ -751,25 +839,35 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
public override render(): ReactNode {
const {
addedByName,
announcementsOnly,
areWeAdmin,
conversationName,
expireTimer,
hasPanelShowing,
i18n,
id,
isBlocked,
isReported,
isSMSOnly,
isSignalConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
outgoingCallButtonStyle,
setDisappearingMessages,
type,
acceptConversation,
blockAndReportSpam,
blockConversation,
reportSpam,
deleteConversation,
} = this.props;
if (hasPanelShowing) {
return null;
}
const { isNarrow, modalState } = this.state;
const { isNarrow, modalState, messageRequestState } = this.state;
const triggerId = `conversation-${id}`;
let modalNode: ReactNode;
@ -829,6 +927,22 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderSearchButton()}
{this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)}
<MessageRequestActionsConfirmation
i18n={i18n}
conversationId={id}
conversationType={type}
addedByName={addedByName}
conversationName={conversationName}
isBlocked={isBlocked ?? false}
isReported={isReported ?? false}
state={messageRequestState}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
onChangeState={this.handleMessageRequestStateChange}
/>
</div>
)}
</SizeObserver>

View file

@ -15,6 +15,8 @@ import { StoryViewModeType } from '../../types/Stories';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { Button, ButtonVariant } from '../Button';
import { SafetyTipsModal } from '../SafetyTipsModal';
export type Props = {
about?: string;
@ -42,6 +44,7 @@ const renderMembershipRow = ({
i18n,
isMe,
onClickMessageRequestWarning,
onToggleSafetyTips,
phoneNumber,
sharedGroupNames,
}: Pick<
@ -54,6 +57,7 @@ const renderMembershipRow = ({
> &
Required<Pick<Props, 'sharedGroupNames'>> & {
onClickMessageRequestWarning: () => void;
onToggleSafetyTips: (showSafetyTips: boolean) => void;
}) => {
if (conversationType !== 'direct') {
return null;
@ -67,6 +71,20 @@ const renderMembershipRow = ({
);
}
const safetyTipsButton = (
<div>
<Button
className="module-conversation-hero__safety-tips-button"
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
onToggleSafetyTips(true);
}}
>
{i18n('icu:MessageRequestWarning__safety-tips')}
</Button>
</div>
);
if (sharedGroupNames.length > 0) {
return (
<div className="module-conversation-hero__membership">
@ -76,6 +94,7 @@ const renderMembershipRow = ({
nameClassName="module-conversation-hero__membership__name"
sharedGroupNames={sharedGroupNames}
/>
{safetyTipsButton}
</div>
);
}
@ -86,6 +105,7 @@ const renderMembershipRow = ({
return (
<div className="module-conversation-hero__membership">
{i18n('icu:no-groups-in-common')}
{safetyTipsButton}
</div>
);
}
@ -107,6 +127,7 @@ const renderMembershipRow = ({
{i18n('icu:MessageRequestWarning__learn-more')}
</button>
</div>
{safetyTipsButton}
</div>
);
};
@ -136,6 +157,7 @@ export function ConversationHero({
viewUserStories,
toggleAboutContactModal,
}: Props): JSX.Element {
const [isShowingSafetyTips, setIsShowingSafetyTips] = useState(false);
const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] =
useState(false);
const closeMessageRequestWarning = () => {
@ -248,6 +270,9 @@ export function ConversationHero({
onClickMessageRequestWarning() {
setIsShowingMessageRequestWarning(true);
},
onToggleSafetyTips(showSafetyTips: boolean) {
setIsShowingSafetyTips(showSafetyTips);
},
phoneNumber,
sharedGroupNames,
})}
@ -277,6 +302,15 @@ export function ConversationHero({
{i18n('icu:MessageRequestWarning__dialog__details')}
</ConfirmationDialog>
)}
{isShowingSafetyTips && (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsShowingSafetyTips(false);
}}
/>
)}
</>
);
/* eslint-enable no-nested-ternary */

View file

@ -8,9 +8,17 @@ import type { Props } from './MandatoryProfileSharingActions';
import { MandatoryProfileSharingActions } from './MandatoryProfileSharingActions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
type Args = {
conversationType: 'direct' | 'group';
};
export default {
title: 'Components/Conversation/MandatoryProfileSharingActions',
argTypes: {
@ -20,34 +28,43 @@ export default {
options: ['direct', 'group'],
},
},
firstName: { control: { type: 'text' } },
title: { control: { type: 'text' } },
},
args: {
conversationId: '123',
i18n,
conversationType: 'direct',
firstName: 'Cayce',
title: 'Cayce Bollard',
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
},
} satisfies Meta<Props>;
} satisfies Meta<Args>;
export function Direct(args: Props): JSX.Element {
function Example(args: Args) {
const conversation =
args.conversationType === 'group'
? getDefaultGroup()
: getDefaultConversation();
const addedBy =
args.conversationType === 'group' ? getDefaultConversation() : conversation;
return (
<div style={{ width: '480px' }}>
<MandatoryProfileSharingActions {...args} />
<MandatoryProfileSharingActions
addedByName={addedBy}
conversationType={conversation.type}
conversationId={conversation.id}
conversationName={conversation}
i18n={i18n}
isBlocked={conversation.isBlocked ?? false}
isReported={conversation.isReported ?? false}
acceptConversation={action('acceptConversation')}
blockAndReportSpam={action('blockAndReportSpam')}
blockConversation={action('blockConversation')}
deleteConversation={action('deleteConversation')}
reportSpam={action('reportSpam')}
/>
</div>
);
}
export function Direct(args: Props): JSX.Element {
return <Example {...args} conversationType="direct" />;
}
export function Group(args: Props): JSX.Element {
return (
<div style={{ width: '480px' }}>
<MandatoryProfileSharingActions {...args} conversationType="group" />
</div>
);
return <Example {...args} conversationType="group" />;
}

View file

@ -2,10 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import { ContactName } from './ContactName';
import { Button, ButtonVariant } from '../Button';
import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
@ -15,17 +14,20 @@ import type { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
firstName?: string;
} & Omit<ContactNameProps, 'module'> &
Pick<
MessageRequestActionsConfirmationProps,
| 'acceptConversation'
| 'blockAndReportSpam'
| 'blockConversation'
| 'conversationId'
| 'conversationType'
| 'deleteConversation'
>;
} & Pick<
MessageRequestActionsConfirmationProps,
| 'addedByName'
| 'conversationId'
| 'conversationType'
| 'conversationName'
| 'isBlocked'
| 'isReported'
| 'acceptConversation'
| 'reportSpam'
| 'blockAndReportSpam'
| 'blockConversation'
| 'deleteConversation'
>;
const learnMoreLink = (parts: Array<JSX.Element | string>) => (
<a
@ -39,15 +41,18 @@ const learnMoreLink = (parts: Array<JSX.Element | string>) => (
);
export function MandatoryProfileSharingActions({
acceptConversation,
blockAndReportSpam,
blockConversation,
addedByName,
conversationId,
conversationType,
deleteConversation,
firstName,
conversationName,
i18n,
title,
isBlocked,
isReported,
acceptConversation,
reportSpam,
blockAndReportSpam,
blockConversation,
deleteConversation,
}: Props): JSX.Element {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
@ -56,7 +61,7 @@ export function MandatoryProfileSharingActions({
key="name"
className="module-message-request-actions__message__name"
>
<ContactName firstName={firstName} title={title} preferFirstName />
<ContactName {...conversationName} preferFirstName />
</strong>
);
@ -64,19 +69,23 @@ export function MandatoryProfileSharingActions({
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
state={mrState}
acceptConversation={() => {
throw new Error(
'Should not be able to unblock from MandatoryProfileSharingActions'
);
}}
blockConversation={blockConversation}
conversationId={conversationId}
deleteConversation={deleteConversation}
i18n={i18n}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
title={title}
conversationType={conversationType}
state={mrState}
onChangeState={setMrState}
/>
) : null}

View file

@ -4,13 +4,23 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { Props } from './MessageRequestActions';
import { MessageRequestActions } from './MessageRequestActions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
type Args = {
conversationType: 'direct' | 'group';
isBlocked: boolean;
isHidden: boolean;
isReported: boolean;
};
export default {
title: 'Components/Conversation/MessageRequestActions',
argTypes: {
@ -20,19 +30,9 @@ export default {
options: ['direct', 'group'],
},
},
firstName: { control: { type: 'text' } },
title: { control: { type: 'text' } },
},
args: {
conversationId: '123',
i18n,
conversationType: 'direct',
firstName: 'Cayce',
title: 'Cayce Bollard',
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
},
decorators: [
(Story: React.ComponentType): JSX.Element => {
@ -43,20 +43,62 @@ export default {
);
},
],
} satisfies Meta<Props>;
} satisfies Meta<Args>;
export function Direct(args: Props): JSX.Element {
return <MessageRequestActions {...args} />;
function Example(args: Args): JSX.Element {
const conversation =
args.conversationType === 'group'
? getDefaultGroup()
: getDefaultConversation();
const addedBy =
args.conversationType === 'group' ? getDefaultConversation() : conversation;
return (
<MessageRequestActions
addedByName={addedBy}
conversationType={conversation.type}
conversationId={conversation.id}
conversationName={conversation}
i18n={i18n}
isBlocked={args.isBlocked}
isHidden={args.isHidden}
isReported={args.isReported}
acceptConversation={action('acceptConversation')}
blockAndReportSpam={action('blockAndReportSpam')}
blockConversation={action('blockConversation')}
deleteConversation={action('deleteConversation')}
reportSpam={action('reportSpam')}
/>
);
}
export function DirectBlocked(args: Props): JSX.Element {
return <MessageRequestActions {...args} isBlocked />;
export function Direct(args: Args): JSX.Element {
return <Example {...args} />;
}
export function Group(args: Props): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" />;
export function DirectBlocked(args: Args): JSX.Element {
return <Example {...args} isBlocked />;
}
export function GroupBlocked(args: Props): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" isBlocked />;
export function DirectReported(args: Args): JSX.Element {
return <Example {...args} isReported />;
}
export function DirectBlockedAndReported(args: Args): JSX.Element {
return <Example {...args} isBlocked isReported />;
}
export function Group(args: Args): JSX.Element {
return <Example {...args} conversationType="group" />;
}
export function GroupBlocked(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isBlocked />;
}
export function GroupReported(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isReported />;
}
export function GroupBlockedAndReported(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isBlocked isReported />;
}

View file

@ -2,52 +2,57 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import { ContactName } from './ContactName';
import { Button, ButtonVariant } from '../Button';
import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import { Intl } from '../Intl';
import type { LocalizerType } from '../../types/Util';
import { strictAssert } from '../../util/assert';
export type Props = {
i18n: LocalizerType;
isHidden?: boolean;
} & Omit<ContactNameProps, 'module'> &
Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
isHidden: boolean | null;
} & Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
export function MessageRequestActions({
addedByName,
conversationId,
conversationType,
conversationName,
i18n,
isBlocked,
isHidden,
isReported,
acceptConversation,
blockAndReportSpam,
blockConversation,
conversationId,
conversationType,
reportSpam,
deleteConversation,
firstName,
i18n,
isHidden,
isBlocked,
title,
}: Props): JSX.Element {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
const name = (
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName firstName={firstName} title={title} preferFirstName />
</strong>
);
const nameValue =
conversationType === 'direct' ? conversationName : addedByName;
let message: JSX.Element | undefined;
if (conversationType === 'direct') {
strictAssert(nameValue != null, 'nameValue is null');
const name = (
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName {...nameValue} preferFirstName />
</strong>
);
if (isBlocked) {
message = (
<Intl
@ -87,39 +92,26 @@ export function MessageRequestActions({
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
state={mrState}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId}
conversationType={conversationType}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
i18n={i18n}
onChangeState={setMrState}
state={mrState}
title={title}
/>
) : null}
<div className="module-message-request-actions">
<p className="module-message-request-actions__message">{message}</p>
<div className="module-message-request-actions__buttons">
<Button
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--delete')}
</Button>
{isBlocked ? (
<Button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
) : (
{!isBlocked && (
<Button
onClick={() => {
setMrState(MessageRequestState.blocking);
@ -129,6 +121,36 @@ export function MessageRequestActions({
{i18n('icu:MessageRequests--block')}
</Button>
)}
{(isReported || isBlocked) && (
<Button
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--delete')}
</Button>
)}
{!isReported && (
<Button
onClick={() => {
setMrState(MessageRequestState.reportingAndMaybeBlocking);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--reportAndMaybeBlock')}
</Button>
)}
{isBlocked && (
<Button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
)}
{!isBlocked ? (
<Button
onClick={() => acceptConversation(conversationId)}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import type { ContactNameData } from './ContactName';
import { ContactName } from './ContactName';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Intl } from '../Intl';
@ -12,38 +12,53 @@ export enum MessageRequestState {
blocking,
deleting,
unblocking,
reportingAndMaybeBlocking,
acceptedOptions,
default,
}
export type Props = {
acceptConversation(conversationId: string): unknown;
blockAndReportSpam(conversationId: string): unknown;
blockConversation(conversationId: string): unknown;
export type MessageRequestActionsConfirmationBaseProps = {
addedByName: ContactNameData | null;
conversationId: string;
conversationType: 'group' | 'direct';
deleteConversation(conversationId: string): unknown;
i18n: LocalizerType;
isBlocked?: boolean;
onChangeState(state: MessageRequestState): unknown;
state: MessageRequestState;
} & Omit<ContactNameProps, 'module'>;
conversationName: ContactNameData;
isBlocked: boolean;
isReported: boolean;
acceptConversation(conversationId: string): void;
blockAndReportSpam(conversationId: string): void;
blockConversation(conversationId: string): void;
reportSpam(conversationId: string): void;
deleteConversation(conversationId: string): void;
};
export type MessageRequestActionsConfirmationProps =
MessageRequestActionsConfirmationBaseProps & {
i18n: LocalizerType;
state: MessageRequestState;
onChangeState(state: MessageRequestState): void;
};
export function MessageRequestActionsConfirmation({
addedByName,
conversationId,
conversationType,
conversationName,
i18n,
isBlocked,
state,
acceptConversation,
blockAndReportSpam,
blockConversation,
conversationId,
conversationType,
reportSpam,
deleteConversation,
i18n,
onChangeState,
state,
title,
}: Props): JSX.Element | null {
}: MessageRequestActionsConfirmationProps): JSX.Element | null {
if (state === MessageRequestState.blocking) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.blocking"
dialogName="messageRequestActionsConfirmation.blocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
@ -54,7 +69,13 @@ export function MessageRequestActionsConfirmation({
i18n={i18n}
id="icu:MessageRequests--block-direct-confirm-title"
components={{
title: <ContactName key="name" title={title} />,
title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}}
/>
) : (
@ -62,21 +83,18 @@ export function MessageRequestActionsConfirmation({
i18n={i18n}
id="icu:MessageRequests--block-group-confirm-title"
components={{
title: <ContactName key="name" title={title} />,
title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}}
/>
)
}
actions={[
...(conversationType === 'direct'
? [
{
text: i18n('icu:MessageRequests--block-and-report-spam'),
action: () => blockAndReportSpam(conversationId),
style: 'negative' as const,
},
]
: []),
{
text: i18n('icu:MessageRequests--block'),
action: () => blockConversation(conversationId),
@ -91,10 +109,62 @@ export function MessageRequestActionsConfirmation({
);
}
if (state === MessageRequestState.reportingAndMaybeBlocking) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.reportingAndMaybeBlocking"
dialogName="messageRequestActionsConfirmation.reportingAndMaybeBlocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={i18n('icu:MessageRequests--ReportAndMaybeBlockModal-title')}
actions={[
...(!isBlocked
? ([
{
text: i18n(
'icu:MessageRequests--ReportAndMaybeBlockModal-reportAndBlock'
),
action: () => blockAndReportSpam(conversationId),
style: 'negative',
},
] as const)
: []),
{
text: i18n('icu:MessageRequests--ReportAndMaybeBlockModal-report'),
action: () => reportSpam(conversationId),
style: 'negative',
},
]}
>
{/* eslint-disable-next-line no-nested-ternary */}
{conversationType === 'direct' ? (
i18n('icu:MessageRequests--ReportAndMaybeBlockModal-body--direct')
) : addedByName == null ? (
i18n(
'icu:MessageRequests--ReportAndMaybeBlockModal-body--group--unknown-contact'
)
) : (
<Intl
i18n={i18n}
id="icu:MessageRequests--ReportAndMaybeBlockModal-body--group"
components={{
name: <ContactName key="name" {...addedByName} preferFirstName />,
}}
/>
)}
</ConfirmationDialog>
);
}
if (state === MessageRequestState.unblocking) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.unblocking"
dialogName="messageRequestActionsConfirmation.unblocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
@ -104,7 +174,9 @@ export function MessageRequestActionsConfirmation({
i18n={i18n}
id="icu:MessageRequests--unblock-direct-confirm-title"
components={{
name: <ContactName key="name" title={title} />,
name: (
<ContactName key="name" {...conversationName} preferFirstName />
),
}}
/>
}
@ -126,7 +198,9 @@ export function MessageRequestActionsConfirmation({
if (state === MessageRequestState.deleting) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.deleting"
dialogName="messageRequestActionsConfirmation.deleting"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
@ -142,7 +216,13 @@ export function MessageRequestActionsConfirmation({
i18n={i18n}
id="icu:MessageRequests--delete-group-confirm-title"
components={{
title: <ContactName key="name" title={title} />,
title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}}
/>
)
@ -165,5 +245,42 @@ export function MessageRequestActionsConfirmation({
);
}
if (state === MessageRequestState.acceptedOptions) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.acceptedOptions"
dialogName="messageRequestActionsConfirmation.acceptedOptions"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
actions={[
{
text: i18n('icu:MessageRequests--reportAndMaybeBlock'),
action: () =>
onChangeState(MessageRequestState.reportingAndMaybeBlocking),
style: 'negative',
},
{
text: i18n('icu:MessageRequests--block'),
action: () => onChangeState(MessageRequestState.blocking),
style: 'negative',
},
]}
>
<Intl
i18n={i18n}
id="icu:MessageRequests--AcceptedOptionsModal--body"
components={{
name: (
<ContactName key="name" {...conversationName} preferFirstName />
),
}}
/>
</ConfirmationDialog>
);
}
return null;
}

View file

@ -0,0 +1,98 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { LocalizerType } from '../../types/I18N';
import { SystemMessage } from './SystemMessage';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { MessageRequestState } from './MessageRequestActionsConfirmation';
import { SafetyTipsModal } from '../SafetyTipsModal';
import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent';
export type MessageRequestResponseNotificationData = {
messageRequestResponseEvent: MessageRequestResponseEvent;
};
export type MessageRequestResponseNotificationProps =
MessageRequestResponseNotificationData & {
i18n: LocalizerType;
isBlocked: boolean;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
};
export function MessageRequestResponseNotification({
i18n,
isBlocked,
messageRequestResponseEvent: event,
onOpenMessageRequestActionsConfirmation,
}: MessageRequestResponseNotificationProps): JSX.Element | null {
const [isSafetyTipsModalOpen, setIsSafetyTipsModalOpen] = useState(false);
return (
<>
{event === MessageRequestResponseEvent.ACCEPT && (
<SystemMessage
icon="thread"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Accepted'
)}
button={
isBlocked ? null : (
<Button
className="MessageRequestResponseNotification__Button"
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
onClick={() => {
onOpenMessageRequestActionsConfirmation(
MessageRequestState.acceptedOptions
);
}}
>
{i18n(
'icu:MessageRequestResponseNotification__Button--Options'
)}
</Button>
)
}
/>
)}
{event === MessageRequestResponseEvent.BLOCK && (
<SystemMessage
icon="block"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Blocked'
)}
/>
)}
{event === MessageRequestResponseEvent.SPAM && (
<SystemMessage
icon="spam"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Reported'
)}
button={
<Button
className="MessageRequestResponseNotification__Button"
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
onClick={() => {
setIsSafetyTipsModalOpen(true);
}}
>
{i18n(
'icu:MessageRequestResponseNotification__Button--LearnMore'
)}
</Button>
}
/>
)}
{isSafetyTipsModalOpen && (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsSafetyTipsModalOpen(false);
}}
/>
)}
</>
);
}

View file

@ -16,6 +16,7 @@ export type PropsType = {
| 'audio-incoming'
| 'audio-missed'
| 'audio-outgoing'
| 'block'
| 'group'
| 'group-access'
| 'group-add'
@ -30,6 +31,7 @@ export type PropsType = {
| 'phone'
| 'profile'
| 'safety-number'
| 'spam'
| 'session-refresh'
| 'thread'
| 'timer'

View file

@ -335,6 +335,10 @@ const actions = () => ({
viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'),
onOpenMessageRequestActionsConfirmation: action(
'onOpenMessageRequestActionsConfirmation'
),
});
const renderItem = ({
@ -350,6 +354,7 @@ const renderItem = ({
getPreferredBadge={() => undefined}
id=""
isTargeted={false}
isBlocked={false}
i18n={i18n}
interactionMode="keyboard"
isNextItemCallingNotification={false}
@ -442,6 +447,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
getTimestampForMessage: Date.now,
haveNewest: overrideProps.haveNewest ?? false,
haveOldest: overrideProps.haveOldest ?? false,
isBlocked: false,
isConversationSelected: true,
isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false,
items: overrideProps.items ?? Object.keys(items),

View file

@ -81,6 +81,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
id: string;
isBlocked: boolean;
isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean;
@ -121,6 +122,7 @@ type PropsHousekeepingType = {
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
conversationId: string;
isBlocked: boolean;
isOldestTimelineItem: boolean;
messageId: string;
nextMessageId: undefined | string;
@ -786,6 +788,7 @@ export class Timeline extends React.Component<
i18n,
id,
invitedContactsForNewlyCreatedGroup,
isBlocked,
isConversationSelected,
isGroupV1AndDisabled,
items,
@ -928,6 +931,7 @@ export class Timeline extends React.Component<
containerElementRef: this.containerRef,
containerWidthBreakpoint: widthBreakpoint,
conversationId: id,
isBlocked,
isOldestTimelineItem: haveOldest && itemIndex === 0,
messageId,
nextMessageId,

View file

@ -59,6 +59,7 @@ const getDefaultProps = () => ({
id: 'asdf',
isNextItemCallingNotification: false,
isTargeted: false,
isBlocked: false,
interactionMode: 'keyboard' as const,
theme: ThemeType.light,
platform: 'darwin',
@ -118,6 +119,9 @@ const getDefaultProps = () => ({
viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'),
onOpenMessageRequestActionsConfirmation: action(
'onOpenMessageRequestActionsConfirmation'
),
});
export default {

View file

@ -56,6 +56,11 @@ import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from
import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification';
import { SystemMessage } from './SystemMessage';
import { TimelineMessage } from './TimelineMessage';
import {
MessageRequestResponseNotification,
type MessageRequestResponseNotificationData,
} from './MessageRequestResponseNotification';
import type { MessageRequestState } from './MessageRequestActionsConfirmation';
type CallHistoryType = {
type: 'callHistory';
@ -137,6 +142,10 @@ type PaymentEventType = {
type: 'paymentEvent';
data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
};
type MessageRequestResponseNotificationType = {
type: 'messageRequestResponse';
data: MessageRequestResponseNotificationData;
};
export type TimelineItemType = (
| CallHistoryType
@ -159,6 +168,7 @@ export type TimelineItemType = (
| UnsupportedMessageType
| VerificationNotificationType
| PaymentEventType
| MessageRequestResponseNotificationType
) & { timestamp: number };
type PropsLocalType = {
@ -166,10 +176,12 @@ type PropsLocalType = {
conversationId: string;
item?: TimelineItemType;
id: string;
isBlocked: boolean;
isNextItemCallingNotification: boolean;
isTargeted: boolean;
targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
platform: string;
renderContact: SmartContactRendererType<JSX.Element>;
renderUniversalTimerNotification: () => JSX.Element;
@ -203,9 +215,11 @@ export const TimelineItem = memo(function TimelineItem({
getPreferredBadge,
i18n,
id,
isBlocked,
isNextItemCallingNotification,
isTargeted,
item,
onOpenMessageRequestActionsConfirmation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
platform,
@ -379,6 +393,17 @@ export const TimelineItem = memo(function TimelineItem({
i18n={i18n}
/>
);
} else if (item.type === 'messageRequestResponse') {
notification = (
<MessageRequestResponseNotification
{...item.data}
i18n={i18n}
isBlocked={isBlocked}
onOpenMessageRequestActionsConfirmation={
onOpenMessageRequestActionsConfirmation
}
/>
);
} else {
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
// with our if/else checks above, but also log out the type we don't understand