├── .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 | logo 38 | 39 | 40 |
41 | {study.name} 42 |
43 |
44 | Hosted by {study.researcher.name} 45 |
46 |
47 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | 59 |
Reward: 55 | {centsToGBP(study.reward)} 56 |
60 | 61 | } 62 | > 63 | {centsToGBP(study.reward)} 64 |
65 | 68 | 69 | 70 | 71 | 72 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 87 | 88 | 89 |
Estimated completion time: 73 | {study.estimated_completion_time} minutes 74 |
Average completion time: 79 | {study.average_completion_time} minutes 80 |
Maximum allowed time: 85 | {study.maximum_allowed_time} minutes 86 |
90 | 91 | } 92 | > 93 | {study.estimated_completion_time} minutes 94 |
95 | 98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 | 108 | 111 | 112 | 113 |
Estimated reward per hour: 103 | {centsToGBP(study.estimated_reward_per_hour)}/hr 104 |
Average reward per hour: 109 | {centsToGBP(study.average_reward_per_hour)}/hr 110 |
114 | 115 | } 116 | > 117 | {centsToGBP(study.estimated_reward_per_hour)}/hr 118 |
119 | 122 | 123 | 124 | 125 | 126 | 129 | 130 | 131 | 132 | 133 | 136 | 137 | 138 | 139 | 140 | 143 | 144 | 145 |
Total available places: 127 | {study.total_available_places} 128 |
Places taken: 134 | {study.places_taken} 135 |
Places remaining: 141 | {study.total_available_places - study.places_taken} 142 |
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 | --------------------------------------------------------------------------------