Files
Desktop/ts/components/SafetyTipsModal.dom.tsx
T
2026-04-27 16:13:27 -04:00

312 lines
9.8 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { type JSX, useId, useMemo, useState } from 'react';
import { Tabs } from 'radix-ui';
import type { LocalizerType } from '../types/I18N.std.ts';
import { AxoDialog } from '../axo/AxoDialog.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import { useReducedMotion } from '../hooks/useReducedMotion.dom.ts';
import { AxoIconButton } from '../axo/AxoIconButton.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
export type SafetyTipsModalProps = Readonly<{
i18n: LocalizerType;
onClose(): void;
}>;
export function SafetyTipsModal({
i18n,
onClose,
}: SafetyTipsModalProps): JSX.Element {
const [page, setPage] = useState<'summary' | 'details'>('summary');
return (
<AxoDialog.Root open onOpenChange={onClose}>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
{page === 'summary' ? (
<SafetyTipsSummary
i18n={i18n}
onViewMore={() => setPage('details')}
/>
) : (
<SafetyTipsDetails i18n={i18n} />
)}
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function SafetyTipsSummary({
i18n,
onViewMore,
}: {
i18n: LocalizerType;
onViewMore: () => void;
}): React.JSX.Element {
const tips = useMemo(
() =>
[
{
key: 'signal',
iconUrl: 'images/safety-tips/safety-tip-icon-chat-x.svg',
titleId: i18n('icu:SafetyTipsModal__TipTitle--SignalChat'),
descriptionId: i18n(
'icu:SafetyTipsModal__TipDescription--SignalChat'
),
},
{
key: 'names-and-photos',
iconUrl: 'images/safety-tips/safety-tip-icon-person-question.svg',
titleId: i18n('icu:SafetyTipsModal__TipTitle--NamesAndPhotos'),
descriptionId: i18n(
'icu:SafetyTipsModal__TipDescription--NamesAndPhotos'
),
},
{
key: 'scams',
iconUrl: 'images/safety-tips/safety-tip-icon-raised-hand.svg',
titleId: i18n('icu:SafetyTipsModal__TipTitle--Scams'),
descriptionId: i18n('icu:SafetyTipsModal__TipDescription--Scams'),
},
] as const,
[i18n]
);
return (
<>
<AxoDialog.Header>
<div className={tw('sr-only')}>
<AxoDialog.Description>
{i18n('icu:SafetyTipsModal__Title-v2')}
</AxoDialog.Description>
</div>
<AxoDialog.Title>
{i18n('icu:SafetyTipsModal__Title-v2')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:close')} />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('py-4')}>
<ul className={tw('mb-7 space-y-9')}>
{tips.map(tip => (
<li key={tip.key} className={tw('flex items-start gap-4')}>
<img
role="presentation"
alt=""
className={tw('mt-1 size-11 shrink-0')}
src={tip.iconUrl}
/>
<div>
<h3 className={tw('type-title-small text-label-primary')}>
{tip.titleId}
</h3>
<p
className={tw('mt-1 type-body-medium text-label-secondary')}
>
{tip.descriptionId}
</p>
</div>
</li>
))}
</ul>
<AxoButton.Root
size="lg"
variant="secondary"
width="full"
onClick={onViewMore}
>
{i18n('icu:SafetyTipsModal__Button--ViewMore')}
</AxoButton.Root>
</div>
</AxoDialog.Body>
</>
);
}
function SafetyTipsDetails({
i18n,
}: {
i18n: LocalizerType;
}): React.JSX.Element {
const cardWrapperId = useId();
const tips = useMemo(
() =>
[
{
key: 'dontRespond',
title: i18n('icu:SafetyTipsModal__TipTitle--DontRespond'),
description: i18n('icu:SafetyTipsModal__TipDescription--DontRespond'),
imageUrl: 'images/safety-tips/safety-tip-dont-respond.svg',
},
{
key: 'reviewNames',
title: i18n('icu:SafetyTipsModal__TipTitle--ReviewNames'),
description: i18n('icu:SafetyTipsModal__TipDescription--ReviewNames'),
imageUrl: 'images/safety-tips/safety-tip-review-names.svg',
},
{
key: 'vague',
title: i18n('icu:SafetyTipsModal__TipTitle--Vague'),
description: i18n('icu:SafetyTipsModal__TipDescription--Vague'),
imageUrl: 'images/safety-tips/safety-tip-vague.svg',
},
{
key: 'links',
title: i18n('icu:SafetyTipsModal__TipTitle--Links-v2'),
description: i18n('icu:SafetyTipsModal__TipDescription--Links'),
imageUrl: 'images/safety-tips/safety-tip-links.svg',
},
{
key: 'crypto',
title: i18n('icu:SafetyTipsModal__TipTitle--Crypto'),
description: i18n('icu:SafetyTipsModal__TipDescription--Crypto'),
imageUrl: 'images/safety-tips/safety-tip-crypto.svg',
},
{
key: 'business',
title: i18n('icu:SafetyTipsModal__TipTitle--Business'),
description: i18n('icu:SafetyTipsModal__TipDescription--Business'),
imageUrl: 'images/safety-tips/safety-tip-business.svg',
},
] as const,
[i18n]
);
function getCardIdForPage(pageIndex: number) {
// oxlint-disable-next-line typescript/no-non-null-assertion
return `${cardWrapperId}_${tips[pageIndex]!.key}`;
}
const maxPageIndex = tips.length - 1;
const [pageIndex, setPageIndex] = useState(0);
const reducedMotion = useReducedMotion();
function scrollToPageIndex(nextPageIndex: number) {
setPageIndex(nextPageIndex);
document.getElementById(getCardIdForPage(nextPageIndex))?.scrollIntoView({
inline: 'center',
behavior: reducedMotion ? 'instant' : 'smooth',
});
}
function scrollToPageKey(key: string) {
const index = tips.findIndex(tip => tip.key === key);
if (index >= 0) {
scrollToPageIndex(index);
}
}
return (
<>
<AxoDialog.Header>
<div className={tw('sr-only')}>
<AxoDialog.Title>
{i18n('icu:SafetyTipsModal__Title-v2')}
</AxoDialog.Title>
</div>
<AxoDialog.Close aria-label={i18n('icu:close')} />
</AxoDialog.Header>
<Tabs.Root
value={tips[pageIndex]?.key}
onValueChange={scrollToPageKey}
role="group"
aria-label={i18n('icu:SafetyTipsModal__Title-v2')}
aria-roledescription={i18n('icu:Carousel--Role')}
>
<div
className={tw('flex gap-3 overflow-hidden')}
id={cardWrapperId}
aria-live="polite"
aria-atomic="false"
>
{tips.map((page, index) => {
return (
<Tabs.Content
key={page.key}
id={getCardIdForPage(index)}
value={page.key}
forceMount
className={tw('w-full shrink-0 snap-center px-10')}
tabIndex={index === pageIndex ? 0 : -1}
aria-roledescription={i18n('icu:Carousel__Slide--Role')}
>
<div className={tw('flex justify-center')}>
<img
role="presentation"
alt=""
className={tw('w-[#204px]')}
src={page.imageUrl}
width={240}
/>
</div>
<h2
className={tw(
'mt-2 type-title-small font-semibold text-label-primary'
)}
>
{page.title}
</h2>
<p className={tw('mt-1 type-body-medium text-label-secondary')}>
{page.description}
</p>
</Tabs.Content>
);
})}
</div>
<AxoDialog.Footer>
<div className={tw('flex w-full items-center justify-between p-1')}>
<div className={pageIndex === 0 ? tw('invisible') : ''}>
<AxoIconButton.Root
variant="secondary"
iconWeight={300}
label={i18n('icu:previous')}
tooltip={false}
symbol="chevron-[start]"
disabled={pageIndex === 0}
size="sm"
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === 0) {
return;
}
scrollToPageIndex(pageIndex - 1);
}}
/>
</div>
<Tabs.List loop={false}>
{tips.map(page => {
return (
<Tabs.Trigger
key={page.key}
value={page.key}
className="SafetyTipsModal__DotsButton"
aria-label={page.title}
/>
);
})}
</Tabs.List>
<div className={pageIndex === maxPageIndex ? tw('invisible') : ''}>
<AxoIconButton.Root
symbol="chevron-[end]"
variant="secondary"
tooltip={false}
iconWeight={300}
label={i18n('icu:next')}
disabled={pageIndex === maxPageIndex}
size="sm"
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === maxPageIndex) {
return;
}
scrollToPageIndex(pageIndex + 1);
}}
/>
</div>
</div>
</AxoDialog.Footer>
</Tabs.Root>
</>
);
}