Fetch history (#292)

This commit is contained in:
Pavel 2022-10-07 21:30:22 +02:00 committed by GitHub
parent 213ca26877
commit 95dc03b99f
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
25 changed files with 382 additions and 202 deletions

View File

@ -240,9 +240,10 @@ export class Chat {
return
}
if (!this.#messages.size) {
return
}
// fixme?: to stop the loading we need to let the listeners know even if there are no messages
// if (!this.#messages.size) {
// return
// }
const messages = this.getMessages()

View File

@ -5,6 +5,8 @@ import { containsOnlyEmoji } from './contains-only-emoji'
test('should be truthy', () => {
expect(containsOnlyEmoji('💩')).toBeTruthy()
expect(containsOnlyEmoji('💩💩💩💩💩💩')).toBeTruthy()
// expect(containsOnlyEmoji('1⃣')).toBeTruthy()
// expect(containsOnlyEmoji('👨‍👩‍👧')).toBeTruthy()
})
test('should be falsy', () => {
@ -14,4 +16,7 @@ test('should be falsy', () => {
expect(containsOnlyEmoji('💩 ')).toBeFalsy()
expect(containsOnlyEmoji('text 💩')).toBeFalsy()
expect(containsOnlyEmoji('💩 text')).toBeFalsy()
expect(containsOnlyEmoji('123')).toBeFalsy()
expect(containsOnlyEmoji('💩 123')).toBeFalsy()
expect(containsOnlyEmoji('123 💩💩💩 ')).toBeFalsy()
})

View File

@ -1,4 +1,7 @@
// todo?: should ignore whitespaces with replace(/\s+/g, '').trim()
/**
* https://www.unicode.org/reports/tr51/#def_emoji_presentation
*/
export function containsOnlyEmoji(text: string): boolean {
return /^\p{Emoji}+$/gu.test(text)
return /^\p{Emoji_Presentation}+$/gu.test(text)
}

View File

@ -42,6 +42,7 @@
"@radix-ui/react-label": "^0.1.5",
"@radix-ui/react-popover": "^0.1.6",
"@radix-ui/react-separator": "^0.1.4",
"@radix-ui/react-toast": "^0.1.1",
"@radix-ui/react-tabs": "^1.0.0",
"@radix-ui/react-toggle-group": "^0.1.5",
"@radix-ui/react-tooltip": "^1.0.0",

View File

@ -56,17 +56,7 @@ export const CommunityDialog = () => {
fill="currentColor"
/>
</svg>
<Button
onClick={() =>
window.open(
'https://status.im/get',
'_blank',
'noopener,noreferrer'
)
}
>
Download Status
</Button>
<Button href="https://status.im/get">Download Status</Button>
</Flex>
</Dialog.Body>
</Dialog>

View File

@ -1,4 +1,4 @@
export { ProtocolProvider, useProtocol } from './provider'
export { ProtocolProvider } from './provider'
export type { Account } from './use-account'
export { useAccount } from './use-account'
export { useActivityCenter } from './use-activity-center'
@ -8,4 +8,5 @@ export type { Member } from './use-members'
export { useMembers } from './use-members'
export type { Message, Reaction, Reactions } from './use-messages'
export { useMessages } from './use-messages'
export { useProtocol } from './use-protocol'
export { useSortedChats } from './use-sorted-chats'

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useReducer } from 'react'
import React, { createContext, useEffect, useReducer } from 'react'
import { createClient } from '@status-im/js'
@ -6,9 +6,9 @@ import { Loading } from '../components/loading'
import type { Account, Client, ClientOptions, Community } from '@status-im/js'
const Context = createContext<State | undefined>(undefined)
export const Context = createContext<State | undefined>(undefined)
type State = {
export type State = {
loading: boolean
client: Client | undefined
community: Community['description'] | undefined
@ -16,7 +16,7 @@ type State = {
dispatch?: React.Dispatch<Action>
}
type Action =
export type Action =
| { type: 'INIT'; client: Client }
| { type: 'UPDATE_COMMUNITY'; community: Community['description'] }
| { type: 'SET_ACCOUNT'; account: Account | undefined }
@ -99,18 +99,3 @@ export const ProtocolProvider = (props: Props) => {
</Context.Provider>
)
}
export function useProtocol() {
const context = useContext(Context)
if (!context) {
throw new Error(`useProtocol must be used within a ProtocolProvider`)
}
// we enforce initialization of client before rendering children
return context as State & {
client: Client
community: Community['description']
dispatch: React.Dispatch<Action>
}
}

View File

@ -1,4 +1,4 @@
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
import type { Account } from '@status-im/js'

View File

@ -1,6 +1,6 @@
import { useMatch } from 'react-router-dom'
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
export const useActiveChat = () => {
const { client } = useProtocol()

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
import type { ActivityCenterLatest } from '@status-im/js'

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
import type { Community } from '@status-im/js'

View File

@ -1,4 +1,4 @@
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
import type { Member } from '@status-im/js'

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { useProtocol } from './provider'
import sub from 'date-fns/sub'
import { useProtocol } from './use-protocol'
import type { Message, Reactions } from '@status-im/js'
@ -9,21 +11,20 @@ type Reaction = keyof Reactions
interface Result {
data: Message[]
loading: boolean
// error?: Error
// fetchMore: () => void
}
export const useMessages = (channelId: string): Result => {
export const useMessages = (chatId: string): Result => {
const { client } = useProtocol()
const chat = client.community.chats.get(channelId)!
// const [state, dispatch] = useReducer<Result>((state,action) => {}, {})
const chat = client.community.chats.get(chatId)!
const [data, setData] = useState<Message[]>(() => chat.getMessages())
const [loading, setLoading] = useState(true)
// const [error, setError] = useState<Error>()
useEffect(() => {
const messages = chat.getMessages()
setData(chat.getMessages())
const handleUpdate = (messages: Message[]) => {
@ -31,15 +32,21 @@ export const useMessages = (channelId: string): Result => {
setData(messages)
}
if (messages.length === 0) {
setLoading(true)
chat.fetchMessages({ start: sub(new Date(), { days: 30 }) })
}
return chat.onMessage(handleUpdate)
}, [chat])
return {
data,
loading,
// fetchMore,
// fetching,
// error,
// hasMore
// fetchMore,
// refetch
}
}

View File

@ -0,0 +1,22 @@
import { useContext } from 'react'
import { Context } from './provider'
import type { Action, State } from './provider'
import type { Client, Community } from '@status-im/js'
import type React from 'react'
export function useProtocol() {
const context = useContext(Context)
if (!context) {
throw new Error(`useProtocol must be used within a ProtocolProvider`)
}
// we enforce initialization of client before rendering children
return context as State & {
client: Client
community: Community['description']
dispatch: React.Dispatch<Action>
}
}

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useProtocol } from './provider'
import { useProtocol } from './use-protocol'
import type { Community } from '@status-im/js'

View File

@ -82,7 +82,8 @@ const Wrapper = styled('div', {
display: 'flex',
overflow: 'hidden',
alignItems: 'flex-end',
padding: '12px 8px 12px 4px',
// padding: '12px 8px 12px 4px',
padding: '12px 8px 12px 8px',
gap: 4,
})

View File

@ -31,7 +31,7 @@ import type { Message, Reaction } from '../../../../protocol'
interface Props {
message: Message
prevMessage?: Message
collapse: boolean
highlight?: boolean
}
@ -57,16 +57,17 @@ interface Props {
// })
export const ChatMessage = (props: Props) => {
const { message, collapse, highlight } = props
const { client, account } = useProtocol()
const { params } = useMatch(':id')!
const chatId = params.id!
const { message, highlight } = props
const mention = false
const pinned = false
const { messageId, contentType, clock, reactions, signer, responseTo } =
const { messageId, contentType, timestamp, reactions, signer, responseTo } =
message
// TODO: remove usage of 0x prefix
@ -74,6 +75,7 @@ export const ChatMessage = (props: Props) => {
const chat = client.community.getChat(chatId)!
const member = client.community.getMember(signer)!
const response = client.community.getChat(params.id!)!.getMessage(responseTo)
const [editing, setEditing] = useState(false)
const [reacting, setReacting] = useState(false)
@ -82,6 +84,7 @@ export const ChatMessage = (props: Props) => {
// const userProfileDialog = useDialog(UserProfileDialog)
// TODO: fix saving of edited message
const handleMessageSubmit = (message: string) => {
chat.sendTextMessage(message)
}
@ -107,7 +110,7 @@ export const ChatMessage = (props: Props) => {
// TODO: pin message
}
const renderMessage = () => {
const renderContent = () => {
if (editing) {
return (
<Box>
@ -170,100 +173,85 @@ export const ChatMessage = (props: Props) => {
}
}
return (
<>
{/* <ContextMenuTrigger> */}
<Wrapper
mention={mention}
pinned={pinned}
data-active={reacting}
highlight={highlight}
>
{responseTo && <MessageReply messageId={responseTo} />}
<Flex gap={2}>
<Box>
{/* <DropdownMenuTrigger>
<button type="button"> */}
<Avatar
size={44}
name={member!.username}
colorHash={member!.colorHash}
/>
{/* </button> */}
{/* <DropdownMenu>
<Flex direction="column" align="center" gap="1">
<Avatar size="36" />
<Text>{member!.username}</Text>
<EmojiHash />
</Flex>
<DropdownMenu.Separator />
<DropdownMenu.Item
icon={<BellIcon />}
onSelect={() => userProfileDialog.open({ member })}
>
View Profile
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Message
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Verify Identity
</DropdownMenu.Item>
<DropdownMenu.Item icon={<BellIcon />}>
Send Contact Request
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={<BellIcon />} danger>
Mark as Untrustworthy
</DropdownMenu.Item>
</DropdownMenu>
</DropdownMenuTrigger> */}
</Box>
const renderMessage = () => {
if (collapse) {
return (
<Box css={{ flex: 1, paddingLeft: 52 }}>
{renderContent()}
<MessageReactions reactions={reactions} onClick={handleReaction} />
</Box>
)
}
<Box css={{ flex: 1 }}>
{/* {pinned && (
return (
<Flex gap={2}>
<Box>
<Avatar
size={44}
name={member!.username}
colorHash={member!.colorHash}
/>
</Box>
<Box css={{ flex: 1 }}>
{/* {pinned && (
</Box>
<Box css={{ flex: 1 }}>
{/* {pinned && (
<Flex gap={1}>
<PinIcon width={8} />
<Text size="13">Pinned by {contact.name}</Text>
</Flex>
)} */}
<Flex gap="1" align="center">
<Text color="primary" weight="500" size="15">
{member!.username}
</Text>
<Text size="10" color="gray">
{new Date(Number(clock)).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
<Flex gap="1" align="center">
<Text color="primary" weight="500" size="15">
{member!.username}
</Text>
<Text size="10" color="gray">
{new Date(Number(timestamp)).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
{renderMessage()}
{renderContent()}
<MessageReactions reactions={reactions} onClick={handleReaction} />
</Box>
</Flex>
<MessageReactions reactions={reactions} onClick={handleReaction} />
</Box>
</Flex>
)
}
<Actions
owner={owner}
pinned={pinned}
onEditClick={() => setEditing(true)}
onReplyClick={handleReplyClick}
onPinClick={handlePinClick}
onDeleteClick={handleMessageDelete}
onReactionClick={handleReaction}
reacting={reacting}
onReactingChange={setReacting}
reactions={reactions}
/>
return (
<>
{/* <ContextMenuTrigger> */}
<Wrapper
mention={mention}
pinned={pinned}
highlight={highlight}
data-active={reacting}
>
{response && <MessageReply message={response} />}
{renderMessage()}
{account && (
<Actions
owner={owner}
pinned={pinned}
onEditClick={() => setEditing(true)}
onReplyClick={handleReplyClick}
onPinClick={handlePinClick}
onDeleteClick={handleMessageDelete}
onReactionClick={handleReaction}
reacting={reacting}
onReactingChange={setReacting}
reactions={reactions}
/>
)}
</Wrapper>
{/* <ContextMenu>
<ContextMenu.Item onSelect={handleReplyClick}>Reply</ContextMenu.Item>
<ContextMenu.Item onSelect={handlePinClick}>Pin</ContextMenu.Item>
</ContextMenu> */}
{/* </ContextMenuTrigger> */}
</>
)
}
@ -280,7 +268,8 @@ const backgroundAnimation = keyframes({
// TODO: Use compound variants https://stitches.dev/docs/variants#compound-variants
const Wrapper = styled('div', {
position: 'relative',
padding: '10px 16px',
padding: '2px 16px',
marginTop: 14,
gap: '$2',
transitionProperty: 'background-color, border-color, color, fill, stroke',

View File

@ -1,47 +1,37 @@
import React from 'react'
import { useMatch } from 'react-router-dom'
import { useProtocol } from '../../../../protocol'
import { styled } from '../../../../styles/config'
import { Avatar, Box, Flex, Image, Text } from '../../../../system'
import type { Message } from '../../../../protocol'
interface Props {
messageId: string
message: Message
}
export const MessageReply = (props: Props) => {
const { messageId } = props
const { message } = props
const { client } = useProtocol()
// TODO: use protocol hook
const { params } = useMatch(':id')! // eslint-disable-line @typescript-eslint/no-non-null-assertion
const message = client.community.getChat(params.id!)!.getMessage(messageId)
if (!message) {
return (
<Wrapper>
<Text color="gray" size="13" truncate>
Message not available.
</Text>
</Wrapper>
)
}
// if (!message) {
// return (
// <Wrapper>
// <Text color="gray" size="13" truncate>
// Message not available.
// </Text>
// </Wrapper>
// )
// }
const { contentType, text, signer } = message
// TODO: can this happen?
const member = client.community.getMember(signer)
if (!member) {
return null
}
const member = client.community.getMember(signer)!
return (
<Wrapper>
<Flex gap="1" align="center">
<Avatar size={20} name={member.username} />
<Avatar size={20} name={member.username} colorHash={member.colorHash} />
<Text color="gray" size="13" weight="500">
{member.username}
</Text>

View File

@ -0,0 +1,32 @@
import React from 'react'
import isSameDay from 'date-fns/isSameDay'
import { Flex, Text } from '../../../../system'
interface Props {
date: Date
}
export const DateDivider = (props: Props) => {
const { date } = props
let label = date.toLocaleDateString([], { weekday: 'long' })
const today = new Date()
const yesterday = new Date().setDate(today.getDate() - 1)
if (isSameDay(date, today)) {
label = 'Today'
} else if (isSameDay(date, yesterday)) {
label = 'Yesterday'
}
return (
<Flex justify="center" css={{ padding: '18px 0 8px' }}>
<Text size="13" color="gray">
{label}
</Text>
</Flex>
)
}

View File

@ -0,0 +1,68 @@
import React from 'react'
import { keyframes } from '../../../../styles/config'
import { Box, Text } from '../../../../system'
interface Props {
label: string
}
const fadeIn = keyframes({
from: { opacity: 0, top: 0 },
to: { opacity: 1 },
})
const spin = keyframes({
to: {
transform: 'rotate(1turn)',
},
})
export const LoadingToast = (props: Props) => {
const { label } = props
return (
<Box
css={{
width: 'max-content',
position: 'sticky',
top: 10,
left: '50%',
transform: 'translateX(-50%)',
background: '$accent-11',
color: '$accent-1',
boxShadow:
'0px 2px 4px rgba(0, 34, 51, 0.16), 0px 4px 12px rgba(0, 34, 51, 0.08)',
borderRadius: 8,
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
gap: 6,
animation: `${fadeIn} .2s linear`,
svg: {
animation: `${spin} 1s linear infinite`,
marginBottom: -1,
},
}}
>
<svg
width="13"
height="12"
viewBox="0 0 13 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.7692 5.07742C10.5677 4.18403 10.0787 3.37742 9.37342 2.77889C8.668 2.18025 7.78425 1.8222 6.85392 1.7598C5.92358 1.69741 4.99847 1.93417 4.21695 2.43357C3.43557 2.93289 2.84032 3.66744 2.51829 4.52621C2.1963 5.38485 2.16416 6.32294 2.42647 7.20091C2.68883 8.07899 3.23215 8.85135 3.97718 9.40157C4.72235 9.95188 5.62888 10.25 6.56139 10.25L6.56139 11.75C5.30961 11.75 4.09047 11.3499 3.08608 10.6082C2.08155 9.86633 1.34532 8.82207 0.989253 7.63032C0.63315 6.43846 0.676901 5.16459 1.11379 3.99953C1.55064 2.8346 2.35652 1.84232 3.40925 1.16961C4.46184 0.496978 5.70538 0.179402 6.9543 0.263164C8.20325 0.346928 9.39243 0.827686 10.344 1.63521C11.2957 2.44286 11.9588 3.53431 12.2324 4.74738L10.7692 5.07742Z"
fill="currentColor"
/>
</svg>
<Text size="13" weight="500">
Loading {label}...
</Text>
</Box>
)
}

View File

@ -0,0 +1,21 @@
import React from 'react'
import ContentLoader from 'react-content-loader'
export const MessageLoader = () => {
return (
<ContentLoader
speed={2}
width={880}
height={64}
viewBox="0 0 880 64"
backgroundColor="var(--colors-accent-8)"
foregroundColor="var(--colors-accent-5)"
>
<circle cx="36" cy="30" r="20" />
<rect x="64" y="8" rx="8" ry="8" width="132" height="20" />
<rect x="200" y="11" rx="4" ry="4" width="50" height="14" />
<rect x="64" y="35" rx="8" ry="8" width="574" height="20" />
</ContentLoader>
)
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react'
import React, { Fragment, useEffect, useRef } from 'react'
import isSameDay from 'date-fns/isSameDay'
import { useLocation, useMatch } from 'react-router-dom'
import { MemberSidebar } from '../../components/member-sidebar'
@ -10,6 +11,9 @@ import { styled } from '../../styles/config'
import { Avatar, Flex, Heading, Text } from '../../system'
import { ChatInput } from './components/chat-input'
import { ChatMessage } from './components/chat-message'
import { DateDivider } from './components/date-divider'
import { LoadingToast } from './components/loading-toast'
import { MessageLoader } from './components/message-loader'
import { Navbar } from './components/navbar'
interface ChatStartProps {
@ -75,20 +79,52 @@ const Body = () => {
chat.sendTextMessage(message, state.reply?.message.messageId)
}
const renderContent = () => {
if (messages.loading) {
return (
<>
<LoadingToast label="last 30 days" />
<MessageLoader />
<MessageLoader />
<MessageLoader />
</>
)
}
if (messages.data.length === 0) {
return <ChatStart chatId={chatId} />
}
return messages.data.map((message, index) => {
const sentDate = new Date(Number(message.timestamp))
const previousMessage = messages.data[index - 1]
let hasDateSeparator = true
if (previousMessage) {
const prevSentDate = new Date(Number(previousMessage.timestamp))
if (isSameDay(prevSentDate, sentDate)) {
hasDateSeparator = false
}
}
const shouldCollapse =
!message.responseTo && message.signer === previousMessage?.signer
return (
<Fragment key={message.messageId}>
{hasDateSeparator && <DateDivider date={sentDate} />}
<ChatMessage message={message} collapse={shouldCollapse} />
</Fragment>
)
})
}
return (
<>
<ContentWrapper ref={contentRef}>
<ChatStart chatId={chatId} />
{messages.data.map(message => {
return (
<ChatMessage
key={message.messageId}
message={message}
highlight={message.messageId === selectedMessageId}
/>
)
})}
</ContentWrapper>
<ContentWrapper ref={contentRef}>{renderContent()}</ContentWrapper>
{account && <ChatInput onSubmit={handleMessageSubmit} />}
</>
)
@ -130,7 +166,7 @@ const ContentWrapper = styled('div', {
overscrollBehavior: 'contain',
// scrollSnapType: 'y proximity',
paddingBottom: 16,
// '& > div:last-child': {
// scrollSnapAlign: 'end',
// scrollMarginBlockEnd: '1px',

View File

@ -5,40 +5,46 @@ import { Base } from './styles'
import type { Variants } from './styles'
import type { Ref } from 'react'
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>
interface Props {
children: string
disabled?: boolean
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
active?: boolean
type?: ButtonProps['type']
onClick?: ButtonProps['onClick']
}
type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string
}
type Props = (AnchorProps | ButtonProps) & {
children: string
variant?: Variants['variant']
size?: Variants['size']
disabled?: boolean
}
const Button = (props: Props, ref: Ref<HTMLButtonElement>) => {
const {
type = 'button',
children,
disabled,
loading,
onClick,
variant = 'default',
...buttonProps
} = props
const { children } = props
if ('href' in props) {
const { href, ...linkProps } = props
const external = href.startsWith('http')
return (
<Base
{...linkProps}
as="a"
href={props.href}
{...(external && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
{children}
</Base>
)
}
const { type = 'button', loading, ...buttonProps } = props
return (
<Base
{...buttonProps}
type={type}
ref={ref}
disabled={disabled}
loading={loading}
onClick={onClick}
variant={variant}
>
<Base {...buttonProps} type={type} ref={ref} loading={loading}>
{children}
</Base>
)

View File

@ -81,4 +81,7 @@ export const Base = styled('button', {
},
},
},
defaultVariants: {
variant: 'default',
},
})

View File

@ -1916,6 +1916,24 @@
"@radix-ui/react-roving-focus" "1.0.0"
"@radix-ui/react-use-controllable-state" "1.0.0"
"@radix-ui/react-toast@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-0.1.1.tgz#d544e796b307e56f1298e40f356f468680958e93"
integrity sha512-9JWC4mPP78OE6muDrpaPf/71dIeozppdcnik1IvsjTxZpDnt9PbTtQj94DdWjlCphbv3S5faD3KL0GOpqKBpTQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-context" "0.1.1"
"@radix-ui/react-dismissable-layer" "0.1.5"
"@radix-ui/react-portal" "0.1.4"
"@radix-ui/react-presence" "0.1.2"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-visually-hidden" "0.1.4"
"@radix-ui/react-toggle-group@^0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-0.1.5.tgz#9e4d65e22c4fc0ba3a42fbc8d5496c430e5e9852"
@ -5397,6 +5415,7 @@ netmask@^2.0.2:
node-fetch@^2.x.x:
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.3.1: