diff --git a/apps/web/styles/app.css b/apps/web/styles/app.css index cfecd650..f4280fb8 100644 --- a/apps/web/styles/app.css +++ b/apps/web/styles/app.css @@ -77,3 +77,15 @@ body, display: none; } } + +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} diff --git a/packages/components/.storybook/components.css b/packages/components/.storybook/components.css new file mode 100644 index 00000000..2fbe765c --- /dev/null +++ b/packages/components/.storybook/components.css @@ -0,0 +1,12 @@ +/* Animation for skeleton placeholder */ +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} diff --git a/packages/components/.storybook/preview.tsx b/packages/components/.storybook/preview.tsx index 2941a73d..efb6b18d 100644 --- a/packages/components/.storybook/preview.tsx +++ b/packages/components/.storybook/preview.tsx @@ -1,7 +1,9 @@ +import React from 'react' import { Provider, ToastContainer } from '../src' import { Parameters, Decorator } from '@storybook/react' import './reset.css' +import './components.css' export const parameters: Parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, diff --git a/packages/components/src/banner/banner.stories.tsx b/packages/components/src/banner/banner.stories.tsx index 3a78021f..c5b5043c 100644 --- a/packages/components/src/banner/banner.stories.tsx +++ b/packages/components/src/banner/banner.stories.tsx @@ -1,4 +1,4 @@ -import { PinIcon } from '@status-im/icons/20' +import { AlertIcon, PinIcon, RecentIcon } from '@status-im/icons/20' import { Stack } from '@tamagui/core' import { Banner } from './banner' @@ -38,6 +38,22 @@ export const NoCount: Story = { }, } +export const NetworkStateConnecting: Story = { + args: { + backgroundColor: '$neutral-80-opa-5', + icon: , + children: 'Connecting...', + }, +} + +export const NetworkStateError: Story = { + args: { + backgroundColor: '$danger-50-opa-20', + icon: , + children: 'Network is down', + }, +} + export const AllVariants: Story = { args: {}, render: () => ( @@ -46,6 +62,12 @@ export const AllVariants: Story = { Banner message Banner message + }> + Connecting... + + }> + Network is down + }>Banner message ), diff --git a/packages/components/src/banner/banner.tsx b/packages/components/src/banner/banner.tsx index 0b1de159..c8bd549c 100644 --- a/packages/components/src/banner/banner.tsx +++ b/packages/components/src/banner/banner.tsx @@ -4,17 +4,25 @@ import { View } from 'react-native' import { Counter } from '../counter' import { Text } from '../text' +import type { ColorTokens } from '@tamagui/core' + type Props = { children: React.ReactNode icon?: React.ReactNode count?: number + backgroundColor?: ColorTokens } const Banner = (props: Props) => { - const { icon, children, count } = props + const { + icon, + children, + count, + backgroundColor = '$primary-50-opa-20', + } = props return ( - + {icon} @@ -30,7 +38,6 @@ export { Banner } export type { Props as BannerProps } const Base = styled(View, { - backgroundColor: '$primary-50-opa-20', padding: 12, flexDirection: 'row', alignItems: 'center', diff --git a/packages/components/src/context-tag/context-tag.tsx b/packages/components/src/context-tag/context-tag.tsx index 5f4711ee..3eee5430 100644 --- a/packages/components/src/context-tag/context-tag.tsx +++ b/packages/components/src/context-tag/context-tag.tsx @@ -55,7 +55,7 @@ const ContextTag = (props: Props) => { src, icon, label, - type = 'default', + // type = 'default', // this is commented because it's not being used size = 24, blur = false, outline, diff --git a/packages/components/src/gap-messages/gap-messages.stories.tsx b/packages/components/src/gap-messages/gap-messages.stories.tsx new file mode 100644 index 00000000..108201ff --- /dev/null +++ b/packages/components/src/gap-messages/gap-messages.stories.tsx @@ -0,0 +1,30 @@ +import { GapMessages } from './gap-messages' + +import type { Meta, StoryObj } from '@storybook/react' + +// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction +const meta: Meta = { + title: 'gap-messages', + component: GapMessages, + argTypes: {}, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=5187-181408&t=5dgANDld90Qfd00V-0', + }, + }, +} + +type Story = StoryObj + +// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args +export const Default: Story = { + args: { + message: 'This is a simple message.', + startDate: 'Jan 8 · 09:12', + endDate: 'Mar 8 · 22:42', + tooltipMessage: 'This is some tooltip message.', + }, +} + +export default meta diff --git a/packages/components/src/gap-messages/gap-messages.tsx b/packages/components/src/gap-messages/gap-messages.tsx new file mode 100644 index 00000000..cae477cc --- /dev/null +++ b/packages/components/src/gap-messages/gap-messages.tsx @@ -0,0 +1,110 @@ +import { InfoIcon } from '@status-im/icons/16' +import { Stack, styled } from '@tamagui/core' + +import { Text } from '../text' +import { Tooltip } from '../tooltip' + +const NUM_CIRCLES = 200 + +type Props = { + startDate: string + endDate: string + message: string + tooltipMessage: string +} + +// TODO try to find a solution for the inset shadow +const GapMessages = (props: Props) => { + const { startDate, endDate, message, tooltipMessage } = props + + return ( + + + + + + + + + + + + + + {startDate} + + + + {message} + + + + {endDate} + + + + + {tooltipMessage}}> + + + + + + + + + + + ) +} + +export { GapMessages } +export type { Props as GapMessageProps } + +// TODO try to find a responsive solution if we need to keep the circles in the future +const Circles = () => { + return ( + <> + {[...Array(NUM_CIRCLES)].map((_, i) => ( + + ))} + + ) +} + +const Circle = styled(Stack, { + name: 'Circle', + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '$neutral-5', + marginRight: 7, +}) + +const EmptyCircle = styled(Stack, { + name: 'EmptyCircle', + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '$neutral-40', +}) + +const Divider = styled(Stack, { + name: 'Divider', + backgroundColor: '$neutral-40', + borderWidth: 1, + borderColor: '$neutral-5', + borderStyle: 'dashed', + width: 1, + height: 'auto', +}) diff --git a/packages/components/src/gap-messages/index.tsx b/packages/components/src/gap-messages/index.tsx new file mode 100644 index 00000000..5648edaa --- /dev/null +++ b/packages/components/src/gap-messages/index.tsx @@ -0,0 +1 @@ +export { GapMessages } from './gap-messages' diff --git a/packages/components/src/index.tsx b/packages/components/src/index.tsx index f158813f..9811cf29 100644 --- a/packages/components/src/index.tsx +++ b/packages/components/src/index.tsx @@ -3,14 +3,17 @@ export * from './button' export * from './composer' export * from './dividers' export * from './dynamic-button' +export * from './gap-messages' export * from './icon-button' export * from './image' +export * from './information-box' export * from './input' export * from './messages' export * from './pinned-message' export * from './provider' export * from './sidebar' export * from './sidebar-members' +export * from './skeleton' export * from './text' export * from './toast' export * from './topbar' diff --git a/packages/components/src/information-box/index.tsx b/packages/components/src/information-box/index.tsx new file mode 100644 index 00000000..1534a617 --- /dev/null +++ b/packages/components/src/information-box/index.tsx @@ -0,0 +1 @@ +export { InformationBox } from './information-box' diff --git a/packages/components/src/information-box/information-box.stories.tsx b/packages/components/src/information-box/information-box.stories.tsx new file mode 100644 index 00000000..92069e6a --- /dev/null +++ b/packages/components/src/information-box/information-box.stories.tsx @@ -0,0 +1,167 @@ +import { InfoIcon } from '@status-im/icons/16' + +import { InformationBox } from './information-box' + +import type { Meta, StoryObj } from '@storybook/react' + +// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction +const meta: Meta = { + title: 'information-box', + component: InformationBox, + argTypes: {}, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=5187-181408&t=5dgANDld90Qfd00V-0', + }, + }, +} + +type Story = StoryObj + +// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args +export const Default: Story = { + args: { + message: 'This is a simple message.', + }, +} + +export const Information: Story = { + args: { + ...Default.args, + variant: 'information', + }, +} + +export const Error: Story = { + args: { + ...Default.args, + variant: 'error', + }, +} + +export const DefaultWithIcon: Story = { + args: { + message: 'This is a simple message with an info icon.', + icon: , + }, +} + +export const InformationWithIcon: Story = { + args: { + ...DefaultWithIcon.args, + variant: 'information', + }, +} + +export const ErrorWithIcon: Story = { + args: { + ...DefaultWithIcon.args, + variant: 'error', + }, +} + +export const WithMaxWidth: Story = { + args: { + ...Default.args, + }, +} + +export const WithIconAndTwoLines: Story = { + args: { + ...DefaultWithIcon.args, + message: 'This is a message with an icon and two lines.', + }, +} + +export const WithButtonAndIconDefault: Story = { + args: { + ...DefaultWithIcon.args, + message: 'This is a message with an icon and a button.', + buttonText: 'Button', + onButtonPress: () => alert('clicked'), + }, +} + +export const WithButtonAndIconInformation: Story = { + args: { + ...WithButtonAndIconDefault.args, + variant: 'information', + }, +} + +export const WithButtonAndIconError: Story = { + args: { + ...WithButtonAndIconDefault.args, + variant: 'error', + }, +} + +export const DefaultWithDismiss: Story = { + args: { + message: 'This is a simple message.', + onClosePress: () => alert('dismissed'), + }, +} + +export const InformationWithDismiss: Story = { + args: { + ...DefaultWithDismiss.args, + variant: 'information', + }, +} + +export const ErrorWithDismiss: Story = { + args: { + ...DefaultWithDismiss.args, + variant: 'error', + }, +} + +export const DefaultWithIconAndDismiss: Story = { + args: { + message: 'This is a simple message with an info icon.', + icon: , + onClosePress: () => alert('dismissed'), + }, +} + +export const InformationWithIconAndDismiss: Story = { + args: { + ...DefaultWithIconAndDismiss.args, + variant: 'information', + }, +} + +export const ErrorWithIconAndDismiss: Story = { + args: { + ...DefaultWithIconAndDismiss.args, + variant: 'error', + }, +} + +export const WithButtonAndIconAndDismiss: Story = { + args: { + ...WithButtonAndIconDefault.args, + message: 'This is a message with an icon and a button.', + buttonText: 'Button', + onButtonPress: () => alert('clicked'), + onClosePress: () => alert('dismissed'), + }, +} + +export const WithButtonAndIconAndDismissInformation: Story = { + args: { + ...WithButtonAndIconAndDismiss.args, + variant: 'information', + }, +} + +export const WithButtonAndIconAndDismissError: Story = { + args: { + ...WithButtonAndIconAndDismiss.args, + variant: 'error', + }, +} + +export default meta diff --git a/packages/components/src/information-box/information-box.tsx b/packages/components/src/information-box/information-box.tsx new file mode 100644 index 00000000..98fd291e --- /dev/null +++ b/packages/components/src/information-box/information-box.tsx @@ -0,0 +1,134 @@ +import { cloneElement } from 'react' + +import { CloseIcon } from '@status-im/icons/12' +import { Stack, styled } from '@tamagui/core' + +import { Button } from '../button' +import { Text } from '../text' + +import type { GetVariants, MapColorToken } from '../types' + +type Variants = GetVariants + +type Props = { + message: string + variant?: Variants['variant'] + icon?: React.ReactElement + buttonText?: string + onButtonPress?: () => void + onClosePress?: () => void +} + +type Variant = Props['variant'] + +const textColors: MapColorToken = { + default: '$neutral-100', + information: '$neutral-100', + error: '$danger-50', +} + +const iconColors: MapColorToken = { + default: '$neutral-50', + information: '$neutral-50', + error: '$danger-50', +} + +const buttonVariants: Record, 'primary' | 'danger'> = { + default: 'primary', + information: 'primary', + error: 'danger', +} + +const InformationBox = (props: Props) => { + const { + message, + variant = 'default', + icon, + buttonText, + onButtonPress, + onClosePress, + } = props + + const textColor = textColors[variant] + const iconColor = iconColors[variant] + const buttonVariant = buttonVariants[variant] + + return ( + + + {icon ? ( + + {cloneElement(icon, { color: iconColor })} + + ) : null} + + + {message} + + {buttonText ? ( + + + + ) : null} + + {onClosePress ? ( + onClosePress()} + cursor="pointer" + alignSelf="flex-start" + > + + + ) : null} + + + ) +} + +export { InformationBox } +export type { Props as InformationBoxProps } + +const Base = styled(Stack, { + name: 'InformationBox', + + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + + userSelect: 'none', + borderWidth: 1, + + py: 11, + px: 16, + borderRadius: 12, + + variants: { + variant: { + default: { + backgroundColor: '$white-100', + borderColor: '$neutral-20', + }, + information: { + backgroundColor: '$blue-50-opa-5', + borderColor: '$blue-50-opa-10', + }, + error: { + backgroundColor: '$danger-50-opa-5', + borderColor: '$danger-50-opa-10', + }, + }, + }, +}) diff --git a/packages/components/src/sidebar/sidebar.stories.tsx b/packages/components/src/sidebar/sidebar.stories.tsx index 832972e2..fb07b274 100644 --- a/packages/components/src/sidebar/sidebar.stories.tsx +++ b/packages/components/src/sidebar/sidebar.stories.tsx @@ -1,7 +1,19 @@ +import { Stack } from '@tamagui/core' + import { CHANNEL_GROUPS } from './mock-data' import { Sidebar } from './sidebar' -import type { Meta, StoryObj } from '@storybook/react' +import type { SidebarProps } from './sidebar' +import type { Meta } from '@storybook/react' + +const COMMUNITY = { + name: 'Rarible', + description: + 'Multichain community-centric NFT marketplace. Create, buy and sell your NFTs.', + membersCount: 123, + imageUrl: + 'https://images.unsplash.com/photo-1574786527860-f2e274867c91?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1764&q=80', +} // More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction const meta: Meta = { @@ -9,6 +21,7 @@ const meta: Meta = { component: Sidebar, args: { channels: CHANNEL_GROUPS, + community: COMMUNITY, }, argTypes: {}, parameters: { @@ -19,11 +32,20 @@ const meta: Meta = { }, } -type Story = StoryObj +export const Default = { + render: (args: SidebarProps) => ( + + + + ), +} -// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args -export const Default: Story = { - args: {}, +export const LoadingSidebar = { + render: (args: SidebarProps) => ( + + + + ), } export default meta diff --git a/packages/components/src/sidebar/sidebar.tsx b/packages/components/src/sidebar/sidebar.tsx index e9c849d9..069ef28a 100644 --- a/packages/components/src/sidebar/sidebar.tsx +++ b/packages/components/src/sidebar/sidebar.tsx @@ -6,12 +6,13 @@ import { AccordionItem } from '../accordion/accordionItem' import { Avatar } from '../avatar' import { Button } from '../button' import { Image } from '../image' +import { SidebarSkeleton } from '../skeleton/sidebar-skeleton' import { Text } from '../text' import { CHANNEL_GROUPS } from './mock-data' import type { ChannelGroup } from './mock-data' -type Props = { +export type SidebarProps = { community: { name: string description: string @@ -21,18 +22,24 @@ type Props = { channels?: ChannelGroup[] selectedChannelId?: string onChannelPress: (channelId: string) => void + isLoading?: boolean } -const Sidebar = (props: Props) => { +const Sidebar = (props: SidebarProps) => { const { community, channels = CHANNEL_GROUPS, selectedChannelId, onChannelPress, + isLoading, } = props const { name, description, membersCount, imageUrl } = community + if (isLoading) { + return + } + return ( = { + component: MessageSkeleton, + + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/1RN1MFwfSqA6jNFJBeNdEu/Posts-%26-Attachments-for-Web?t=1Xf5496ymHeazodw-0', + }, + }, +} + +type Story = StoryObj + +export const MessageSkeletonSmallest: Story = { + name: 'Smallest', + args: { + size: 'smallest', + }, +} +export const MessageSkeletonSmall: Story = { + name: 'Small', + args: { + size: 'small', + }, +} +export const MessageSkeletonMedium: Story = { + name: 'Medium', + args: { + size: 'medium', + }, +} +export const MessageSkeletonLarge: Story = { + name: 'Large', + args: { + size: 'large', + }, +} + +export default meta diff --git a/packages/components/src/skeleton/message-skeleton.tsx b/packages/components/src/skeleton/message-skeleton.tsx new file mode 100644 index 00000000..50bd81e4 --- /dev/null +++ b/packages/components/src/skeleton/message-skeleton.tsx @@ -0,0 +1,52 @@ +import { Stack } from '@tamagui/core' + +import { Skeleton } from './skeleton' + +import type { StackProps } from '@tamagui/core' + +type SizeVariant = 'smallest' | 'small' | 'medium' | 'large' + +type Props = Omit & { + size?: SizeVariant +} + +const skeletonTopSize = { + smallest: 80, + small: 96, + medium: 112, + large: 124, +} + +const skeletonBottomSizes = { + smallest: 144, + small: 156, + medium: 212, + large: 249, +} + +const MessageSkeleton = (props: Props) => { + const { size, ...rest } = props + return ( + + {/* Avatar */} + + + {/* Text placeholders */} + + + + + ) +} + +export { MessageSkeleton } +export type { Props as MessageSkeletonProps } diff --git a/packages/components/src/skeleton/sidebar-skeleton.tsx b/packages/components/src/skeleton/sidebar-skeleton.tsx new file mode 100644 index 00000000..d77124c1 --- /dev/null +++ b/packages/components/src/skeleton/sidebar-skeleton.tsx @@ -0,0 +1,152 @@ +import { Stack } from '@tamagui/core' + +import { Skeleton } from './skeleton' + +const SidebarSkeleton = () => { + // Eventually we can in the future abstract some of these components to be reusable if we need to + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export { SidebarSkeleton } diff --git a/packages/components/src/skeleton/skeleton.stories.tsx b/packages/components/src/skeleton/skeleton.stories.tsx new file mode 100644 index 00000000..fe835a92 --- /dev/null +++ b/packages/components/src/skeleton/skeleton.stories.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from './skeleton' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + component: Skeleton, + argTypes: {}, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/1RN1MFwfSqA6jNFJBeNdEu/Posts-%26-Attachments-for-Web?t=1Xf5496ymHeazodw-0', + }, + }, +} + +type Story = StoryObj + +export const Avatar: Story = { + name: 'Avatar', + args: {}, +} + +export const Text: Story = { + name: 'Text', + args: { + width: 249, + br: 6, + height: 8, + }, +} + +export default meta diff --git a/packages/components/src/skeleton/skeleton.tsx b/packages/components/src/skeleton/skeleton.tsx new file mode 100644 index 00000000..ef3d3a5d --- /dev/null +++ b/packages/components/src/skeleton/skeleton.tsx @@ -0,0 +1,57 @@ +import { Stack, useTheme } from '@tamagui/core' + +import type { ColorTokens, StackProps } from '@tamagui/core' + +type Props = StackProps & { + width?: number | string + height?: number | string + borderRadius?: number + variant?: 'primary' | 'secondary' +} + +const skeletonColor: Record, ColorTokens> = { + primary: '$neutral-10', + secondary: '$neutral-20', +} + +const Skeleton = (props: Props) => { + const { + width = 32, + height = 32, + borderRadius = 16, + variant = 'primary', + ...rest + } = props + + const theme = useTheme() + /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const color = theme[skeletonColor[variant]]?.val + + return ( + +
+ + ) +} + +export { Skeleton } +export type { Props as SkeletonProps } diff --git a/packages/components/src/skeleton/topbar-skeleton.tsx b/packages/components/src/skeleton/topbar-skeleton.tsx new file mode 100644 index 00000000..5905e0bb --- /dev/null +++ b/packages/components/src/skeleton/topbar-skeleton.tsx @@ -0,0 +1,54 @@ +import { Stack } from '@tamagui/core' +import { BlurView } from 'expo-blur' + +import { Skeleton } from './skeleton' + +const TopbarSkeleton = () => { + return ( + + + + + + + + + + + + + + + ) +} + +export { TopbarSkeleton } diff --git a/packages/components/src/topbar/topbar.stories.tsx b/packages/components/src/topbar/topbar.stories.tsx index 00caec79..b70a28ca 100644 --- a/packages/components/src/topbar/topbar.stories.tsx +++ b/packages/components/src/topbar/topbar.stories.tsx @@ -35,6 +35,13 @@ export const Default: Story = { args: {}, } +export const isLoading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +} + export const WithMembersSelected: Story = { args: { ...Default.args, diff --git a/packages/components/src/topbar/topbar.tsx b/packages/components/src/topbar/topbar.tsx index 9e657081..d9fc77ac 100644 --- a/packages/components/src/topbar/topbar.tsx +++ b/packages/components/src/topbar/topbar.tsx @@ -16,6 +16,7 @@ import { BlurView } from 'expo-blur' import { DropdownMenu } from '../dropdown-menu' import { IconButton } from '../icon-button' import { PinnedMessage } from '../pinned-message' +import { TopbarSkeleton } from '../skeleton/topbar-skeleton' import { Text } from '../text' import type { Channel } from '../sidebar/mock-data' @@ -41,13 +42,19 @@ type Props = { goBack?: () => void channel: Channel blur?: boolean + isLoading?: boolean } const Topbar = (props: Props) => { - const { showMembers, onMembersPress, goBack, blur, channel } = props + const { showMembers, onMembersPress, goBack, blur, channel, isLoading } = + props const { title, description, emoji } = channel + if (isLoading) { + return + } + return ( diff --git a/packages/components/src/types.ts b/packages/components/src/types.ts index 0e7e3539..070b496b 100644 --- a/packages/components/src/types.ts +++ b/packages/components/src/types.ts @@ -28,6 +28,10 @@ export type MapVariant< [key in V[K] & string]: ColorTokens } +export type MapColorToken = { + [key in V & string]: ColorTokens +} + export type GetVariants = Required< GetStyledVariants > diff --git a/yarn.lock b/yarn.lock index e35a37c6..31956389 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13232,7 +13232,6 @@ node-fetch-native@^1.0.1: node-fetch@2.6.7, node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" - uid "1b5d62978f2ed07b99444f64f0df39f960a6d34d" resolved "https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz#1b5d62978f2ed07b99444f64f0df39f960a6d34d" node-forge@^1.1.0, node-forge@^1.2.1, node-forge@^1.3.1: