ICU types
This commit is contained in:
parent
38adef4233
commit
78f4e96297
42 changed files with 583 additions and 1182 deletions
|
@ -45,7 +45,7 @@ export function CallParticipantCount({
|
|||
if (!isToggleVisible) {
|
||||
return (
|
||||
<span
|
||||
aria-label={i18n('icu:calling__participants', {
|
||||
aria-label={i18n('icu:calling__participants--pluralized', {
|
||||
people: count,
|
||||
})}
|
||||
className="CallControls__Status--InactiveCallParticipantCount"
|
||||
|
@ -57,7 +57,7 @@ export function CallParticipantCount({
|
|||
|
||||
return (
|
||||
<button
|
||||
aria-label={i18n('icu:calling__participants', {
|
||||
aria-label={i18n('icu:calling__participants--pluralized', {
|
||||
people: count,
|
||||
})}
|
||||
className="CallControls__Status--ParticipantCount"
|
||||
|
|
|
@ -570,20 +570,20 @@ export function CallScreen({
|
|||
);
|
||||
} else {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--one', {
|
||||
name: names[0],
|
||||
name: names[0] ?? '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--two', {
|
||||
name: names[0],
|
||||
otherName: names[1],
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--more', {
|
||||
name: names[0],
|
||||
otherName: names[1],
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
overflowCount: names.length - 2,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -54,13 +54,11 @@ export function CallingAdhocCallInfo({
|
|||
<div className="CallingAdhocCallInfo module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{!participants.length && i18n('icu:calling__in-this-call--zero')}
|
||||
{participants.length === 1 &&
|
||||
i18n('icu:calling__in-this-call--one')}
|
||||
{participants.length > 1 &&
|
||||
i18n('icu:calling__in-this-call--many', {
|
||||
people: participants.length,
|
||||
})}
|
||||
{participants.length
|
||||
? i18n('icu:calling__in-this-call', {
|
||||
people: participants.length,
|
||||
})
|
||||
: i18n('icu:calling__in-this-call--zero')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -82,14 +82,11 @@ export const CallingParticipantsList = React.memo(
|
|||
<div className="module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{!participants.length &&
|
||||
i18n('icu:calling__in-this-call--zero')}
|
||||
{participants.length === 1 &&
|
||||
i18n('icu:calling__in-this-call--one')}
|
||||
{participants.length > 1 &&
|
||||
i18n('icu:calling__in-this-call--many', {
|
||||
people: participants.length,
|
||||
})}
|
||||
{participants.length
|
||||
? i18n('icu:calling__in-this-call', {
|
||||
people: participants.length,
|
||||
})
|
||||
: i18n('icu:calling__in-this-call--zero')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -406,7 +406,7 @@ export function ConversationList({
|
|||
get(lastMessage, 'text') ||
|
||||
i18n('icu:ConversationList__last-message-undefined'),
|
||||
title,
|
||||
unreadCount,
|
||||
unreadCount: unreadCount ?? 0,
|
||||
})}
|
||||
key={key}
|
||||
badge={getPreferredBadge(badges)}
|
||||
|
|
|
@ -414,7 +414,7 @@ export function EditUsernameModalBody({
|
|||
}}
|
||||
>
|
||||
{i18n('icu:ProfileEditor--username--reservation-gone', {
|
||||
username: reservation?.username ?? nickname,
|
||||
username: reservation?.username ?? nickname ?? '',
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
import type { Props } from './Intl';
|
||||
import { Intl } from './Intl';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
@ -14,68 +14,77 @@ const i18n = setupI18n('en', enMessages);
|
|||
export default {
|
||||
title: 'Components/Intl',
|
||||
component: Intl,
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
i18n,
|
||||
id: overrideProps.id || '',
|
||||
components: overrideProps.components,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line react/function-component-definition, local-rules/valid-i18n-keys
|
||||
const Template: StoryFn<Props> = args => <Intl {...args} />;
|
||||
|
||||
export const NoReplacements = Template.bind({});
|
||||
NoReplacements.args = createProps({
|
||||
id: 'icu:deleteAndRestart',
|
||||
});
|
||||
|
||||
export const SingleStringReplacement = Template.bind({});
|
||||
SingleStringReplacement.args = createProps({
|
||||
id: 'icu:leftTheGroup',
|
||||
components: { name: 'Theodora' },
|
||||
});
|
||||
|
||||
export const SingleTagReplacement = Template.bind({});
|
||||
SingleTagReplacement.args = createProps({
|
||||
id: 'icu:leftTheGroup',
|
||||
components: {
|
||||
name: (
|
||||
<button type="button" key="a-button">
|
||||
Theodora
|
||||
</button>
|
||||
),
|
||||
args: {
|
||||
i18n,
|
||||
id: 'icu:ok',
|
||||
components: undefined,
|
||||
},
|
||||
});
|
||||
} satisfies ComponentMeta<Props<'icu:ok'>>;
|
||||
|
||||
export const MultipleStringReplacement = Template.bind({});
|
||||
MultipleStringReplacement.args = createProps({
|
||||
id: 'icu:changedRightAfterVerify',
|
||||
components: {
|
||||
name1: 'Fred',
|
||||
name2: 'The Fredster',
|
||||
},
|
||||
});
|
||||
|
||||
export const MultipleTagReplacement = Template.bind({});
|
||||
MultipleTagReplacement.args = createProps({
|
||||
id: 'icu:changedRightAfterVerify',
|
||||
components: {
|
||||
name1: <b>Fred</b>,
|
||||
name2: <b>The Fredster</b>,
|
||||
},
|
||||
});
|
||||
|
||||
export function Emoji(): JSX.Element {
|
||||
const customI18n = setupI18n('en', {
|
||||
'icu:emoji': {
|
||||
messageformat: '<emojify>👋</emojify> Hello, world!',
|
||||
},
|
||||
});
|
||||
export function NoReplacements(
|
||||
args: Props<'icu:deleteAndRestart'>
|
||||
): JSX.Element {
|
||||
return <Intl {...args} id="icu:deleteAndRestart" />;
|
||||
}
|
||||
|
||||
export function SingleStringReplacement(
|
||||
args: Props<'icu:leftTheGroup'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
<Intl i18n={customI18n} id="icu:emoji" />
|
||||
<Intl {...args} id="icu:leftTheGroup" components={{ name: 'Theodora' }} />
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleTagReplacement(
|
||||
args: Props<'icu:leftTheGroup'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:leftTheGroup"
|
||||
components={{
|
||||
name: (
|
||||
<button type="button" key="a-button">
|
||||
Theodora
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultipleStringReplacement(
|
||||
args: Props<'icu:changedRightAfterVerify'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:changedRightAfterVerify"
|
||||
components={{ name1: 'Fred', name2: 'The Fredster' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultipleTagReplacement(
|
||||
args: Props<'icu:changedRightAfterVerify'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:changedRightAfterVerify"
|
||||
components={{ name1: <b>Fred</b>, name2: <b>The Fredster</b> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Emoji(
|
||||
args: Props<'icu:Message__reaction-emoji-label--you'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:Message__reaction-emoji-label--you"
|
||||
components={{ emoji: '😛' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,34 +2,31 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { FormatXMLElementFn } from 'intl-messageformat';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ReplacementValuesType } from '../types/I18N';
|
||||
import type {
|
||||
LocalizerType,
|
||||
ICUJSXMessageParamsByKeyType,
|
||||
} from '../types/Util';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
export type FullJSXType =
|
||||
| FormatXMLElementFn<JSX.Element | string>
|
||||
| Array<JSX.Element | string>
|
||||
| ReactNode
|
||||
| JSX.Element
|
||||
| string;
|
||||
export type IntlComponentsType = undefined | ReplacementValuesType<FullJSXType>;
|
||||
|
||||
export type Props = {
|
||||
export type Props<Key extends keyof ICUJSXMessageParamsByKeyType> = {
|
||||
/** The translation string id */
|
||||
id: string;
|
||||
id: Key;
|
||||
i18n: LocalizerType;
|
||||
components?: IntlComponentsType;
|
||||
};
|
||||
} & (ICUJSXMessageParamsByKeyType[Key] extends undefined
|
||||
? {
|
||||
components?: ICUJSXMessageParamsByKeyType[Key];
|
||||
}
|
||||
: {
|
||||
components: ICUJSXMessageParamsByKeyType[Key];
|
||||
});
|
||||
|
||||
export function Intl({
|
||||
export function Intl<Key extends keyof ICUJSXMessageParamsByKeyType>({
|
||||
components,
|
||||
id,
|
||||
// Indirection for linter/migration tooling
|
||||
i18n: localizer,
|
||||
}: Props): JSX.Element | null {
|
||||
}: Props<Key>): JSX.Element | null {
|
||||
if (!id) {
|
||||
log.error('Error: Intl id prop not provided');
|
||||
return null;
|
||||
|
|
|
@ -764,7 +764,7 @@ export function ProfileEditor({
|
|||
]}
|
||||
>
|
||||
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
|
||||
username,
|
||||
username: username ?? '',
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
|
|
@ -580,7 +580,7 @@ export function SendStoryModal({
|
|||
|
||||
<div className="SendStoryModal__distribution-list__description">
|
||||
{i18n('icu:ConversationHero--members', {
|
||||
count: group.membersCount,
|
||||
count: group.membersCount ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -853,7 +853,7 @@ export function SendStoryModal({
|
|||
</span>
|
||||
<span className="SendStoryModal__rtl-span">
|
||||
{i18n('icu:ConversationHero--members', {
|
||||
count: group.membersCount,
|
||||
count: group.membersCount ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -231,7 +231,7 @@ function GroupStoryItem({
|
|||
{i18n('icu:StoriesSettings__group-story-subtitle')}
|
||||
·
|
||||
{i18n('icu:StoriesSettings__viewers', {
|
||||
count: groupStory.membersCount,
|
||||
count: groupStory.membersCount ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
@ -325,7 +325,7 @@ export function renderToast({
|
|||
>
|
||||
{i18n('icu:decryptionErrorToast', {
|
||||
name,
|
||||
deviceId,
|
||||
deviceId: String(deviceId),
|
||||
})}
|
||||
</Toast>
|
||||
);
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import type { FormatXMLElementFn } from 'intl-messageformat';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { UNSUPPORTED_OS_URL } from '../types/support';
|
||||
|
@ -28,14 +27,14 @@ export function UnsupportedOSDialog({
|
|||
type,
|
||||
OS,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const learnMoreLink: FormatXMLElementFn<JSX.Element | string> = children => (
|
||||
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
|
||||
<a
|
||||
key="signal-support"
|
||||
href={UNSUPPORTED_OS_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</a>
|
||||
);
|
||||
|
||||
|
|
|
@ -90,7 +90,7 @@ function GroupNotificationChange({
|
|||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:joinedTheGroup"
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
components={{ name: otherPeople[0] }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
|
@ -121,7 +121,7 @@ function GroupNotificationChange({
|
|||
<Intl
|
||||
id="icu:multipleLeftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
components={{ name: otherPeople[0] }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
|
|
|
@ -25,7 +25,7 @@ export function GroupV1DisabledActions({
|
|||
components={{
|
||||
// This is a render prop, not a component
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
learnMoreLink: (...parts) => {
|
||||
learnMoreLink: parts => {
|
||||
return (
|
||||
<a
|
||||
href="https://support.signal.org/hc/articles/360007319331"
|
||||
|
|
|
@ -13,7 +13,6 @@ import { SignalService as Proto } from '../../protobuf';
|
|||
import type { SmartContactRendererType } from '../../groupChange';
|
||||
import type { PropsType } from './GroupV2Change';
|
||||
import { GroupV2Change } from './GroupV2Change';
|
||||
import type { FullJSXType } from '../Intl';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -28,7 +27,7 @@ const INVITEE_A = generateAci();
|
|||
const AccessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const RoleEnum = Proto.Member.Role;
|
||||
|
||||
const renderContact: SmartContactRendererType<FullJSXType> = (
|
||||
const renderContact: SmartContactRendererType<JSX.Element> = (
|
||||
conversationId: string
|
||||
) => (
|
||||
<React.Fragment key={conversationId}>
|
||||
|
|
|
@ -6,10 +6,11 @@ import React, { useState } from 'react';
|
|||
import { get } from 'lodash';
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import type { ReplacementValuesType } from '../../types/I18N';
|
||||
import type { FullJSXType } from '../Intl';
|
||||
import { Intl } from '../Intl';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type {
|
||||
LocalizerType,
|
||||
ICUJSXMessageParamsByKeyType,
|
||||
} from '../../types/Util';
|
||||
import type {
|
||||
AciString,
|
||||
PniString,
|
||||
|
@ -49,19 +50,18 @@ export type PropsActionsType = {
|
|||
|
||||
export type PropsHousekeepingType = {
|
||||
i18n: LocalizerType;
|
||||
renderContact: SmartContactRendererType<FullJSXType>;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType &
|
||||
PropsActionsType &
|
||||
PropsHousekeepingType;
|
||||
|
||||
function renderStringToIntl(
|
||||
id: string,
|
||||
function renderStringToIntl<Key extends keyof ICUJSXMessageParamsByKeyType>(
|
||||
id: Key,
|
||||
i18n: LocalizerType,
|
||||
components?: ReplacementValuesType<FullJSXType>
|
||||
): FullJSXType {
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
components: ICUJSXMessageParamsByKeyType[Key]
|
||||
): JSX.Element {
|
||||
return <Intl id={id} i18n={i18n} components={components} />;
|
||||
}
|
||||
|
||||
|
@ -168,8 +168,8 @@ function GroupV2Detail({
|
|||
i18n: LocalizerType;
|
||||
fromId?: ServiceIdString;
|
||||
ourAci: AciString | undefined;
|
||||
renderContact: SmartContactRendererType<FullJSXType>;
|
||||
text: FullJSXType;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
text: ReactNode;
|
||||
}): JSX.Element {
|
||||
const icon = getIcon(detail, isLastText, fromId);
|
||||
let buttonNode: ReactNode;
|
||||
|
@ -305,12 +305,12 @@ export function GroupV2Change(props: PropsType): ReactElement {
|
|||
|
||||
return (
|
||||
<>
|
||||
{renderChange<FullJSXType>(change, {
|
||||
{renderChange<JSX.Element>(change, {
|
||||
i18n,
|
||||
ourAci,
|
||||
ourPni,
|
||||
renderContact,
|
||||
renderString: renderStringToIntl,
|
||||
renderIntl: renderStringToIntl,
|
||||
}).map(({ detail, isLastText, text }, index) => {
|
||||
return (
|
||||
<GroupV2Detail
|
||||
|
|
|
@ -360,7 +360,7 @@ const renderItem = ({
|
|||
conversationId=""
|
||||
item={items[messageId]}
|
||||
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
|
||||
renderContact={() => '*ContactName*'}
|
||||
renderContact={() => <div>*ContactName*</div>}
|
||||
renderEmojiPicker={() => <div />}
|
||||
renderReactionPicker={() => <div />}
|
||||
renderUniversalTimerNotification={() => (
|
||||
|
|
|
@ -18,7 +18,6 @@ import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
|||
import { WidthBreakpoint } from '../_util';
|
||||
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import type { FullJSXType } from '../Intl';
|
||||
import { Intl } from '../Intl';
|
||||
import { TimelineWarning } from './TimelineWarning';
|
||||
import { TimelineWarnings } from './TimelineWarnings';
|
||||
|
@ -980,7 +979,9 @@ export class Timeline extends React.Component<
|
|||
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
|
||||
const { groupNameCollisions } = warning;
|
||||
const numberOfSharedNames = Object.keys(groupNameCollisions).length;
|
||||
const reviewRequestLink: FullJSXType = parts => (
|
||||
const reviewRequestLink = (
|
||||
parts: Array<string | JSX.Element>
|
||||
): JSX.Element => (
|
||||
<TimelineWarning.Link onClick={reviewConversationNameCollision}>
|
||||
{parts}
|
||||
</TimelineWarning.Link>
|
||||
|
|
|
@ -53,7 +53,6 @@ import { ConversationMergeNotification } from './ConversationMergeNotification';
|
|||
import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification';
|
||||
import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import type { FullJSXType } from '../Intl';
|
||||
import { TimelineMessage } from './TimelineMessage';
|
||||
|
||||
type CallHistoryType = {
|
||||
|
@ -165,7 +164,7 @@ type PropsLocalType = {
|
|||
targetMessage: (messageId: string, conversationId: string) => unknown;
|
||||
shouldRenderDateHeader: boolean;
|
||||
platform: string;
|
||||
renderContact: SmartContactRendererType<FullJSXType>;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
renderUniversalTimerNotification: () => JSX.Element;
|
||||
i18n: LocalizerType;
|
||||
interactionMode: InteractionModeType;
|
||||
|
|
|
@ -72,8 +72,12 @@ const renderPerson = (
|
|||
isMe?: boolean;
|
||||
title: string;
|
||||
}>
|
||||
): ReactNode =>
|
||||
person.isMe ? i18n('icu:you') : <ContactName title={person.title} />;
|
||||
): JSX.Element =>
|
||||
person.isMe ? (
|
||||
<Intl i18n={i18n} id="icu:you" />
|
||||
) : (
|
||||
<ContactName title={person.title} />
|
||||
);
|
||||
|
||||
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||
function MessageSearchResult({
|
||||
|
|
|
@ -135,7 +135,7 @@ function InstallScreenQrCode(
|
|||
id="icu:Install__qr-failed-load"
|
||||
components={{
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
retry: children => (
|
||||
retry: (parts: Array<string | JSX.Element>) => (
|
||||
<button
|
||||
className={getQrCodeClassName('__link')}
|
||||
onClick={props.retryGetQrCode}
|
||||
|
@ -148,7 +148,7 @@ function InstallScreenQrCode(
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import type { FormatXMLElementFn } from 'intl-messageformat';
|
||||
import formatFileSize from 'filesize';
|
||||
|
||||
import { DialogType } from '../../types/Dialogs';
|
||||
|
@ -36,14 +35,14 @@ export function InstallScreenUpdateDialog({
|
|||
currentVersion,
|
||||
OS,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const learnMoreLink: FormatXMLElementFn<JSX.Element | string> = children => (
|
||||
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
|
||||
<a
|
||||
key="signal-support"
|
||||
href={UNSUPPORTED_OS_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</a>
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue