Skip to content

Commit 268913f

Browse files
committed
Making refresh buttons tired if they are exposed to too many clicks in a short time.
1 parent d584e0c commit 268913f

8 files changed

Lines changed: 127 additions & 24 deletions

File tree

src/components/Solutions/SolutionDetail/SolutionDetail.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import ResourceRenderer from '../../helpers/ResourceRenderer';
1717
import SolutionFiles from '../SolutionFiles';
1818
import EvaluationDetail from '../EvaluationDetail';
1919
import CompilationLogs from '../CompilationLogs';
20-
import { RefreshIcon, WarningIcon } from '../../icons';
20+
import { WarningIcon } from '../../icons';
2121
import FailureReport from '../../SubmissionFailures/FailureReport';
22-
import Button from '../../widgets/TheButton';
22+
import RefreshButton from '../../buttons/RefreshButton/RefreshButton';
2323
import Callout from '../../widgets/Callout';
2424

2525
import { isStudentLocked } from '../../helpers/exams.js';
@@ -139,10 +139,7 @@ class SolutionDetail extends Component {
139139
/>
140140
</td>
141141
<td>
142-
<Button onClick={refreshSolutionEvaluations} variant="primary">
143-
<RefreshIcon gapRight={2} />
144-
<FormattedMessage id="generic.refresh" defaultMessage="Refresh" />
145-
</Button>
142+
<RefreshButton onClick={refreshSolutionEvaluations} variant="primary" />
146143
</td>
147144
</tr>
148145
</tbody>

src/components/Users/NotVerifiedEmailCallout/NotVerifiedEmailCallout.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
33
import { FormattedMessage } from 'react-intl';
44

55
import ResendVerificationEmail from '../../../containers/ResendVerificationEmailContainer';
6+
import RefreshButton from '../../buttons/RefreshButton/RefreshButton';
67
import Callout from '../../widgets/Callout';
7-
import Button, { TheButtonGroup } from '../../widgets/TheButton';
8-
import { RefreshIcon } from '../../icons';
8+
import { TheButtonGroup } from '../../widgets/TheButton';
99

1010
const NotVerifiedEmailCallout = ({ userId, refreshUser }) => (
1111
<Callout variant="warning">
@@ -26,10 +26,7 @@ const NotVerifiedEmailCallout = ({ userId, refreshUser }) => (
2626
</p>
2727
<TheButtonGroup className="mb-2">
2828
<ResendVerificationEmail userId={userId} />
29-
<Button variant="outline-secondary" onClick={refreshUser}>
30-
<RefreshIcon gapRight={2} />
31-
<FormattedMessage id="generic.refresh" defaultMessage="Refresh" />
32-
</Button>
29+
<RefreshButton onClick={refreshUser} variant="outline-secondary" />
3330
</TheButtonGroup>
3431
</Callout>
3532
);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import React, { Component } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage } from 'react-intl';
4+
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
5+
6+
import Button from '../../widgets/TheButton';
7+
import Icon, { RefreshIcon } from '../../icons';
8+
import { storageGetItem, storageSetItem, listenForChanges, removeListener } from '../../../helpers/localStorage';
9+
10+
const COUNTER_KEY = 'refreshButtonCounter';
11+
const UPDATE_INTERVAL = 15; // s
12+
const CHANGE_PER_INTERVAL = 15;
13+
const COOL_DOWN_TIME = 60;
14+
const CLICK_LIMIT = 15;
15+
16+
class RefreshButton extends Component {
17+
// >=0 - counter counts number of clicks; <0 - in cool down mode (returning to 0)
18+
state = { counter: 0 };
19+
20+
constructor(props) {
21+
super(props);
22+
this.intervalId = null;
23+
this.storageListenerId = null;
24+
}
25+
26+
componentDidMount() {
27+
this.setState({ counter: Number(storageGetItem(COUNTER_KEY, 0)) });
28+
29+
this.storageListenerId = listenForChanges(COUNTER_KEY, newVal => {
30+
this.setState({ counter: newVal || 0 });
31+
});
32+
33+
// periodic counter update
34+
this.intervalId = window.setInterval(() => {
35+
const newCounter =
36+
this.state.counter < 0
37+
? Math.min(this.state.counter + CHANGE_PER_INTERVAL, 0) // cool down
38+
: Math.max(this.state.counter - CHANGE_PER_INTERVAL, 0); // counting clicks
39+
40+
if (newCounter !== this.state.counter) {
41+
this.setState({ counter: newCounter });
42+
if (newCounter === 0) {
43+
// let's make sure all clients have their buttons enabled again
44+
storageSetItem(COUNTER_KEY, newCounter);
45+
}
46+
}
47+
}, UPDATE_INTERVAL * 1000);
48+
}
49+
50+
componentWillUnmount() {
51+
if (this.intervalId !== null) {
52+
window.clearInterval(this.intervalId);
53+
this.intervalId = null;
54+
}
55+
if (this.storageListenerId !== null) {
56+
removeListener(this.storageListenerId);
57+
this.storageListenerId = null;
58+
}
59+
}
60+
61+
clickHandler = () => {
62+
if (this.state.counter < 0) {
63+
return; // in cool down mode
64+
}
65+
66+
const newCounter = this.state.counter >= CLICK_LIMIT ? -COOL_DOWN_TIME : this.state.counter + 1;
67+
this.setState({ counter: newCounter });
68+
69+
const storageCounter = Number(storageGetItem(COUNTER_KEY, 0));
70+
if (newCounter < 0 || storageCounter < newCounter) {
71+
storageSetItem(COUNTER_KEY, newCounter);
72+
}
73+
74+
this.props.onClick();
75+
};
76+
77+
render() {
78+
const { onClick, ...props } = this.props;
79+
return this.state.counter < 0 ? (
80+
<OverlayTrigger
81+
placement="bottom"
82+
overlay={
83+
<Tooltip id="refreshButtonTooltip">
84+
<FormattedMessage
85+
id="app.refreshButton.coolingDownTooltip"
86+
defaultMessage="The refresh button is too tired from all the refreshing. Please give it some time to recover."
87+
/>
88+
</Tooltip>
89+
}>
90+
<span>
91+
<Button {...props} disabled>
92+
<Icon icon="bed" gapRight={2} />
93+
<FormattedMessage id="generic.refresh" defaultMessage="Refresh" />
94+
</Button>
95+
</span>
96+
</OverlayTrigger>
97+
) : (
98+
<Button {...props} onClick={this.clickHandler}>
99+
<RefreshIcon gapRight={2} />
100+
<FormattedMessage id="generic.refresh" defaultMessage="Refresh" />
101+
</Button>
102+
);
103+
}
104+
}
105+
106+
RefreshButton.propTypes = {
107+
onClick: PropTypes.func.isRequired,
108+
};
109+
110+
export default RefreshButton;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import RefreshButton from './RefreshButton.js';
2+
export default RefreshButton;

src/components/widgets/Comments/CommentThread/CommentThread.js

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
55
import CommentBox from '../CommentBox';
66
import AddComment from '../AddComment';
77
import { UsersComment, SomebodyElsesComment } from '../Comment';
8-
import { RefreshIcon } from '../../../icons';
8+
import RefreshButton from '../../../buttons/RefreshButton/RefreshButton';
99

1010
const CommentThread = ({
1111
title = <FormattedMessage id="app.comments.title" defaultMessage="Comments and Notes" />,
@@ -52,15 +52,7 @@ const CommentThread = ({
5252
)}
5353

5454
<p className="text-center small text-body-secondary">
55-
<a
56-
href="#"
57-
onClick={ev => {
58-
ev.preventDefault();
59-
refresh();
60-
}}>
61-
<RefreshIcon gapRight={2} />
62-
<FormattedMessage id="generic.refresh" defaultMessage="Refresh" />
63-
</a>
55+
<RefreshButton onClick={refresh} variant="outline-primary" size="xs" />
6456
</p>
6557
</div>
6658
</CommentBox>

src/helpers/localStorage.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const localStorageAvailable = () => {
4444
/**
4545
* Wrapper for local storage writer.
4646
* @param {string} key
47-
* @param {*} value value must be serializabe into JSON; if null or undedined is passed, the item is removed
47+
* @param {*} value value must be serializable into JSON; if null or undefined is passed, the item is removed
4848
*/
4949
export const storageSetItem = (key, value) => {
5050
const prefixedKey = `${PERSISTENT_TOKENS_KEY_PREFIX}${key}`;
@@ -93,7 +93,7 @@ const listenKeyMatch = (eventKey, key) =>
9393

9494
/**
9595
* Register callback which is triggered when the value is changed in local storage.
96-
* @param {string|null} key which changes are observerd, null to observe all changes
96+
* @param {string|null} key which changes are observed, null to observe all changes
9797
* @param {Function} callback invoked with every change: (newVal [, oldVal [, key]]) => {}
9898
* @returns {string|null} identifier of the listener, null if no listener was registered
9999
*/
@@ -112,6 +112,7 @@ export const listenForChanges = (key, callback) => {
112112
};
113113
window.addEventListener('storage', listener);
114114
listeners[id] = listener;
115+
return id;
115116
};
116117

117118
/**

src/locales/cs.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@
977977
"app.failureList.noFailures": "V tomto seznamu nejsou žádné neúspěšné odevzdání.",
978978
"app.failureListItem.referenceAssignment": "Referenční úloha",
979979
"app.failureListItem.studentAssignment": "Studentská úloha",
980+
"app.faq.loadError": "Obsah stránky FAQ se nepodařilo načíst. Pravděpodobně se tak stalo kvůli nesprávné konfiguraci aplikace.",
980981
"app.faq.title": "Často kladené dotazy (FAQ)",
981982
"app.fields.limits.memory": "Paměť [KiB]:",
982983
"app.fields.limits.time": "Čas [s]:",
@@ -1543,6 +1544,7 @@
15431544
"app.referenceSolutionTable.evaluationFailed": "Poslední vyhodnocení selhalo",
15441545
"app.referenceSolutionTable.noDescription": "popis nebyl uveden",
15451546
"app.referenceSolutionTable.stillEvaluating": "Řešení se stále vyhodnocuje",
1547+
"app.refreshButton.coolingDownTooltip": "Občerstvovací tlačítko je příliš unavené z veškerého toho občerstvování. Prosíme dejte mu nějaký čas na zotavení.",
15461548
"app.registration.external.gotoSignin": "Stránka přihlášení",
15471549
"app.registration.external.link": "Navštívit stránky podpory",
15481550
"app.registration.external.mail": "Kontaktovat podporu",

src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@
977977
"app.failureList.noFailures": "There are no failures in this list.",
978978
"app.failureListItem.referenceAssignment": "Reference assignment",
979979
"app.failureListItem.studentAssignment": "Student assignment",
980+
"app.faq.loadError": "FAQ content could not be loaded. Possibly due to the misconfiguration of the application.",
980981
"app.faq.title": "Frequently Asked Questions",
981982
"app.fields.limits.memory": "Memory [KiB]:",
982983
"app.fields.limits.time": "Time [s]:",
@@ -1543,6 +1544,7 @@
15431544
"app.referenceSolutionTable.evaluationFailed": "Last evaluation failed",
15441545
"app.referenceSolutionTable.noDescription": "no description given",
15451546
"app.referenceSolutionTable.stillEvaluating": "Last submission is still evaluating",
1547+
"app.refreshButton.coolingDownTooltip": "The refresh button is too tired from all the refreshing. Please give it some time to recover.",
15461548
"app.registration.external.gotoSignin": "Sign-in Page",
15471549
"app.registration.external.link": "Visit Help Page",
15481550
"app.registration.external.mail": "Contact Support",

0 commit comments

Comments
 (0)