BOD-14 Added virtualization for messages with known bug. Added messages geting marked as read.

This commit is contained in:
Patrick Fic
2020-04-30 17:37:34 -07:00
parent bf42655186
commit c98e0b33fd
17 changed files with 284 additions and 93 deletions

View File

@@ -10168,6 +10168,32 @@
<folder_node> <folder_node>
<name>messaging</name> <name>messaging</name>
<children> <children>
<folder_node>
<name>actions</name>
<children>
<concept_node>
<name>link</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node> <folder_node>
<name>labels</name> <name>labels</name>
<children> <children>

View File

@@ -43,6 +43,7 @@
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.1", "react-scripts": "3.4.1",
"react-trello": "^2.2.5", "react-trello": "^2.2.5",
"react-virtualized": "^9.21.2",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-saga": "^1.1.3", "redux-saga": "^1.1.3",

View File

@@ -1,15 +1,37 @@
import React from "react"; import React from "react";
import { Tag } from "antd"; import { Tag } from "antd";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMutation } from "@apollo/react-hooks";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
export default function ChatConversationTitleTags({ jobConversations }) { export default function ChatConversationTitleTags({ jobConversations }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const handleRemoveTag = (jobId) => {
const convId = jobConversations[0].conversationid;
if (!!convId) {
removeJobConversation({
variables: {
conversationId: convId,
jobId: jobId,
},
});
}
};
return ( return (
<div> <div>
{jobConversations.map((item) => ( {jobConversations.map((item) => (
<Link to={`/manage/jobs/${item.job.id}`}> <Tag
<Tag color='blue' style={{ cursor: "pointer" }}> key={item.job.id}
closable
color='blue'
style={{ cursor: "pointer" }}
onClose={() => handleRemoveTag(item.job.id)}>
<Link to={`/manage/jobs/${item.job.id}`}>
{item.job.ro_number || "?"} {item.job.ro_number || "?"}
</Tag> </Link>
</Link> </Tag>
))} ))}
</div> </div>
); );

View File

@@ -6,7 +6,11 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import ChatConversationTitle from "../chat-conversation-title/chat-conversation-title.component"; import ChatConversationTitle from "../chat-conversation-title/chat-conversation-title.component";
export default function ChatConversationComponent({ subState, conversation }) { export default function ChatConversationComponent({
subState,
conversation,
handleMarkConversationAsRead,
}) {
const [loading, error] = subState; const [loading, error] = subState;
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
@@ -20,12 +24,13 @@ export default function ChatConversationComponent({ subState, conversation }) {
conversation.messages_aggregate.aggregate.count) || conversation.messages_aggregate.aggregate.count) ||
0; 0;
const messages = const messages = (conversation && conversation.messages) || [];
(conversation && conversation.messages) ||
[];
return ( return (
<div className='chat-conversation'> <div
className='chat-conversation'
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}>
<Badge count={unreadCount}> <Badge count={unreadCount}>
<Card size='small'> <Card size='small'>
<ChatConversationTitle conversation={conversation} /> <ChatConversationTitle conversation={conversation} />

View File

@@ -1,11 +1,11 @@
import { useSubscription } from "@apollo/react-hooks"; import { useMutation, useSubscription } from "@apollo/react-hooks";
import React from "react"; import React from "react";
import { CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import ChatConversationComponent from "./chat-conversation.component";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
}); });
@@ -13,10 +13,6 @@ const mapStateToProps = createStructuredSelector({
export default connect(mapStateToProps, null)(ChatConversationContainer); export default connect(mapStateToProps, null)(ChatConversationContainer);
export function ChatConversationContainer({ selectedConversation }) { export function ChatConversationContainer({ selectedConversation }) {
console.log(
"ChatConversationContainer -> selectedConversation",
selectedConversation
);
const { loading, error, data } = useSubscription( const { loading, error, data } = useSubscription(
CONVERSATION_SUBSCRIPTION_BY_PK, CONVERSATION_SUBSCRIPTION_BY_PK,
{ {
@@ -24,12 +20,33 @@ export function ChatConversationContainer({ selectedConversation }) {
} }
); );
const [markConversationRead] = useMutation(
MARK_MESSAGES_AS_READ_BY_CONVERSATION,
{
variables: { conversationId: selectedConversation },
}
);
const unreadCount =
(data &&
data.conversations_by_pk &&
data.conversations_by_pk &&
data.conversations_by_pk.messages_aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate &&
data.conversations_by_pk.messages_aggregate.aggregate.count) ||
0;
const handleMarkConversationAsRead = () => {
if (unreadCount > 0 && !!selectedConversation) {
markConversationRead();
}
};
return ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[loading, error]} subState={[loading, error]}
conversation={data ? data.conversations_by_pk : {}} conversation={data ? data.conversations_by_pk : {}}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/> />
); );
} }

View File

@@ -1,44 +1,68 @@
import { CheckCircleOutlined, CheckOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, CheckOutlined } from "@ant-design/icons";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import "./chat-message-list.styles.scss"; import "./chat-message-list.styles.scss";
import { List, CellMeasurer, CellMeasurerCache } from "react-virtualized";
export default function ChatMessageListComponent({ messages }) { export default function ChatMessageListComponent({ messages }) {
const messagesEndRef = useRef(null); const virtualizedListRef = useRef(null);
const _cache = new CellMeasurerCache({
fixedWidth: true,
minHeight: 20,
});
const scrollToBottom = () => { const scrollToBottom = () => {
!!messagesEndRef.current && console.log("SCrolling to", messages.length);
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); !!virtualizedListRef.current &&
virtualizedListRef.current.scrollToRow(messages.length - 1);
//TODO Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
}; };
useEffect(scrollToBottom, [messages]); useEffect(scrollToBottom, [messages]);
const StatusRender = (status) => {
switch (status) { const _rowRenderer = ({ index, key, parent, style }) => {
case "sent": return (
return <CheckOutlined style={{ margin: "2px", float: "right" }} />; <CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
case "delivered": {({ measure, registerChild }) => (
return ( <li
<CheckCircleOutlined style={{ margin: "2px", float: "right" }} /> ref={registerChild}
); style={style}
default: className={`${messages[index].isoutbound ? "replies" : "sent"}`}>
return null; <p onLoad={measure}>
} {messages[index].text}
{StatusRender(messages[index].status)}
</p>
</li>
)}
</CellMeasurer>
);
}; };
return ( return (
<div className='messages'> <div className='messages'>
<ul> <ul>
{messages.map((item) => ( <List
<li ref={virtualizedListRef}
key={item.id} width={300}
className={`${item.isoutbound ? "replies" : "sent"}`}> height={300}
<p> rowHeight={_cache.rowHeight}
{item.text} rowRenderer={_rowRenderer}
{StatusRender(item.status)} rowCount={messages.length}
</p> />
</li>
))}
<li ref={messagesEndRef} />
</ul> </ul>
</div> </div>
); );
} }
const StatusRender = (status) => {
switch (status) {
case "sent":
return <CheckOutlined style={{ margin: "2px", float: "right" }} />;
case "delivered":
return <CheckCircleOutlined style={{ margin: "2px", float: "right" }} />;
default:
return null;
}
};

View File

@@ -1,22 +1,22 @@
.messages { // .messages {
height: 350px; // height: 300px;
min-height: calc(100% - 10px); // min-height: calc(100% - 10px);
max-height: calc(100% - 93px); // max-height: calc(100% - 93px);
overflow-y: scroll; // overflow-y: scroll;
overflow-x: hidden; // overflow-x: hidden;
} // }
@media screen and (max-width: 735px) { // @media screen and (max-width: 735px) {
.messages { // .messages {
max-height: calc(100% - 105px); // max-height: calc(100% - 105px);
} // }
} // }
.messages::-webkit-scrollbar { // .messages::-webkit-scrollbar {
width: 8px; // width: 8px;
background: transparent; // background: transparent;
} // }
.messages::-webkit-scrollbar-thumb { // .messages::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.3); // background-color: rgba(0, 0, 0, 0.3);
} // }
.messages ul li { .messages ul li {
display: inline-block; display: inline-block;
clear: both; clear: both;

View File

@@ -1,13 +1,18 @@
import React from "react"; import React from "react";
import { AutoComplete } from "antd"; import { AutoComplete } from "antd";
import { LoadingOutlined } from "@ant-design/icons"; import { LoadingOutlined, CloseCircleOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
export default function ChatTagRoComponent({ export default function ChatTagRoComponent({
searchQueryState, searchQueryState,
roOptions, roOptions,
loading, loading,
executeSearch, executeSearch,
handleInsertTag, handleInsertTag,
setVisible,
}) { }) {
const { t } = useTranslation();
const setSearchQuery = searchQueryState[1]; const setSearchQuery = searchQueryState[1];
const handleSearchQuery = (value) => { const handleSearchQuery = (value) => {
setSearchQuery(value); setSearchQuery(value);
@@ -20,19 +25,26 @@ export default function ChatTagRoComponent({
}; };
return ( return (
<AutoComplete <span>
suffixIcon={loading ? <LoadingOutlined /> : null} <AutoComplete
style={{ width: 200 }} style={{ width: 100 }}
onSearch={handleSearchQuery} onSearch={handleSearchQuery}
onSelect={handleInsertTag} onSelect={handleInsertTag}
onKeyDown={handleKeyDown}> placeholder={t("general.labels.search")}
{roOptions.map((item, idx) => ( onKeyDown={handleKeyDown}>
<AutoComplete.Option key={item.id || idx}> {roOptions.map((item, idx) => (
{` ${item.ro_number || ""} | ${item.ownr_fn || ""} ${ <AutoComplete.Option key={item.id || idx}>
item.ownr_ln || "" {` ${item.ro_number || ""} | ${item.ownr_fn || ""} ${
}`} item.ownr_ln || ""
</AutoComplete.Option> }`}
))} </AutoComplete.Option>
</AutoComplete> ))}
</AutoComplete>
{loading ? (
<LoadingOutlined />
) : (
<CloseCircleOutlined onClick={() => setVisible(false)} />
)}
</span>
); );
} }

View File

@@ -3,9 +3,14 @@ import ChatTagRo from "./chat-tag-ro.component";
import { useLazyQuery, useMutation } from "@apollo/react-hooks"; import { useLazyQuery, useMutation } from "@apollo/react-hooks";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries"; import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries"; import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { Tag } from "antd";
import { useTranslation } from "react-i18next";
import { PlusOutlined } from "@ant-design/icons";
export default function ChatTagRoContainer({ conversation }) { export default function ChatTagRoContainer({ conversation }) {
console.log("ChatTagRoContainer -> conversation", conversation); console.log("ChatTagRoContainer -> conversation", conversation);
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const searchQueryState = useState(""); const searchQueryState = useState("");
const searchText = searchQueryState[0]; const searchText = searchQueryState[0];
@@ -28,19 +33,33 @@ export default function ChatTagRoContainer({ conversation }) {
}); });
const handleInsertTag = (value, option) => { const handleInsertTag = (value, option) => {
console.log("value, option", value, option);
insertTag({ variables: { jobId: option.key } }); insertTag({ variables: { jobId: option.key } });
setVisible(false);
}; };
const existingJobTags = conversation.job_conversations.map((i) => i.jobid);
const roOptions = data
? data.jobs.filter((job) => !existingJobTags.includes(job.id))
: [];
return ( return (
<div> <div>
<ChatTagRo {visible ? (
loading={loading} <ChatTagRo
searchQueryState={searchQueryState} loading={loading}
roOptions={data ? data.jobs : []} searchQueryState={searchQueryState}
executeSearch={executeSearch} roOptions={roOptions}
handleInsertTag={handleInsertTag} executeSearch={executeSearch}
/> handleInsertTag={handleInsertTag}
setVisible={setVisible}
/>
) : (
<Tag onClick={() => setVisible(true)}>
<PlusOutlined />
{t("messaging.actions.link")}
</Tag>
)}
</div> </div>
); );
} }

View File

@@ -19,7 +19,7 @@ export const CONVERSATION_LIST_SUBSCRIPTION = gql`
export const CONVERSATION_SUBSCRIPTION_BY_PK = gql` export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) { subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) {
conversations_by_pk(id: $conversationId) { conversations_by_pk(id: $conversationId) {
messages { messages(order_by: { created_at: asc_nulls_first }) {
id id
status status
text text

View File

@@ -12,3 +12,18 @@ export const INSERT_CONVERSATION_TAG = gql`
} }
} }
`; `;
export const REMOVE_CONVERSATION_TAG = gql`
mutation REMOVE_CONVERSATION_TAG($conversationId: uuid!, $jobId: uuid!) {
delete_job_conversations(
where: {
_and: {
jobid: { _eq: $jobId }
conversationid: { _eq: $conversationId }
}
}
) {
affected_rows
}
}
`;

View File

@@ -0,0 +1,14 @@
import { gql } from "apollo-boost";
export const MARK_MESSAGES_AS_READ_BY_CONVERSATION = gql`
mutation MARK_MESSAGES_AS_READ_BY_CONVERSATION($conversationId: uuid) {
update_messages(
where: { conversationid: { _eq: $conversationId } }
_set: { read: true }
) {
returning {
id
}
}
}
`;

View File

@@ -10,8 +10,8 @@ import applicationReducer from "./application/application.reducer";
const persistConfig = { const persistConfig = {
key: "root", key: "root",
storage, storage,
//whitelist: ["user"] whitelist: ["messaging"],
blacklist: ["user", "email", "messaging", "modals"] blacklist: ["user", "email", "modals"],
}; };
const rootReducer = combineReducers({ const rootReducer = combineReducers({
@@ -19,7 +19,7 @@ const rootReducer = combineReducers({
messaging: messagingReducer, messaging: messagingReducer,
email: emailReducer, email: emailReducer,
modals: modalsReducer, modals: modalsReducer,
application: applicationReducer application: applicationReducer,
}); });
export default persistReducer(persistConfig, rootReducer); export default persistReducer(persistConfig, rootReducer);

View File

@@ -641,6 +641,9 @@
} }
}, },
"messaging": { "messaging": {
"actions": {
"link": "Link to Job"
},
"labels": { "labels": {
"messaging": "Messaging", "messaging": "Messaging",
"typeamessage": "Send a message..." "typeamessage": "Send a message..."

View File

@@ -641,6 +641,9 @@
} }
}, },
"messaging": { "messaging": {
"actions": {
"link": ""
},
"labels": { "labels": {
"messaging": "Mensajería", "messaging": "Mensajería",
"typeamessage": "Enviar un mensaje..." "typeamessage": "Enviar un mensaje..."

View File

@@ -641,6 +641,9 @@
} }
}, },
"messaging": { "messaging": {
"actions": {
"link": ""
},
"labels": { "labels": {
"messaging": "Messagerie", "messaging": "Messagerie",
"typeamessage": "Envoyer un message..." "typeamessage": "Envoyer un message..."

View File

@@ -1294,6 +1294,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.8.7":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.4.0", "@babel/template@^7.8.3", "@babel/template@^7.8.6": "@babel/template@^7.4.0", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6" version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@@ -3978,7 +3985,7 @@ clone-deep@^4.0.1:
kind-of "^6.0.2" kind-of "^6.0.2"
shallow-clone "^3.0.0" shallow-clone "^3.0.0"
clsx@^1.0.4: clsx@^1.0.1, clsx@^1.0.4:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702"
integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA== integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==
@@ -4964,6 +4971,14 @@ dom-helpers@^3.4.0:
dependencies: dependencies:
"@babel/runtime" "^7.1.2" "@babel/runtime" "^7.1.2"
dom-helpers@^5.0.0:
version "5.1.4"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b"
integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^2.6.7"
dom-helpers@^5.1.0: dom-helpers@^5.1.0:
version "5.1.3" version "5.1.3"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.3.tgz#7233248eb3a2d1f74aafca31e52c5299cc8ce821" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.3.tgz#7233248eb3a2d1f74aafca31e52c5299cc8ce821"
@@ -8205,7 +8220,7 @@ long@^4.0.0:
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -11281,6 +11296,18 @@ react-trello@^2.2.5:
trello-smooth-dnd "1.0.0" trello-smooth-dnd "1.0.0"
uuid "^3.3.2" uuid "^3.3.2"
react-virtualized@^9.21.2:
version "9.21.2"
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.2.tgz#02e6df65c1e020c8dbf574ec4ce971652afca84e"
integrity sha512-oX7I7KYiUM7lVXQzmhtF4Xg/4UA5duSA+/ZcAvdWlTLFCoFYq1SbauJT5gZK9cZS/wdYR6TPGpX/dqzvTqQeBA==
dependencies:
babel-runtime "^6.26.0"
clsx "^1.0.1"
dom-helpers "^5.0.0"
loose-envify "^1.3.0"
prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4"
"react@>= 16.3", react@^16.13.1: "react@>= 16.3", react@^16.13.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"