Move report modal state to useState

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-09-17 23:03:31 +02:00
parent c7dba3334f
commit 0c7d57ce40
14 changed files with 124 additions and 239 deletions

View file

@ -14,7 +14,7 @@ const reportSchema = z.object({
created_at: dateSchema.optional().catch(undefined),
status_ids: z.array(z.string()).nullable().catch(null),
rule_ids: z.array(z.string()).nullable().catch(null),
target_account: accountSchema,
target_account: accountSchema.nullable().catch(null),
});
type Report = z.infer<typeof reportSchema>;

View file

@ -1,6 +1,6 @@
{
"name": "pl-api",
"version": "0.0.34",
"version": "0.0.35",
"type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/fork/packages/pl-api",
"repository": {

View file

@ -134,7 +134,7 @@
"multiselect-react-dropdown": "^2.0.25",
"object-to-formdata": "^4.5.1",
"path-browserify": "^1.0.1",
"pl-api": "^0.0.34",
"pl-api": "^0.0.35",
"postcss": "^8.4.29",
"process": "^0.11.10",
"punycode": "^2.1.1",

View file

@ -5,20 +5,10 @@ import { getClient } from '../api';
import type { Account, Status } from 'pl-fe/normalizers';
import type { AppDispatch, RootState } from 'pl-fe/store';
const REPORT_INIT = 'REPORT_INIT' as const;
const REPORT_CANCEL = 'REPORT_CANCEL' as const;
const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST' as const;
const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS' as const;
const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL' as const;
const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE' as const;
const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE' as const;
const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE' as const;
const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE' as const;
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE' as const;
enum ReportableEntities {
ACCOUNT = 'ACCOUNT',
STATUS = 'STATUS'
@ -31,36 +21,22 @@ type ReportedEntity = {
const initReport = (entityType: ReportableEntities, account: Pick<Account, 'id'>, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status } = entities || {};
dispatch({
type: REPORT_INIT,
return useModalsStore.getState().openModal('REPORT', {
accountId: account.id,
entityType,
account,
status,
statusIds: status ? [status.id] : [],
});
return useModalsStore.getState().openModal('REPORT');
};
const cancelReport = () => ({
type: REPORT_CANCEL,
});
const toggleStatusReport = (statusId: string, checked: boolean) => ({
type: REPORT_STATUS_TOGGLE,
statusId,
checked,
});
const submitReport = () =>
const submitReport = (accountId: string, statusIds: string[], ruleIds?: string[], comment?: string, forward?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(submitReportRequest());
const { reports } = getState();
return getClient(getState()).accounts.reportAccount(reports.new.account_id!, {
status_ids: reports.new.status_ids.toArray(),
rule_ids: reports.new.rule_ids.toArray(),
comment: reports.new.comment,
forward: reports.new.forward,
return getClient(getState()).accounts.reportAccount(accountId, {
status_ids: statusIds,
rule_ids: ruleIds,
comment: comment,
forward: forward,
});
};
@ -77,47 +53,14 @@ const submitReportFail = (error: unknown) => ({
error,
});
const changeReportComment = (comment: string) => ({
type: REPORT_COMMENT_CHANGE,
comment,
});
const changeReportForward = (forward: boolean) => ({
type: REPORT_FORWARD_CHANGE,
forward,
});
const changeReportBlock = (block: boolean) => ({
type: REPORT_BLOCK_CHANGE,
block,
});
const changeReportRule = (ruleId: string) => ({
type: REPORT_RULE_CHANGE,
rule_id: ruleId,
});
export {
ReportableEntities,
REPORT_INIT,
REPORT_CANCEL,
REPORT_SUBMIT_REQUEST,
REPORT_SUBMIT_SUCCESS,
REPORT_SUBMIT_FAIL,
REPORT_STATUS_TOGGLE,
REPORT_COMMENT_CHANGE,
REPORT_FORWARD_CHANGE,
REPORT_BLOCK_CHANGE,
REPORT_RULE_CHANGE,
initReport,
cancelReport,
toggleStatusReport,
submitReport,
submitReportRequest,
submitReportSuccess,
submitReportFail,
changeReportComment,
changeReportForward,
changeReportBlock,
changeReportRule,
};

View file

@ -2,7 +2,6 @@ import React, { Suspense, lazy } from 'react';
import { cancelReplyCompose } from 'pl-fe/actions/compose';
import { cancelEventCompose } from 'pl-fe/actions/events';
import { cancelReport } from 'pl-fe/actions/reports';
import Base from 'pl-fe/components/modal-root';
import { useAppDispatch } from 'pl-fe/hooks';
import { useModalsStore } from 'pl-fe/stores';
@ -73,9 +72,6 @@ const ModalRoot: React.FC = () => {
case 'COMPOSE_EVENT':
dispatch(cancelEventCompose());
break;
case 'REPORT':
dispatch(cancelReport());
break;
default:
break;
}

View file

@ -1,23 +1,22 @@
import noop from 'lodash/noop';
import React, { Suspense } from 'react';
import { toggleStatusReport } from 'pl-fe/actions/reports';
import StatusContent from 'pl-fe/components/status-content';
import { Stack, Toggle } from 'pl-fe/components/ui';
import { MediaGallery, Video, Audio } from 'pl-fe/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'pl-fe/hooks';
import { useAppSelector } from 'pl-fe/hooks';
interface IStatusCheckBox {
id: string;
disabled?: boolean;
toggleStatusReport: (value: boolean) => void;
checked: boolean;
}
const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
const dispatch = useAppDispatch();
const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled, checked, toggleStatusReport }) => {
const status = useAppSelector((state) => state.statuses.get(id));
const checked = useAppSelector((state) => state.reports.new.status_ids.includes(id));
const onToggle: React.ChangeEventHandler<HTMLInputElement> = (e) => dispatch(toggleStatusReport(id, e.target.checked));
const onToggle: React.ChangeEventHandler<HTMLInputElement> = (e) => toggleStatusReport(e.target.checked);
if (!status || status.reblog_id) {
return null;

View file

@ -64,19 +64,25 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
);
};
const ReportModal = ({ onClose }: BaseModalProps) => {
interface ReportModalProps {
accountId: string;
entityType: ReportableEntities;
statusIds: Array<string>;
}
const ReportModal: React.FC<BaseModalProps & ReportModalProps> = ({ onClose, accountId, entityType, statusIds }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accountId = useAppSelector((state) => state.reports.new.account_id);
const { account } = useAccount(accountId || undefined);
const entityType = useAppSelector((state) => state.reports.new.entityType);
const isBlocked = useAppSelector((state) => state.reports.new.block);
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
const [block, setBlock] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { rules } = useInstance();
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const [ruleIds, setRuleIds] = useState<Array<string>>([]);
const [selectedStatusIds, setSelectedStatusIds] = useState(statusIds);
const [comment, setComment] = useState('');
const [forward, setForward] = useState(false);
const shouldRequireRule = rules.length > 0;
@ -86,17 +92,25 @@ const ReportModal = ({ onClose }: BaseModalProps) => {
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const handleSubmit = () => {
dispatch(submitReport())
.then(() => setCurrentStep(Steps.THREE))
.catch((error) => dispatch(submitReportFail(error)));
setIsSubmitting(true);
if (isBlocked && account) {
dispatch(submitReport(accountId, selectedStatusIds, [...ruleIds], comment, forward))
.then(() => {
setIsSubmitting(false);
setCurrentStep(Steps.THREE);
})
.catch((error) => {
setIsSubmitting(false);
dispatch(submitReportFail(error));
});
if (block && account) {
dispatch(blockAccount(account.id));
}
};
const renderSelectedStatuses = useCallback(() => {
switch (selectedStatusIds.size) {
switch (selectedStatusIds.length) {
case 0:
return (
<div className='flex w-full items-center justify-center rounded-lg bg-gray-100 p-4 dark:bg-gray-800'>
@ -104,9 +118,9 @@ const ReportModal = ({ onClose }: BaseModalProps) => {
</div>
);
default:
return <SelectedStatus statusId={selectedStatusIds.first()} />;
return <SelectedStatus statusId={selectedStatusIds[0]} />;
}
}, [selectedStatusIds.size]);
}, [selectedStatusIds.length]);
const cancelText = useMemo(() => {
switch (currentStep) {
@ -174,8 +188,8 @@ const ReportModal = ({ onClose }: BaseModalProps) => {
return false;
}
return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || (isReportingStatus && selectedStatusIds.size === 0);
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size, isReportingStatus]);
return isSubmitting || (shouldRequireRule && ruleIds.length === 0) || (isReportingStatus && selectedStatusIds.length === 0);
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds.length, selectedStatusIds.length, isReportingStatus]);
const calculateProgress = useCallback(() => {
switch (currentStep) {
@ -219,11 +233,24 @@ const ReportModal = ({ onClose }: BaseModalProps) => {
{(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedEntity()}
{StepToRender && (
<StepToRender account={account} />
<StepToRender
account={account}
selectedStatusIds={selectedStatusIds}
setSelectedStatusIds={setSelectedStatusIds}
block={block}
setBlock={setBlock}
forward={forward}
setForward={setForward}
comment={comment}
setComment={setComment}
ruleIds={ruleIds}
setRuleIds={setRuleIds}
isSubmitting={isSubmitting}
/>
)}
</Stack>
</Modal>
);
};
export { ReportModal as default };
export { ReportModal as default, type ReportModalProps };

View file

@ -2,10 +2,9 @@ import { OrderedSet } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { changeReportBlock, changeReportForward } from 'pl-fe/actions/reports';
import { Button, FormGroup, HStack, Stack, Text, Toggle } from 'pl-fe/components/ui';
import StatusCheckBox from 'pl-fe/features/ui/components/modals/report-modal/components/status-check-box';
import { useAppDispatch, useAppSelector, useFeatures } from 'pl-fe/hooks';
import { useAppSelector, useFeatures } from 'pl-fe/hooks';
import { getDomain } from 'pl-fe/utils/accounts';
import type { Account } from 'pl-fe/normalizers';
@ -20,27 +19,49 @@ const messages = defineMessages({
interface IOtherActionsStep {
account: Pick<Account, 'id' | 'acct' | 'local' | 'url'>;
selectedStatusIds: string[];
setSelectedStatusIds: (value: string[]) => void;
block: boolean;
setBlock: (value: boolean) => void;
forward: boolean;
setForward: (value: boolean) => void;
isSubmitting: boolean;
}
const OtherActionsStep = ({ account }: IOtherActionsStep) => {
const dispatch = useAppDispatch();
const OtherActionsStep = ({
account,
selectedStatusIds,
setSelectedStatusIds,
block,
setBlock,
forward,
setForward,
isSubmitting,
}: IOtherActionsStep) => {
const features = useFeatures();
const intl = useIntl();
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.get(`account:${account.id}:with_replies`)!.items).union(state.reports.new.status_ids) as OrderedSet<string>);
const isBlocked = useAppSelector((state) => state.reports.new.block);
const isForward = useAppSelector((state) => state.reports.new.forward);
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.get(`account:${account.id}:with_replies`)!.items).union(selectedStatusIds) as OrderedSet<string>);
const isBlocked = block;
const isForward = forward;
const canForward = !account.local && features.federating;
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
const [showAdditionalStatuses, setShowAdditionalStatuses] = useState<boolean>(false);
const handleBlockChange = (event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(changeReportBlock(event.target.checked));
setBlock(event.target.checked);
};
const handleForwardChange = (event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(changeReportForward(event.target.checked));
setForward(event.target.checked);
};
const toggleStatusReport = (statusId: string) => (value: boolean) => {
let newStatusIds = selectedStatusIds;
if (value && !selectedStatusIds.includes(statusId)) newStatusIds = [...selectedStatusIds, statusId];
if (!value) newStatusIds = selectedStatusIds.filter(id => id !== statusId);
setSelectedStatusIds(newStatusIds);
};
return (
@ -54,7 +75,14 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => {
{showAdditionalStatuses ? (
<Stack space={2}>
<div className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'>
{statusIds.map((statusId) => <StatusCheckBox id={statusId} key={statusId} />)}
{statusIds.map((statusId) => (
<StatusCheckBox
id={statusId}
key={statusId}
checked={selectedStatusIds.includes(statusId)}
toggleStatusReport={toggleStatusReport(statusId)}
/>
))}
</div>
<div>

View file

@ -2,9 +2,8 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeReportComment, changeReportRule } from 'pl-fe/actions/reports';
import { FormGroup, Stack, Text, Textarea } from 'pl-fe/components/ui';
import { useAppDispatch, useAppSelector, useInstance } from 'pl-fe/hooks';
import { useInstance } from 'pl-fe/hooks';
import type { Account } from 'pl-fe/normalizers';
@ -15,12 +14,15 @@ const messages = defineMessages({
interface IReasonStep {
account?: Account;
comment: string;
setComment: (value: string) => void;
ruleIds: Array<string>;
setRuleIds: (value: Array<string>) => void;
}
const RULES_HEIGHT = 385;
const ReasonStep: React.FC<IReasonStep> = () => {
const dispatch = useAppDispatch();
const ReasonStep: React.FC<IReasonStep> = ({ comment, setComment, ruleIds, setRuleIds }) => {
const intl = useIntl();
const rulesListRef = useRef(null);
@ -28,13 +30,18 @@ const ReasonStep: React.FC<IReasonStep> = () => {
const [isNearBottom, setNearBottom] = useState<boolean>(false);
const [isNearTop, setNearTop] = useState<boolean>(true);
const comment = useAppSelector((state) => state.reports.new.comment);
const { rules } = useInstance();
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const shouldRequireRule = rules.length > 0;
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch(changeReportComment(event.target.value));
setComment(event.target.value);
};
const handleRuleChange = (ruleId: string) => {
let newRuleIds;
if (ruleIds.includes(ruleId)) newRuleIds = ruleIds.filter(id => id !== ruleId);
else newRuleIds = [...ruleIds, ruleId];
setRuleIds(newRuleIds);
};
const handleRulesScrolling = () => {
@ -81,13 +88,13 @@ const ReasonStep: React.FC<IReasonStep> = () => {
ref={rulesListRef}
>
{rules.map((rule, idx) => {
const isSelected = ruleIds.includes(String(rule.id));
const isSelected = ruleIds.includes(rule.id);
return (
<button
key={idx}
data-testid={`rule-${rule.id}`}
onClick={() => dispatch(changeReportRule(rule.id))}
onClick={() => handleRuleChange(rule.id)}
className={clsx({
'relative border border-solid border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-primary-800/30 text-start w-full p-4 flex justify-between items-center cursor-pointer': true,
'rounded-tl-lg rounded-tr-lg': idx === 0,

View file

@ -37,7 +37,6 @@ import polls from './polls';
import profile_hover_card from './profile-hover-card';
import push_notifications from './push-notifications';
import relationships from './relationships';
import reports from './reports';
import scheduled_statuses from './scheduled-statuses';
import search from './search';
import security from './security';
@ -87,7 +86,6 @@ const reducers = {
profile_hover_card,
push_notifications,
relationships,
reports,
scheduled_statuses,
search,
security,

View file

@ -1,20 +0,0 @@
import reducer from './reports';
describe('reports reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {} as any).toJS()).toEqual({
new: {
isSubmitting: false,
account_id: null,
status_ids: [],
chat_message: null,
group: null,
entityType: '',
comment: '',
forward: false,
block: false,
rule_ids: [],
},
});
});
});

View file

@ -1,95 +0,0 @@
import { Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import {
REPORT_INIT,
REPORT_SUBMIT_REQUEST,
REPORT_SUBMIT_SUCCESS,
REPORT_SUBMIT_FAIL,
REPORT_CANCEL,
REPORT_STATUS_TOGGLE,
REPORT_COMMENT_CHANGE,
REPORT_FORWARD_CHANGE,
REPORT_BLOCK_CHANGE,
REPORT_RULE_CHANGE,
ReportableEntities,
} from '../actions/reports';
import type { AnyAction } from 'redux';
const NewReportRecord = ImmutableRecord({
entityType: '' as ReportableEntities,
account_id: null as string | null,
status_ids: ImmutableSet<string>(),
comment: '',
forward: false,
rule_ids: ImmutableSet<string>(),
block: false,
isSubmitting: false,
});
const ReducerRecord = ImmutableRecord({
new: NewReportRecord(),
});
type State = ReturnType<typeof ReducerRecord>;
const reports = (state: State = ReducerRecord(), action: AnyAction) => {
switch (action.type) {
case REPORT_INIT:
return state.withMutations(map => {
map.setIn(['new', 'isSubmitting'], false);
map.setIn(['new', 'account_id'], action.account.id);
map.setIn(['new', 'entityType'], action.entityType);
if (state.new.account_id !== action.account.id) {
map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.reblog_id || action.status.id]) : ImmutableSet());
map.setIn(['new', 'comment'], '');
} else if (action.status) {
map.updateIn(['new', 'status_ids'], set => (set as ImmutableSet<string>).add(action.status.reblog?.id || action.status.id));
}
});
case REPORT_STATUS_TOGGLE:
return state.updateIn(['new', 'status_ids'], set => {
if (action.checked) {
return (set as ImmutableSet<string>).add(action.statusId);
}
return (set as ImmutableSet<string>).remove(action.statusId);
});
case REPORT_COMMENT_CHANGE:
return state.setIn(['new', 'comment'], action.comment);
case REPORT_FORWARD_CHANGE:
return state.setIn(['new', 'forward'], action.forward);
case REPORT_BLOCK_CHANGE:
return state.setIn(['new', 'block'], action.block);
case REPORT_RULE_CHANGE:
return state.updateIn(['new', 'rule_ids'], (set) => {
if ((set as ImmutableSet<string>).includes(action.rule_id)) {
return (set as ImmutableSet<string>).remove(action.rule_id);
}
return (set as ImmutableSet<string>).add(action.rule_id);
});
case REPORT_SUBMIT_REQUEST:
return state.setIn(['new', 'isSubmitting'], true);
case REPORT_SUBMIT_FAIL:
return state.setIn(['new', 'isSubmitting'], false);
case REPORT_CANCEL:
case REPORT_SUBMIT_SUCCESS:
return state.withMutations(map => {
map.setIn(['new', 'account_id'], null);
map.setIn(['new', 'status_ids'], ImmutableSet());
map.setIn(['new', 'comment'], '');
map.setIn(['new', 'isSubmitting'], false);
map.setIn(['new', 'rule_ids'], ImmutableSet());
map.setIn(['new', 'block'], false);
});
default:
return state;
}
};
export { reports as default };

View file

@ -30,6 +30,7 @@ import type { MissingDescriptionModalProps } from 'pl-fe/features/ui/components/
import type { ReactionsModalProps } from 'pl-fe/features/ui/components/modals/reactions-modal';
import type { ReblogsModalProps } from 'pl-fe/features/ui/components/modals/reblogs-modal';
import type { ReplyMentionsModalProps } from 'pl-fe/features/ui/components/modals/reply-mentions-modal';
import type { ReportModalProps } from 'pl-fe/features/ui/components/modals/report-modal';
import type { SelectBookmarkFolderModalProps } from 'pl-fe/features/ui/components/modals/select-bookmark-folder-modal';
import type { TextFieldModalProps } from 'pl-fe/features/ui/components/modals/text-field-modal';
import type { UnauthorizedModalProps } from 'pl-fe/features/ui/components/modals/unauthorized-modal';
@ -37,7 +38,7 @@ import type { VideoModalProps } from 'pl-fe/features/ui/components/modals/video-
type OpenModalProps =
| [type: 'ACCOUNT_MODERATION', props: AccountModerationModalProps]
| [type: 'BIRTHDAYS' | 'COMPOSE_EVENT' | 'CREATE_GROUP' | 'HOTKEYS' | 'REPORT']
| [type: 'BIRTHDAYS' | 'COMPOSE_EVENT' | 'CREATE_GROUP' | 'HOTKEYS']
| [type: 'BOOST', props: BoostModalProps]
| [type: 'COMPARE_HISTORY', props: CompareHistoryModalProps]
| [type: 'COMPONENT', props: ComponentModalProps]
@ -66,6 +67,7 @@ type OpenModalProps =
| [type: 'REACTIONS', props: ReactionsModalProps]
| [type: 'REBLOGS', props: ReblogsModalProps]
| [type: 'REPLY_MENTIONS', props: ReplyMentionsModalProps]
| [type: 'REPORT', props: ReportModalProps]
| [type: 'SELECT_BOOKMARK_FOLDER', props: SelectBookmarkFolderModalProps]
| [type: 'TEXT_FIELD', props: TextFieldModalProps]
| [type: 'UNAUTHORIZED', props?: UnauthorizedModalProps]

View file

@ -8240,10 +8240,10 @@ pkg-types@^1.0.3:
mlly "^1.2.0"
pathe "^1.1.0"
pl-api@^0.0.34:
version "0.0.34"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.34.tgz#76c1c17401c4201802f81f33e77a35f22b964289"
integrity sha512-JMxGcaVUXpd2oxLpw2f1ScYvv3q+P2HETJ1u22FAHHF3c3Gt9uQkHAtCvw2aLMi1tlQKPiSmMK4BDElwdcmAig==
pl-api@^0.0.35:
version "0.0.35"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.35.tgz#333fbe9ab2e2c1ced45b010c17e0720a89a7d3c5"
integrity sha512-/6kuLZIZxRHrjxDSufvqQVT7GDkYxl6/z0AaXN6l0XAx+0CkYvDOyGaQ7jUv91M2pJixfZWYf+9szMQ6h9Q+hQ==
dependencies:
blurhash "^2.0.5"
http-link-header "^1.1.3"