├── .gitattributes
├── .gitignore
├── .prettierrc
├── README.md
├── package-lock.json
├── package.json
├── promotional
├── settings.png
└── studies.png
├── src
├── audio
│ ├── sweet-alert-1.wav
│ ├── sweet-alert-2.wav
│ ├── sweet-alert-3.wav
│ ├── sweet-alert-4.wav
│ └── sweet-alert-5.wav
├── components
│ └── App.tsx
├── containers
│ ├── Header.tsx
│ ├── SettingsPane.tsx
│ └── StudiesPane.tsx
├── functions
│ ├── centsToGBP.ts
│ ├── fetchProlificStudies.ts
│ ├── openProlificStudy.ts
│ └── playAlertSound.ts
├── icon.png
├── manifest.json
├── pages
│ ├── background.ts
│ ├── contentScript.js
│ ├── popup.css
│ ├── popup.html
│ └── popup.tsx
├── prolific.d.ts
├── store
│ ├── index.ts
│ ├── prolific
│ │ ├── actions.ts
│ │ ├── reducers.ts
│ │ ├── selectors.ts
│ │ └── types.ts
│ ├── prolificStudiesUpdateMiddleware.ts
│ ├── session
│ │ ├── action.ts
│ │ ├── reducers.ts
│ │ ├── selectors.ts
│ │ └── types.ts
│ ├── settings
│ │ ├── actions.ts
│ │ ├── reducers.ts
│ │ ├── selectors.ts
│ │ └── types.ts
│ └── settingsAlertSoundMiddleware.ts
└── webextension-env.d.ts
└── tsconfig.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "printWidth": 120,
4 | "singleQuote": true,
5 | "trailingComma": "all"
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Available Scripts
2 |
3 | In the project directory, you can run:
4 |
5 | ### `npm run start`
6 |
7 | Compiles the extension into `./build/unpacked` to be loaded into your broswer for development. The extension automatically reloads when you save changes to the code.
8 |
9 | ##### Load into Chrome
10 |
11 | 1. Open Chrome
12 | 2. Go to `chrome://extensions`
13 | 3. Turn on `Developer mode`
14 | 4. Click `Load unpacked`
15 | 5. Select folder `./build/unpacked`
16 |
17 | ##### Load into Firefox
18 |
19 | 1. Open Firefox
20 | 2. Go to `about:debugging`
21 | 3. Click `Load Temporary Add-on...`
22 | 4. Open `./build/unpacked/manifest.json`
23 |
24 | ### `npm run build`
25 |
26 | Compiles the extension and packages them into production ready zips at `./build/{target}-{version}.zip`. These zips can then be uploaded to their respective extension stores.
27 |
28 | ---
29 |
30 | Documentation: https://github.com/kadauchi/webextension-create#webextension-create
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prolific-assistant",
3 | "version": "3.4.0",
4 | "description": "Monitors https://prolific.co/ for new studies.",
5 | "license": "ISC",
6 | "scripts": {
7 | "start": "webextension-scripts start",
8 | "build": "webextension-scripts build"
9 | },
10 | "dependencies": {
11 | "@types/react": "^16.9.2",
12 | "@types/react-dom": "^16.9.0",
13 | "@types/react-redux": "^7.1.2",
14 | "@types/redux-logger": "^3.0.7",
15 | "bootstrap": "^4.3.1",
16 | "immer": "^3.1.3",
17 | "moment": "^2.24.0",
18 | "react": "^16.9.0",
19 | "react-bootstrap": "^1.0.0-beta.11",
20 | "react-dom": "^16.9.0",
21 | "react-redux": "^7.1.0",
22 | "redux": "^4.0.4",
23 | "redux-logger": "^3.0.6",
24 | "redux-persist": "^5.10.0",
25 | "typescript": "^3.6.4",
26 | "webext-redux": "^2.1.4",
27 | "webextension-scripts": "^0.5.4"
28 | },
29 | "devDependencies": {
30 | "prettier": "^1.18.2"
31 | },
32 | "eslintConfig": {
33 | "extends": "eslint-config-webextension"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/promotional/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/promotional/settings.png
--------------------------------------------------------------------------------
/promotional/studies.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/promotional/studies.png
--------------------------------------------------------------------------------
/src/audio/sweet-alert-1.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/audio/sweet-alert-1.wav
--------------------------------------------------------------------------------
/src/audio/sweet-alert-2.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/audio/sweet-alert-2.wav
--------------------------------------------------------------------------------
/src/audio/sweet-alert-3.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/audio/sweet-alert-3.wav
--------------------------------------------------------------------------------
/src/audio/sweet-alert-4.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/audio/sweet-alert-4.wav
--------------------------------------------------------------------------------
/src/audio/sweet-alert-5.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/audio/sweet-alert-5.wav
--------------------------------------------------------------------------------
/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import Nav from 'react-bootstrap/Nav';
3 | import Tab from 'react-bootstrap/Tab';
4 |
5 | import { Header } from '../containers/Header';
6 | import { StudiesPane } from '../containers/StudiesPane';
7 | import { SettingsPane } from '../containers/SettingsPane';
8 |
9 | export function App() {
10 | const [key, setKey] = useState('studies');
11 |
12 | function onSelect(k: string) {
13 | setKey(k);
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/containers/Header.tsx:
--------------------------------------------------------------------------------
1 | import { browser } from 'webextension-scripts/polyfill';
2 |
3 | import React from 'react';
4 | import { useSelector } from 'react-redux';
5 | import moment from 'moment';
6 | import Button from 'react-bootstrap/Button';
7 | import Nav from 'react-bootstrap/Nav';
8 | import Navbar from 'react-bootstrap/Navbar';
9 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
10 | import Tooltip from 'react-bootstrap/Tooltip';
11 |
12 | import { selectSessionLastChecked } from '../store/session/selectors';
13 |
14 | export function Header() {
15 | const last_checked = useSelector(selectSessionLastChecked);
16 |
17 | return (
18 |
19 |
23 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/containers/SettingsPane.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import Form from 'react-bootstrap/Form';
4 | import Tab from 'react-bootstrap/Tab';
5 |
6 | import { selectSettings } from '../store/settings/selectors';
7 | import {
8 | settingAlertSound,
9 | settingAlertVolume,
10 | settingCheckInterval,
11 | settingDesktopNotifications,
12 | } from '../store/settings/actions';
13 |
14 | export function SettingsPane() {
15 | const dispatch = useDispatch();
16 | const settings = useSelector(selectSettings);
17 |
18 | function onChangeAlertSound(event: any) {
19 | dispatch(settingAlertSound(event.target.value));
20 | }
21 |
22 | function onChangeAlertVolume(event: any) {
23 | const value = Number(event.target.value);
24 |
25 | if (0 <= value && value <= 100) {
26 | dispatch(settingAlertVolume(value));
27 | }
28 | }
29 |
30 | function onChangeCheckInterval(event: any) {
31 | const value = Number(event.target.value);
32 |
33 | if (60 <= value) {
34 | dispatch(settingCheckInterval(Number(event.target.value)));
35 | }
36 | }
37 |
38 | function onChangeDesktopNotification(event: any) {
39 | dispatch(settingDesktopNotifications(event.target.checked));
40 | }
41 |
42 | return (
43 |
44 |
45 | Check Interval
46 |
47 |
48 |
49 | Alert Sound
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Alert Volume
62 |
63 |
64 |
65 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/containers/StudiesPane.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import Button from 'react-bootstrap/Button';
4 | import Card from 'react-bootstrap/Card';
5 | import Col from 'react-bootstrap/Col';
6 | import Container from 'react-bootstrap/Container';
7 | import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
8 | import Row from 'react-bootstrap/Row';
9 | import Tab from 'react-bootstrap/Tab';
10 | import Tooltip from 'react-bootstrap/Tooltip';
11 |
12 | import { centsToGBP } from '../functions/centsToGBP';
13 | import { openProlificStudy } from '../functions/openProlificStudy';
14 | import { selectProlificError, selectProlificStudies } from '../store/prolific/selectors';
15 |
16 | export function StudiesPane() {
17 | const error = useSelector(selectProlificError);
18 | const studies = useSelector(selectProlificStudies);
19 |
20 | return (
21 |
22 | {studies.length ? (
23 | studies.map((study) => (
24 | openProlificStudy(study.id)}>
25 |
26 |
27 |
28 |
29 |
38 |
39 |
40 |
41 | {study.name}
42 |
43 |
44 | Hosted by {study.researcher.name}
45 |
46 |
47 |
50 |
51 |
52 |
53 | | Reward: |
54 |
55 | {centsToGBP(study.reward)}
56 | |
57 |
58 |
59 |
60 |
61 | }
62 | >
63 | {centsToGBP(study.reward)}
64 |
65 |
68 |
69 |
70 |
71 | | Estimated completion time: |
72 |
73 | {study.estimated_completion_time} minutes
74 | |
75 |
76 |
77 | | Average completion time: |
78 |
79 | {study.average_completion_time} minutes
80 | |
81 |
82 |
83 | | Maximum allowed time: |
84 |
85 | {study.maximum_allowed_time} minutes
86 | |
87 |
88 |
89 |
90 |
91 | }
92 | >
93 | {study.estimated_completion_time} minutes
94 |
95 |
98 |
99 |
100 |
101 | | Estimated reward per hour: |
102 |
103 | {centsToGBP(study.estimated_reward_per_hour)}/hr
104 | |
105 |
106 |
107 | | Average reward per hour: |
108 |
109 | {centsToGBP(study.average_reward_per_hour)}/hr
110 | |
111 |
112 |
113 |
114 |
115 | }
116 | >
117 | {centsToGBP(study.estimated_reward_per_hour)}/hr
118 |
119 |
122 |
123 |
124 |
125 | | Total available places: |
126 |
127 | {study.total_available_places}
128 | |
129 |
130 |
131 |
132 | | Places taken: |
133 |
134 | {study.places_taken}
135 | |
136 |
137 |
138 |
139 | | Places remaining: |
140 |
141 | {study.total_available_places - study.places_taken}
142 | |
143 |
144 |
145 |
146 |
147 | }
148 | >
149 | {study.total_available_places - study.places_taken} places remaining
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 | ))
158 | ) : (
159 |
160 | {error === 401 ? (
161 |
164 | ) : (
165 | 'No studies available.'
166 | )}
167 |
168 | )}
169 |
170 | );
171 | }
172 |
--------------------------------------------------------------------------------
/src/functions/centsToGBP.ts:
--------------------------------------------------------------------------------
1 | export function centsToGBP(cents: number) {
2 | return new Intl.NumberFormat('en-US', {
3 | style: 'currency',
4 | currency: 'GBP',
5 | }).format(cents * 0.01);
6 | }
7 |
--------------------------------------------------------------------------------
/src/functions/fetchProlificStudies.ts:
--------------------------------------------------------------------------------
1 | export async function fetchProlificStudies(authHeader: any) {
2 | const { name, value } = authHeader
3 | const headers = { [name]: value }
4 | // omit credentials here, since auth is handled via the bearer token
5 | const response = await fetch('https://www.prolific.co/api/v1/studies/?current=1', { credentials: 'omit', headers });
6 | const json: ProlificApiStudies = await response.json();
7 | return json;
8 | }
9 |
--------------------------------------------------------------------------------
/src/functions/openProlificStudy.ts:
--------------------------------------------------------------------------------
1 | import { browser } from 'webextension-scripts/polyfill';
2 |
3 | export function openProlificStudy(id: string) {
4 | browser.tabs.create({ url: `https://app.prolific.co/studies/${id}` });
5 | }
6 |
--------------------------------------------------------------------------------
/src/functions/playAlertSound.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from '../store';
2 |
3 | import sweetAlert1 from '../audio/sweet-alert-1.wav';
4 | import sweetAlert2 from '../audio/sweet-alert-2.wav';
5 | import sweetAlert3 from '../audio/sweet-alert-3.wav';
6 | import sweetAlert4 from '../audio/sweet-alert-4.wav';
7 | import sweetAlert5 from '../audio/sweet-alert-5.wav';
8 |
9 | function playFile(file: any, volume: number) {
10 | const audio = new Audio(file);
11 | audio.volume = volume / 100;
12 | audio.play();
13 | }
14 |
15 | export function playAlertSound(state: AppState) {
16 | switch (state.settings.alert_sound) {
17 | case 'none':
18 | break;
19 | case 'sweet-alert-1':
20 | playFile(sweetAlert1, state.settings.alert_volume);
21 | break;
22 | case 'sweet-alert-2':
23 | playFile(sweetAlert2, state.settings.alert_volume);
24 | break;
25 | case 'sweet-alert-3':
26 | playFile(sweetAlert3, state.settings.alert_volume);
27 | break;
28 | case 'sweet-alert-4':
29 | playFile(sweetAlert4, state.settings.alert_volume);
30 | break;
31 | case 'sweet-alert-5':
32 | playFile(sweetAlert5, state.settings.alert_volume);
33 | break;
34 | case 'voice':
35 | const speech = new SpeechSynthesisUtterance('New studies available on Prolific.');
36 | speech.volume = state.settings.alert_volume / 100;
37 | speechSynthesis.speak(speech);
38 | break;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brandonhellman/prolific-assistant/3d86867d064213d7bc89acfca482cc06de7afb24/src/icon.png
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "Prolific Assistant",
4 | "version": "__package.version__",
5 |
6 | "icons": {
7 | "16": "icon.png",
8 | "48": "icon.png",
9 | "128": "icon.png"
10 | },
11 |
12 | "browser_action": {
13 | "default_title": "Prolific Assistant",
14 | "default_icon": "icon.png",
15 | "default_popup": "pages/popup.html"
16 | },
17 |
18 | "background": {
19 | "scripts": ["pages/background.ts"],
20 | "persistent": true
21 | },
22 |
23 | "content_scripts": [
24 | {
25 | "matches": ["https://app.prolific.co/*"],
26 | "run_at": "document_end",
27 | "js": ["pages/contentScript.js"]
28 | }
29 | ],
30 |
31 | "permissions": ["notifications", "tabs", "webRequest", "webNavigation", "webRequestBlocking", "https://*.prolific.co/*"]
32 | }
33 |
--------------------------------------------------------------------------------
/src/pages/background.ts:
--------------------------------------------------------------------------------
1 | import { browser, WebRequest } from 'webextension-scripts/polyfill';
2 |
3 | import { fetchProlificStudies } from '../functions/fetchProlificStudies';
4 | import { openProlificStudy } from '../functions/openProlificStudy';
5 | import { configureStore } from '../store';
6 | import { prolificStudiesUpdate, prolificErrorUpdate } from '../store/prolific/actions';
7 | import { sessionLastChecked } from '../store/session/action';
8 | import { prolificStudiesUpdateMiddleware } from '../store/prolificStudiesUpdateMiddleware';
9 | import { settingsAlertSoundMiddleware } from '../store/settingsAlertSoundMiddleware';
10 |
11 | const store = configureStore(prolificStudiesUpdateMiddleware, settingsAlertSoundMiddleware);
12 |
13 | let authHeader: WebRequest.HttpHeadersItemType;
14 | let timeout = window.setTimeout(main);
15 |
16 | function updateResults(results: any[]) {
17 | store.dispatch(prolificStudiesUpdate(results));
18 | store.dispatch(sessionLastChecked());
19 | browser.browserAction.setBadgeText({ text: results.length ? results.length.toString() : '' });
20 | }
21 |
22 | async function main() {
23 | clearTimeout(timeout);
24 | const state = store.getState();
25 |
26 | if (authHeader) {
27 | try {
28 | const response = await fetchProlificStudies(authHeader);
29 |
30 | if (response.results) {
31 | updateResults(response.results)
32 | browser.browserAction.setBadgeBackgroundColor({ color: 'red' });
33 | }
34 |
35 | if (response.error) {
36 | if (response.error.status === 401) {
37 | store.dispatch(prolificErrorUpdate(401));
38 | browser.browserAction.setBadgeText({ text: '!' });
39 | browser.browserAction.setBadgeBackgroundColor({ color: 'red' });
40 | } else {
41 | store.dispatch(prolificStudiesUpdate([]));
42 | browser.browserAction.setBadgeText({ text: 'ERR' });
43 | browser.browserAction.setBadgeBackgroundColor({ color: 'black' });
44 | }
45 | }
46 | } catch (error) {
47 | store.dispatch(prolificStudiesUpdate([]));
48 | browser.browserAction.setBadgeText({ text: 'ERR' });
49 | browser.browserAction.setBadgeBackgroundColor({ color: 'black' });
50 | window.console.error('fetchProlificStudies error', error);
51 | }
52 | } else {
53 | store.dispatch(prolificErrorUpdate(401));
54 | }
55 |
56 | timeout = window.setTimeout(main, state.settings.check_interval * 1000);
57 | }
58 |
59 | browser.notifications.onClicked.addListener((notificationId) => {
60 | browser.notifications.clear(notificationId);
61 | openProlificStudy(notificationId);
62 | });
63 |
64 | function handleSignedOut() {
65 | authHeader = null
66 | updateResults([])
67 | store.dispatch(prolificErrorUpdate(401))
68 | }
69 |
70 | // Watch for url changes and handle sign out
71 | browser.webNavigation.onCompleted.addListener(
72 | (details) => {
73 | handleSignedOut();
74 | },
75 | {
76 | url: [{ urlEquals: 'https://www.prolific.co/auth/accounts/login/'}],
77 | },
78 | );
79 |
80 | // Prolific is a single page app, so we need to watch
81 | // the history state for changes too
82 | browser.webNavigation.onHistoryStateUpdated.addListener(
83 | (details) => {
84 | handleSignedOut();
85 | },
86 | {
87 | url: [{ urlEquals: 'https://app.prolific.co/login'}],
88 | },
89 | );
90 |
91 | // Parse and save the Authorization header from any Prolific request.
92 | browser.webRequest.onBeforeSendHeaders.addListener(
93 | (details) => {
94 | const foundAuthHeader = details.requestHeaders.find((header) => header.name === 'Authorization');
95 |
96 |
97 | if (foundAuthHeader) {
98 | if (foundAuthHeader.value === 'Bearer null') {
99 | return
100 | }
101 |
102 | let restart = false;
103 |
104 | if (!authHeader) {
105 | restart = true;
106 | }
107 |
108 | authHeader = foundAuthHeader;
109 |
110 | if (restart) {
111 | main();
112 | }
113 | }
114 |
115 | return {};
116 | },
117 | {
118 | urls: ['https://www.prolific.co/api/*'],
119 | },
120 | ['blocking', 'requestHeaders'],
121 | );
122 |
123 | browser.runtime.onMessage.addListener((message) => {
124 | if (message === 'check_for_studies') {
125 | main();
126 | }
127 | });
128 |
--------------------------------------------------------------------------------
/src/pages/contentScript.js:
--------------------------------------------------------------------------------
1 | // adds class to app body when PA is enabled
2 | (function () {
3 | var app = document.getElementById('app');
4 | if (app) {
5 | app.classList.add('pa-enabled');
6 | }
7 | })();
8 |
--------------------------------------------------------------------------------
/src/pages/popup.css:
--------------------------------------------------------------------------------
1 | .study-card:hover {
2 | background: rgba(0, 123, 255, 0.25);
3 | cursor: pointer;
4 | }
5 |
6 | .split-with-bullets *:not(:first-child)::before {
7 | content: ' • ';
8 | }
9 |
10 | .tooltip-inner {
11 | line-height: 1.1;
12 | max-width: 500px;
13 | }
14 |
15 | .tooltip-table {
16 | color: #f8f9fa !important;
17 | }
18 |
19 | .tooltip-table td {
20 | text-align: right !important;
21 | }
22 |
23 | .tooltip-table td:last-child {
24 | font-weight: bolder;
25 | padding-left: 5px;
26 | }
27 |
28 | #root {
29 | height: 100vh;
30 | width: 100vw;
31 | display: flex;
32 | flex-direction: column;
33 | }
34 |
35 | #root > .tab-content {
36 | flex: 1;
37 | overflow: auto;
38 | }
39 |
--------------------------------------------------------------------------------
/src/pages/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/pages/popup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDom from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { Store } from 'webext-redux';
5 |
6 | import { App } from '../components/App';
7 |
8 | import 'bootstrap/dist/css/bootstrap.css';
9 | import './popup.css';
10 |
11 | const store = new Store();
12 |
13 | store.ready().then(() => {
14 | ReactDom.render(
15 | // @ts-ignore
16 |
17 |
18 | ,
19 | document.getElementById('root'),
20 | );
21 | });
22 |
--------------------------------------------------------------------------------
/src/prolific.d.ts:
--------------------------------------------------------------------------------
1 | interface ProlificStudy {
2 | average_completion_time: number;
3 | average_reward_per_hour: number;
4 | date_created: string;
5 | description: string;
6 | estimated_completion_time: number;
7 | estimated_reward_per_hour: number;
8 | id: string;
9 | is_desktop_compatible: boolean;
10 | is_mobile_compatible: boolean;
11 | is_tablet_compatible: boolean;
12 | maximum_allowed_time: number;
13 | name: string;
14 | places_taken: number;
15 | published_at: string;
16 | researcher: {
17 | id: string;
18 | name: string;
19 | institution?: {
20 | name: string | null;
21 | logo: string | null;
22 | link: string;
23 | };
24 | };
25 | reward: number;
26 | study_type: 'SINGLE';
27 | total_available_places: number;
28 | }
29 |
30 | interface ProlificApiStudies {
31 | error: {
32 | additional_information: '/api/v1/errors/';
33 | detail: string;
34 | error_code: number;
35 | status: number;
36 | title: string;
37 | };
38 | meta?: {
39 | count: number;
40 | };
41 | results?: ProlificStudy[];
42 | }
43 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore, combineReducers, Middleware } from 'redux';
2 | import { createLogger } from 'redux-logger';
3 | import { createMigrate, persistStore, persistReducer } from 'redux-persist';
4 | import storage from 'redux-persist/lib/storage';
5 | import { wrapStore } from 'webext-redux';
6 |
7 | import { prolificReducer } from './prolific/reducers';
8 | import { sessionReducer } from './session/reducers';
9 | import { settingsReducer } from './settings/reducers';
10 |
11 | const logger = createLogger();
12 |
13 | const persistMigrate = {
14 | 2: (state: any) => {
15 | return {
16 | ...state,
17 | settings: {
18 | ...state.settings,
19 | desktop_notifications: true,
20 | },
21 | };
22 | },
23 | };
24 |
25 | const persistConfig = {
26 | key: 'settings',
27 | storage: storage,
28 | migrate: createMigrate(persistMigrate),
29 | whitelist: ['settings'],
30 | version: 2,
31 | };
32 |
33 | const rootReducer = combineReducers({
34 | prolific: prolificReducer,
35 | session: sessionReducer,
36 | settings: settingsReducer,
37 | });
38 |
39 | const persistedReducer = persistReducer(persistConfig, rootReducer);
40 |
41 | export type AppState = ReturnType;
42 |
43 | export function configureStore(...middlewares: Middleware[]) {
44 | const store = createStore(persistedReducer, applyMiddleware(...middlewares, logger));
45 | persistStore(store);
46 | wrapStore(store);
47 | return store;
48 | }
49 |
--------------------------------------------------------------------------------
/src/store/prolific/actions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ProlificErrorUpdateAction,
3 | ProlificStudiesUpdateAction,
4 | PROLIFIC_ERROR_UPDATE,
5 | PROLIFIC_STUDIES_UPDATE,
6 | } from './types';
7 |
8 | export function prolificErrorUpdate(payload: ProlificErrorUpdateAction['payload']): ProlificErrorUpdateAction {
9 | return {
10 | type: PROLIFIC_ERROR_UPDATE,
11 | payload,
12 | };
13 | }
14 |
15 | export function prolificStudiesUpdate(payload: ProlificStudiesUpdateAction['payload']): ProlificStudiesUpdateAction {
16 | return {
17 | type: PROLIFIC_STUDIES_UPDATE,
18 | payload,
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/store/prolific/reducers.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 |
3 | import { ProlificState, ProlificActionTypes, PROLIFIC_ERROR_UPDATE, PROLIFIC_STUDIES_UPDATE } from './types';
4 |
5 | const initialState: ProlificState = {
6 | error: undefined,
7 | studies: [],
8 | };
9 |
10 | export function prolificReducer(state = initialState, action: ProlificActionTypes) {
11 | return produce(state, (draftState) => {
12 | switch (action.type) {
13 | case PROLIFIC_ERROR_UPDATE:
14 | draftState.error = action.payload;
15 | draftState.studies = [];
16 | break;
17 | case PROLIFIC_STUDIES_UPDATE:
18 | draftState.error = undefined;
19 | draftState.studies = action.payload;
20 | break;
21 | }
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/src/store/prolific/selectors.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from '..';
2 |
3 | export function selectProlific(state: AppState) {
4 | return state.prolific;
5 | }
6 |
7 | export function selectProlificError(state: AppState) {
8 | return selectProlific(state).error;
9 | }
10 |
11 | export function selectProlificStudies(state: AppState) {
12 | return selectProlific(state).studies;
13 | }
14 |
--------------------------------------------------------------------------------
/src/store/prolific/types.ts:
--------------------------------------------------------------------------------
1 | export interface ProlificState {
2 | error: number;
3 | studies: ProlificStudy[];
4 | }
5 |
6 | export const PROLIFIC_ERROR_UPDATE = 'PROLIFIC_ERROR_UPDATE';
7 | export const PROLIFIC_STUDIES_UPDATE = 'PROLIFIC_STUDIES_UPDATE';
8 |
9 | export interface ProlificErrorUpdateAction {
10 | type: typeof PROLIFIC_ERROR_UPDATE;
11 | payload: number;
12 | }
13 |
14 | export interface ProlificStudiesUpdateAction {
15 | type: typeof PROLIFIC_STUDIES_UPDATE;
16 | payload: ProlificStudy[];
17 | }
18 |
19 | export type ProlificActionTypes = ProlificErrorUpdateAction | ProlificStudiesUpdateAction;
20 |
--------------------------------------------------------------------------------
/src/store/prolificStudiesUpdateMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { browser } from 'webextension-scripts/polyfill';
2 | import { Middleware } from 'redux';
3 |
4 | import { centsToGBP } from '../functions/centsToGBP';
5 | import { playAlertSound } from '../functions/playAlertSound';
6 |
7 | import { AppState } from '.';
8 | import { PROLIFIC_STUDIES_UPDATE } from './prolific/types';
9 |
10 | const seen: ProlificStudy['id'][] = [];
11 |
12 | export const prolificStudiesUpdateMiddleware: Middleware = (store) => (next) => (action) => {
13 | const result = next(action);
14 |
15 | if (action.type === PROLIFIC_STUDIES_UPDATE) {
16 | const state: AppState = store.getState();
17 | const studies: ProlificStudy[] = action.payload;
18 |
19 | const newStudies = studies.reduce((acc: ProlificStudy[], study) => {
20 | if (!seen.includes(study.id)) {
21 | seen.push(study.id);
22 |
23 | if (state.settings.desktop_notifications) {
24 | browser.notifications.create(study.id, {
25 | type: 'list',
26 | title: study.name,
27 | message: '',
28 | iconUrl: 'icon.png',
29 | items: [
30 | {
31 | title: 'Hosted By',
32 | message: study.researcher.name,
33 | },
34 | {
35 | title: 'Reward',
36 | message: `${centsToGBP(study.reward)} | Avg. ${centsToGBP(study.average_reward_per_hour)}`,
37 | },
38 | {
39 | title: 'Places',
40 | message: `${study.total_available_places - study.places_taken}`,
41 | },
42 | ],
43 | });
44 | }
45 |
46 | return [...acc, study];
47 | }
48 |
49 | return acc;
50 | }, []);
51 |
52 | if (newStudies.length) {
53 | playAlertSound(state);
54 | }
55 | }
56 |
57 | return result;
58 | };
59 |
--------------------------------------------------------------------------------
/src/store/session/action.ts:
--------------------------------------------------------------------------------
1 | import { SessionLastCheckedAction, SESSION_LAST_CHECKED } from './types';
2 |
3 | export function sessionLastChecked(): SessionLastCheckedAction {
4 | return {
5 | type: SESSION_LAST_CHECKED,
6 | payload: Date.now(),
7 | };
8 | }
9 |
--------------------------------------------------------------------------------
/src/store/session/reducers.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 |
3 | import { SessionState, SessionActionTypes, SESSION_LAST_CHECKED } from './types';
4 |
5 | const initialState: SessionState = {
6 | last_checked: 0,
7 | };
8 |
9 | export function sessionReducer(state = initialState, action: SessionActionTypes) {
10 | return produce(state, (draftState) => {
11 | switch (action.type) {
12 | case SESSION_LAST_CHECKED:
13 | draftState.last_checked = action.payload;
14 | break;
15 | }
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/src/store/session/selectors.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from '..';
2 |
3 | export function selectSession(state: AppState) {
4 | return state.session;
5 | }
6 |
7 | export function selectSessionLastChecked(state: AppState) {
8 | return selectSession(state).last_checked;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/session/types.ts:
--------------------------------------------------------------------------------
1 | export interface SessionState {
2 | last_checked: number;
3 | }
4 |
5 | export const SESSION_LAST_CHECKED = 'SESSION_LAST_CHECKED';
6 |
7 | export interface SessionLastCheckedAction {
8 | type: typeof SESSION_LAST_CHECKED;
9 | payload: SessionState['last_checked'];
10 | }
11 |
12 | export type SessionActionTypes = SessionLastCheckedAction;
13 |
--------------------------------------------------------------------------------
/src/store/settings/actions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SettingAlertSoundAction,
3 | SettingAlertVolumeAction,
4 | SettingCheckIntervalAction,
5 | SettingsDesktopNotificationAction,
6 | SETTING_ALERT_SOUND,
7 | SETTING_ALERT_VOLUME,
8 | SETTING_CHECK_INTERVAL,
9 | SETTING_DESKTOP_NOTIFICATIONS,
10 | } from './types';
11 |
12 | export function settingAlertSound(payload: SettingAlertSoundAction['payload']): SettingAlertSoundAction {
13 | return {
14 | type: SETTING_ALERT_SOUND,
15 | payload,
16 | };
17 | }
18 |
19 | export function settingAlertVolume(payload: SettingAlertVolumeAction['payload']): SettingAlertVolumeAction {
20 | return {
21 | type: SETTING_ALERT_VOLUME,
22 | payload,
23 | };
24 | }
25 |
26 | export function settingCheckInterval(payload: SettingCheckIntervalAction['payload']): SettingCheckIntervalAction {
27 | return {
28 | type: SETTING_CHECK_INTERVAL,
29 | payload,
30 | };
31 | }
32 |
33 | export function settingDesktopNotifications(
34 | payload: SettingsDesktopNotificationAction['payload'],
35 | ): SettingsDesktopNotificationAction {
36 | return {
37 | type: SETTING_DESKTOP_NOTIFICATIONS,
38 | payload,
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/src/store/settings/reducers.ts:
--------------------------------------------------------------------------------
1 | import produce from 'immer';
2 |
3 | import {
4 | SettingsState,
5 | SettingsActionTypes,
6 | SETTING_ALERT_SOUND,
7 | SETTING_ALERT_VOLUME,
8 | SETTING_CHECK_INTERVAL,
9 | SETTING_DESKTOP_NOTIFICATIONS,
10 | } from './types';
11 |
12 | const initialState: SettingsState = {
13 | alert_sound: 'voice',
14 | alert_volume: 100,
15 | check_interval: 60,
16 | desktop_notifications: true,
17 | };
18 |
19 | export function settingsReducer(state = initialState, action: SettingsActionTypes) {
20 | return produce(state, (draftState) => {
21 | switch (action.type) {
22 | case SETTING_ALERT_SOUND:
23 | draftState.alert_sound = action.payload;
24 | break;
25 | case SETTING_ALERT_VOLUME:
26 | draftState.alert_volume = action.payload;
27 | break;
28 | case SETTING_CHECK_INTERVAL:
29 | draftState.check_interval = action.payload;
30 | break;
31 | case SETTING_DESKTOP_NOTIFICATIONS:
32 | draftState.desktop_notifications = action.payload;
33 | break;
34 | }
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/src/store/settings/selectors.ts:
--------------------------------------------------------------------------------
1 | import { AppState } from '..';
2 |
3 | export function selectSettings(state: AppState) {
4 | return state.settings;
5 | }
6 |
--------------------------------------------------------------------------------
/src/store/settings/types.ts:
--------------------------------------------------------------------------------
1 | export interface SettingsState {
2 | alert_sound:
3 | | 'none'
4 | | 'voice'
5 | | 'sweet-alert-1'
6 | | 'sweet-alert-2'
7 | | 'sweet-alert-3'
8 | | 'sweet-alert-4'
9 | | 'sweet-alert-5';
10 | alert_volume: number;
11 | check_interval: number;
12 | desktop_notifications: boolean;
13 | }
14 |
15 | export const SETTING_ALERT_SOUND = 'SETTING_ALERT_SOUND';
16 | export const SETTING_ALERT_VOLUME = 'SETTING_ALERT_VOLUME';
17 | export const SETTING_CHECK_INTERVAL = 'SETTING_CHECK_INTERVAL';
18 | export const SETTING_DESKTOP_NOTIFICATIONS = 'SETTING_DESKTOP_NOTIFICATIONS';
19 |
20 | export interface SettingAlertSoundAction {
21 | type: typeof SETTING_ALERT_SOUND;
22 | payload: SettingsState['alert_sound'];
23 | }
24 |
25 | export interface SettingAlertVolumeAction {
26 | type: typeof SETTING_ALERT_VOLUME;
27 | payload: SettingsState['alert_volume'];
28 | }
29 |
30 | export interface SettingCheckIntervalAction {
31 | type: typeof SETTING_CHECK_INTERVAL;
32 | payload: SettingsState['check_interval'];
33 | }
34 |
35 | export interface SettingsDesktopNotificationAction {
36 | type: typeof SETTING_DESKTOP_NOTIFICATIONS;
37 | payload: SettingsState['desktop_notifications'];
38 | }
39 |
40 | export type SettingsActionTypes =
41 | | SettingAlertSoundAction
42 | | SettingAlertVolumeAction
43 | | SettingCheckIntervalAction
44 | | SettingsDesktopNotificationAction;
45 |
--------------------------------------------------------------------------------
/src/store/settingsAlertSoundMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { Middleware } from 'redux';
2 |
3 | import { playAlertSound } from '../functions/playAlertSound';
4 |
5 | import { AppState } from '.';
6 | import { SETTING_ALERT_SOUND } from './settings/types';
7 |
8 | export const settingsAlertSoundMiddleware: Middleware = (store) => (next) => (action) => {
9 | const result = next(action);
10 |
11 | if (action.type === SETTING_ALERT_SOUND) {
12 | const state: AppState = store.getState();
13 | playAlertSound(state);
14 | }
15 |
16 | return result;
17 | };
18 |
--------------------------------------------------------------------------------
/src/webextension-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "sourceMap": true,
5 | "noImplicitAny": true,
6 | "module": "commonjs",
7 | "target": "es6",
8 | "jsx": "react",
9 | "lib": ["es2017", "dom"]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------