├── .babelrc ├── .env ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .storybook ├── main.js └── preview.js ├── CNAME ├── LICENSE ├── README.md ├── _config.yml ├── assets ├── chromestore.png └── screenshot-1.png ├── jest.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── assets │ ├── data │ │ └── proxies.json │ ├── img │ │ ├── hackernews_icon.png │ │ ├── icon-128.png │ │ ├── icon-32.png │ │ ├── icon-outline.svg │ │ ├── icon.svg │ │ ├── reddit_icon.png │ │ ├── slack_icon.png │ │ └── undraw_group_chat.svg │ └── styles │ │ └── tailwind.css ├── containers │ ├── Badge.tsx │ ├── CardBottomBar.tsx │ ├── HelpPanel.tsx │ ├── HotkeysListenerButton.tsx │ ├── ResultCard.stories.tsx │ ├── ResultCard.tsx │ ├── ResultCardComments.stories.tsx │ ├── ResultCardComments.tsx │ ├── ResultFeedSettingsPanel.tsx │ ├── ResultsContainer.stories.tsx │ ├── ResultsContainer.tsx │ ├── SelectMenu.tsx │ ├── SettingsPanel.tsx │ ├── Slider.tsx │ ├── Toggle.tsx │ └── ToggleGroup.tsx ├── d.ts ├── manifest.json ├── pages │ ├── Background │ │ └── index.js │ ├── Content │ │ ├── content.styles.css │ │ ├── index.css │ │ └── index.js │ ├── Options │ │ ├── Options.tsx │ │ ├── index.html │ │ └── index.jsx │ └── Sidebar │ │ ├── Sidebar.css │ │ ├── Sidebar.tsx │ │ ├── index.html │ │ └── index.jsx ├── providers │ ├── blacklist.ts │ ├── hackernews.ts │ ├── providers.ts │ ├── reddit.ts │ └── scoring.ts ├── shared │ ├── constants.ts │ ├── events.ts │ ├── options.ts │ ├── settings.ts │ ├── storage │ │ ├── createChromeStorageStateHook.js │ │ ├── index.js │ │ ├── storage.js │ │ └── useChromeStorage.js │ └── useHotkeysPressed.tsx ├── tests │ ├── mocks.ts │ └── proxy.test.ts └── utils │ ├── api.ts │ ├── array.ts │ ├── cache.ts │ ├── classNames.ts │ ├── color.ts │ ├── formatText.tsx │ ├── image.ts │ ├── log.ts │ ├── proxy.ts │ ├── results.ts │ ├── tabs.ts │ └── time.ts ├── tailwind.config.js ├── tsconfig.json ├── utils ├── build.js ├── env.js └── webserver.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | // "@babel/preset-env" 4 | "@babel/preset-react" 5 | // "react-app" 6 | ], 7 | "plugins": [ 8 | // "@babel/plugin-proposal-class-properties", 9 | "react-hot-loader/babel" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | INLINE_RUNTIME_CHUNK=false 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | build.zip 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .idea 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | src/stories -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | node_modules 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | trailingComma: "es5", 5 | singleQuote: false, 6 | semi: true, 7 | importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 8 | importOrderSeparation: true, 9 | importOrderSortSpecifiers: true, 10 | }; 11 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: [ 4 | "@storybook/addon-links", 5 | "@storybook/addon-essentials", 6 | "@storybook/addon-interactions", 7 | // This is needed for Tailwind to work with Storybook 8 | // https://stackoverflow.com/a/68757745 9 | { 10 | name: "@storybook/addon-postcss", 11 | options: { 12 | cssLoaderOptions: { 13 | // When you have splitted your css over multiple files 14 | // and use @import('./other-styles.css') 15 | importLoaders: 1, 16 | }, 17 | postcssLoaderOptions: { 18 | // When using postCSS 8 19 | implementation: require("postcss"), 20 | }, 21 | }, 22 | }, 23 | ], 24 | framework: "@storybook/react", 25 | core: { 26 | builder: "@storybook/builder-webpack5", 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import "../src/assets/styles/tailwind.css"; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | usecrowdwise.com -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Michael Xieyang Liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CrowdWise 2 | 3 |

4 | 5 | Extension available in the Chrome Web Store 6 | 7 |

8 |

9 | 10 | Join our Slack Community 11 | 12 | GitHub stars 13 |

14 | 15 | # What's CrowdWise? 16 | 17 | CrowdWise is a Google Chrome extension that adds to your browsing experience by showing you relevant discussions about your current web page from Hacker News and Reddit. 18 | 19 |

20 | 21 | 22 | 23 |

24 | 25 | ## Features 26 | 27 | ### Discussions on Hacker News and Reddit 28 | 29 | - When you navigate to a web page, CrowdWise pulls relevant discussions about this web page from Hacker News and Reddit. You can click on these discussions and it will - open in a new tab, where you can find counter-opinions and different perspectives. 30 | 31 | ### Incognito Mode 32 | 33 | By default, CrowdWise searches for relevant discussions in the background. For additional privacy, CrowdWise also includes an Incognito mode where it will only search for discussions when you click on the CrowdWise button. 34 | 35 | ### Personalised Settings 36 | 37 | Adjust the sidebar to suit the look and feel that you like. You can customise the following: 38 | 39 | - Keyboard shortcuts to open and close the sidebar 40 | - Font sizes 41 | - Sidebar width 42 | - and more 43 | 44 | ## Setup 45 | 46 | ### Running this project 47 | 48 | - `npm install` 49 | - `npm start` 50 | - For chrome: go to `chrome://extensions/` and `Load unpacked`, point it to the `build/` folder. 51 | 52 | ### Developing UI components 53 | 54 | - `npm run storybook` 55 | - Go to http://localhost:6006 where you will have an auto-reloading server to iterate on UI components. 56 | 57 | ## Credits 58 | 59 | - Some HN and Reddit parsing code adapted from Newsit: https://github.com/benwinding/newsit/ 60 | - Michael Xieyang Liu: [Website](https://lxieyang.github.io) 61 | - This boilerplate is largely derived from [lxieyang/vertical-tabs-chrome-extension](https://github.com/lxieyang/vertical-tabs-chrome-extension) and [lxieyang/chrome-extension-boilerplate-react](https://github.com/lxieyang/chrome-extension-boilerplate-react) (which in turn is adapted from [samuelsimoes/chrome-extension-webpack-boilerplate](https://github.com/samuelsimoes/chrome-extension-webpack-boilerplate)). 62 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-dinky -------------------------------------------------------------------------------- /assets/chromestore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/assets/chromestore.png -------------------------------------------------------------------------------- /assets/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/assets/screenshot-1.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CrowdWise", 3 | "version": "0.1.3", 4 | "description": "CrowdWise shows you discussions (from HN/Reddit) about the page you're browsing. What do other people think?", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/usecrowdwise/crowdwise/" 9 | }, 10 | "scripts": { 11 | "build": "node utils/build.js", 12 | "start": "node utils/webserver.js", 13 | "prettier": "prettier --write '**/*.{js,jsx,ts,tsx,json,css,scss,md}'", 14 | "storybook": "start-storybook -p 6006", 15 | "build-storybook": "build-storybook", 16 | "test": "jest" 17 | }, 18 | "dependencies": { 19 | "@emotion/css": "^11.7.1", 20 | "@headlessui/react": "^1.5.0", 21 | "@heroicons/react": "^1.0.5", 22 | "@hot-loader/react-dom": "^17.0.2", 23 | "@radix-ui/react-slider": "^0.1.3", 24 | "@sindresorhus/to-milliseconds": "^2.0.0", 25 | "@tailwindcss/forms": "^0.4.0", 26 | "animate.css": "^4.1.1", 27 | "cheerio": "*", 28 | "classnames": "^2.3.1", 29 | "date-fns": "^2.28.0", 30 | "fromnow": "^3.0.1", 31 | "lodash": "^4.17.21", 32 | "loglevel": "^1.8.0", 33 | "loglevel-plugin-prefix": "^0.8.4", 34 | "re-resizable": "^6.9.1", 35 | "react": "^17.0.2", 36 | "react-dom": "^17.0.2", 37 | "react-draggable": "^4.4.5", 38 | "react-hot-loader": "^4.13.0", 39 | "react-hotkeys-hook": "^3.4.4", 40 | "react-spinners": "^0.11.0", 41 | "react-tooltip": "^4.2.21", 42 | "string-strip-html": "^9.1.12", 43 | "tailwind-scrollbar": "^1.3.1", 44 | "tracking-params": "^0.2.2", 45 | "webext-storage-cache": "^5.0.0", 46 | "zoom-level": "^2.5.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.17.0", 50 | "@babel/plugin-proposal-class-properties": "^7.16.7", 51 | "@babel/preset-env": "^7.16.11", 52 | "@babel/preset-react": "^7.16.7", 53 | "@storybook/addon-actions": "^6.5.8", 54 | "@storybook/addon-essentials": "^6.5.8", 55 | "@storybook/addon-interactions": "^6.5.8", 56 | "@storybook/addon-links": "^6.5.8", 57 | "@storybook/addon-postcss": "^2.0.0", 58 | "@storybook/builder-webpack5": "^6.5.8", 59 | "@storybook/manager-webpack5": "^6.5.8", 60 | "@storybook/react": "^6.5.8", 61 | "@storybook/testing-library": "0.0.12", 62 | "@tailwindcss/line-clamp": "^0.4.0", 63 | "@trivago/prettier-plugin-sort-imports": "^3.2.0", 64 | "@types/chrome": "^0.0.177", 65 | "@types/jest": "^28.1.1", 66 | "@types/lodash": "^4.14.178", 67 | "@types/react": "^17.0.39", 68 | "@types/react-dom": "^17.0.11", 69 | "autoprefixer": "^10.4.2", 70 | "babel-eslint": "^10.1.0", 71 | "babel-loader": "^8.2.3", 72 | "babel-preset-react-app": "^10.0.1", 73 | "clean-webpack-plugin": "^4.0.0", 74 | "copy-webpack-plugin": "^7.0.0", 75 | "css-loader": "^6.6.0", 76 | "eslint": "^8.8.0", 77 | "eslint-config-react-app": "^7.0.0", 78 | "eslint-plugin-flowtype": "^8.0.3", 79 | "eslint-plugin-import": "^2.25.4", 80 | "eslint-plugin-jsx-a11y": "^6.5.1", 81 | "eslint-plugin-react": "^7.28.0", 82 | "eslint-plugin-react-hooks": "^4.3.0", 83 | "file-loader": "^6.2.0", 84 | "fs-extra": "^10.0.0", 85 | "html-loader": "^3.1.0", 86 | "html-webpack-plugin": "^5.5.0", 87 | "jest": "^28.1.1", 88 | "node-sass": "^6.0.1", 89 | "postcss": "^8.4.6", 90 | "postcss-loader": "^6.2.1", 91 | "prettier": "^2.5.1", 92 | "prettier-plugin-tailwindcss": "^0.1.7", 93 | "prop-types": "^15.8.1", 94 | "sass-loader": "^12.4.0", 95 | "source-map-loader": "^3.0.1", 96 | "style-loader": "^3.3.1", 97 | "tailwindcss": "^3.0.19", 98 | "terser-webpack-plugin": "^5.3.1", 99 | "ts-jest": "^28.0.5", 100 | "ts-loader": "^9.2.6", 101 | "typescript": "^4.5.5", 102 | "webpack": "^5.68.0", 103 | "webpack-cli": "^4.9.2", 104 | "webpack-dev-server": "^4.7.4" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/assets/img/hackernews_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/src/assets/img/hackernews_icon.png -------------------------------------------------------------------------------- /src/assets/img/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/src/assets/img/icon-128.png -------------------------------------------------------------------------------- /src/assets/img/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/src/assets/img/icon-32.png -------------------------------------------------------------------------------- /src/assets/img/icon-outline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/img/reddit_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/src/assets/img/reddit_icon.png -------------------------------------------------------------------------------- /src/assets/img/slack_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UseCrowdWise/crowdwise/f88841e7a6eec1aa91016504d0323e8263a938e6/src/assets/img/slack_icon.png -------------------------------------------------------------------------------- /src/assets/img/undraw_group_chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/containers/Badge.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { classNames } from "../utils/classNames"; 4 | 5 | export interface Props { 6 | className?: string; 7 | children: string; 8 | } 9 | 10 | const Badge = (props: Props) => { 11 | const { className, children } = props; 12 | return ( 13 | 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | export default Badge; 25 | -------------------------------------------------------------------------------- /src/containers/CardBottomBar.tsx: -------------------------------------------------------------------------------- 1 | import { ChatIcon, ThumbUpIcon } from "@heroicons/react/solid"; 2 | import React from "react"; 3 | 4 | import { 5 | COLOR_IF_OUTSIDE_HASH, 6 | KEY_FONT_SIZES, 7 | KEY_SHOULD_COLOR_FOR_SUBMITTED_BY, 8 | } from "../shared/constants"; 9 | import { useSettingsStore as useSettingsStoreDI } from "../shared/settings"; 10 | import { hashStringToColor } from "../utils/color"; 11 | 12 | interface Props { 13 | submittedPrettyDate: string; 14 | submittedByLink: string; 15 | submittedBy: string; 16 | commentsCount?: number; 17 | submittedUpvotes?: number; 18 | onClickSubmittedBy: (e: React.MouseEvent) => void; 19 | useSettingsStore?: () => any; 20 | } 21 | 22 | const CardBottomBar = ({ 23 | commentsCount, 24 | submittedUpvotes, 25 | submittedPrettyDate, 26 | submittedByLink, 27 | submittedBy, 28 | onClickSubmittedBy, 29 | useSettingsStore = useSettingsStoreDI, 30 | }: Props) => { 31 | const [ 32 | settings, 33 | setValueAll, 34 | setKeyValue, 35 | isPersistent, 36 | error, 37 | isLoadingStore, 38 | ] = useSettingsStore(); 39 | const colorForSubmittedBy = settings[KEY_SHOULD_COLOR_FOR_SUBMITTED_BY] 40 | ? hashStringToColor(submittedBy) 41 | : COLOR_IF_OUTSIDE_HASH; 42 | const fontSizes = settings[KEY_FONT_SIZES]; 43 | return ( 44 |
45 | {Number.isInteger(commentsCount) && ( 46 |
47 | {commentsCount} 48 | 49 |
50 | )} 51 | {Number.isInteger(submittedUpvotes) && ( 52 |
53 | {submittedUpvotes} 54 | 55 |
56 | )} 57 |
{submittedPrettyDate}
58 |
59 |
60 | 61 | {submittedBy} 62 | 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default CardBottomBar; 69 | -------------------------------------------------------------------------------- /src/containers/HelpPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { GITHUB_REPOSITORY_LINK } from "../shared/constants"; 4 | 5 | export const HelpPanel = () => { 6 | return ( 7 |
8 |
9 |
10 | Information 11 |
12 |
13 |
How does it work?
14 |
15 | When you navigate to a web page, CrowdWise pulls relevant 16 | discussions about this web page from Hacker News and Reddit. You can 17 | click on these discussions and it will open in a new tab, where you 18 | can find counter-opinions and different perspectives. 19 |
20 |
21 |
22 |
23 | Open source and extensible 24 |
25 |
26 | To submit bugs or contribute to CrowdWise, visit our{" "} 27 | e.stopPropagation()} 32 | > 33 | GitHub repository 34 | 35 | . 36 |
37 |
38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/containers/HotkeysListenerButton.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import React, { useState } from "react"; 3 | 4 | import { KEY_INCOGNITO_MODE } from "../shared/constants"; 5 | import { EventType, sendEventsToServerViaWorker } from "../shared/events"; 6 | import { useSettingsStore } from "../shared/settings"; 7 | import { useHotkeysPressed } from "../shared/useHotkeysPressed"; 8 | import { classNames } from "../utils/classNames"; 9 | import { log } from "../utils/log"; 10 | 11 | interface Props { 12 | settingsKey: string; 13 | minNumHotkeys: number; 14 | debounceTime: number; 15 | } 16 | 17 | const HotkeysListenerButton = (props: Props) => { 18 | const { settingsKey, minNumHotkeys, debounceTime } = props; 19 | const [ 20 | settings, 21 | setValueAll, 22 | setKeyValue, 23 | isPersistent, 24 | error, 25 | isLoadingStore, 26 | ] = useSettingsStore(); 27 | const setKeyValueWithEvents = (key: string, value: any) => { 28 | setKeyValue(key, value); 29 | sendEventsToServerViaWorker( 30 | { 31 | eventType: EventType.CHANGE_SETTING, 32 | settingKey: key, 33 | settingValue: value, 34 | }, 35 | settings[KEY_INCOGNITO_MODE] 36 | ); 37 | }; 38 | 39 | // "+" and "," are special delimiters used by react-hotkeys-hook 40 | // replaceAll here is to prettify the hotkeys before showing to user 41 | const currentHotkeyCombos: string[] = settings[settingsKey]; 42 | const hotkeysUserDisplay = currentHotkeyCombos 43 | .join(", ") 44 | .replaceAll("+", " + "); 45 | 46 | const [isHotkeyFocused, setIsHotkeyFocused] = useState(false); 47 | 48 | log.debug("Hotkey Button rerender", currentHotkeyCombos); 49 | 50 | const onKeyPressed = _.debounce((keys: string[]) => { 51 | // Note that this is a single combo only, we do not support multiple hotkey 52 | // combos (except when it is the default option) 53 | const newHotkeyCombo = [keys.join("+")]; 54 | log.debug("Key pressed", newHotkeyCombo); 55 | 56 | if (keys.length >= minNumHotkeys) { 57 | setKeyValueWithEvents(settingsKey, newHotkeyCombo); 58 | setIsHotkeyFocused(false); 59 | } 60 | }, debounceTime); 61 | 62 | const { ref } = useHotkeysPressed( 63 | 200, 64 | onKeyPressed, 65 | isHotkeyFocused 66 | ); 67 | 68 | if (isLoadingStore) return null; 69 | return ( 70 |
71 | {isHotkeyFocused && ( 72 |
73 | Enter hotkey 74 |
75 | )} 76 | 90 |
91 | ); 92 | }; 93 | 94 | HotkeysListenerButton.defaultProps = { 95 | minNumHotkeys: 2, 96 | debounceTime: 200, 97 | }; 98 | 99 | export default HotkeysListenerButton; 100 | -------------------------------------------------------------------------------- /src/containers/ResultCard.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import { 5 | createMockOnFetchComments, 6 | mockComments, 7 | mockHackerNewsResultItem, 8 | mockRedditResultItem, 9 | mockUseSettingsStore, 10 | } from "../tests/mocks"; 11 | import ResultCard from "./ResultCard"; 12 | 13 | export default { 14 | title: "ResultCard/ResultCard", 15 | component: ResultCard, 16 | argTypes: { 17 | backgroundColor: { control: "color" }, 18 | }, 19 | } as ComponentMeta; 20 | 21 | const Template: ComponentStory = (args) => ( 22 | 23 | ); 24 | 25 | export const Reddit = Template.bind({}); 26 | Reddit.args = { 27 | cardPosition: 0, 28 | result: mockRedditResultItem, 29 | useSettingsStore: mockUseSettingsStore, 30 | onFetchComments: createMockOnFetchComments(0, mockComments), 31 | }; 32 | 33 | export const HackerNews = Template.bind({}); 34 | HackerNews.args = { 35 | cardPosition: 0, 36 | result: mockHackerNewsResultItem, 37 | useSettingsStore: mockUseSettingsStore, 38 | onFetchComments: createMockOnFetchComments(0, mockComments), 39 | }; 40 | -------------------------------------------------------------------------------- /src/containers/ResultCard.tsx: -------------------------------------------------------------------------------- 1 | import { ChatIcon, ThumbUpIcon } from "@heroicons/react/solid"; 2 | import React, { useState } from "react"; 3 | import ReactTooltip from "react-tooltip"; 4 | 5 | import { 6 | Comment, 7 | ProviderQueryType, 8 | ProviderType, 9 | ResultItem, 10 | } from "../providers/providers"; 11 | import { 12 | COLOR_IF_OUTSIDE_HASH, 13 | KEY_BOLD_INITIAL_CHARS_OF_WORDS, 14 | KEY_FONT_SIZES, 15 | KEY_INCOGNITO_MODE, 16 | KEY_IS_DEBUG_MODE, 17 | KEY_SHOULD_COLOR_FOR_SUBMITTED_BY, 18 | KEY_SHOULD_USE_OLD_REDDIT_LINK, 19 | ML_FILTER_THRESHOLD, 20 | } from "../shared/constants"; 21 | import { 22 | EventType, 23 | logForumResultEvent, 24 | sendEventsToServerViaWorker, 25 | } from "../shared/events"; 26 | import { useSettingsStore as useSettingsStoreDI } from "../shared/settings"; 27 | import { classNames } from "../utils/classNames"; 28 | import { hashStringToColor } from "../utils/color"; 29 | import { boldFrontPortionOfWords } from "../utils/formatText"; 30 | import { getImageUrl } from "../utils/image"; 31 | import { 32 | onFetchComments as onFetchCommentsDI, 33 | replaceRedditLinksInResult, 34 | } from "../utils/results"; 35 | import Badge from "./Badge"; 36 | import CardBottomBar from "./CardBottomBar"; 37 | import ResultCardComments from "./ResultCardComments"; 38 | 39 | interface Props { 40 | cardPosition: number; 41 | result: ResultItem; 42 | useSettingsStore?: () => any; 43 | onFetchComments?: ( 44 | a: string, 45 | b: ProviderType, 46 | c: (comments: Comment[]) => void 47 | ) => void; 48 | } 49 | 50 | const ResultCard = ({ 51 | result, 52 | cardPosition, 53 | useSettingsStore = useSettingsStoreDI, 54 | onFetchComments = onFetchCommentsDI, 55 | }: Props) => { 56 | const [ 57 | settings, 58 | setValueAll, 59 | setKeyValue, 60 | isPersistent, 61 | error, 62 | isLoadingStore, 63 | ] = useSettingsStore(); 64 | 65 | const [shouldShowComments, setShouldShowComments] = useState( 66 | cardPosition < 1 67 | ); 68 | 69 | const isDebugMode = settings[KEY_IS_DEBUG_MODE]; 70 | const shouldUseOldRedditLink = settings[KEY_SHOULD_USE_OLD_REDDIT_LINK]; 71 | const boldInitialCharsOfWords = settings[KEY_BOLD_INITIAL_CHARS_OF_WORDS]; 72 | const fontSizes = settings[KEY_FONT_SIZES]; 73 | const isIncognitoMode = settings[KEY_INCOGNITO_MODE]; 74 | 75 | const resultWithReplacedLink = replaceRedditLinksInResult( 76 | result, 77 | shouldUseOldRedditLink 78 | ); 79 | 80 | const onCardClick = (url: string) => { 81 | window.open(url, "_blank"); 82 | }; 83 | 84 | const createOnClickLogForumEvent = (eventType: EventType) => { 85 | return (e: React.MouseEvent) => { 86 | logForumResultEvent( 87 | eventType, 88 | cardPosition, 89 | resultWithReplacedLink, 90 | isIncognitoMode 91 | ); 92 | e.stopPropagation(); 93 | }; 94 | }; 95 | 96 | const isFiltered = 97 | result.relevanceScore !== undefined 98 | ? result.relevanceScore < ML_FILTER_THRESHOLD 99 | : false; 100 | 101 | // Open comments bar beneath this result 102 | const toggleComments = () => { 103 | // Toggle show state 104 | setShouldShowComments((prev: boolean) => !prev); 105 | }; 106 | 107 | return ( 108 |
109 | {isDebugMode && result.relevanceScore !== undefined && ( 110 |
111 | Score: {result.relevanceScore.toFixed(1)}{" "} 112 | {isFiltered ? "(Filtered)" : ""} 113 |
114 | )} 115 | {resultWithReplacedLink.providerQueryType === 116 | ProviderQueryType.EXACT_URL && ( 117 | // data-iscapture="true" allow us to immediately dismiss tooltip on user scroll 118 |
122 | EXACT MATCH 123 |
124 | )} 125 | {resultWithReplacedLink.subSourceName !== "" && ( 126 | 141 | )} 142 |
{ 151 | logForumResultEvent( 152 | EventType.CLICK_SIDEBAR_FORUM_RESULT_TITLE, 153 | cardPosition, 154 | resultWithReplacedLink, 155 | isIncognitoMode 156 | ); 157 | onCardClick(resultWithReplacedLink.commentsLink); 158 | }} 159 | > 160 | Source Icon 165 | 174 | {boldInitialCharsOfWords 175 | ? boldFrontPortionOfWords(resultWithReplacedLink.submittedTitle) 176 | : resultWithReplacedLink.submittedTitle} 177 | 178 |
179 | 180 |
) => { 183 | toggleComments(); 184 | logForumResultEvent( 185 | EventType.CLICK_SIDEBAR_FORUM_RESULT_SHOW_COMMENTS, 186 | cardPosition, 187 | resultWithReplacedLink, 188 | isIncognitoMode 189 | ); 190 | e.stopPropagation(); 191 | }} 192 | > 193 | 204 | 211 |
212 | 213 | 224 |
225 | ); 226 | }; 227 | 228 | export default ResultCard; 229 | -------------------------------------------------------------------------------- /src/containers/ResultCardComments.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import { ProviderType } from "../providers/providers"; 5 | import { 6 | createMockOnFetchComments, 7 | mockComments, 8 | mockUseSettingsStore, 9 | } from "../tests/mocks"; 10 | import ResultCardComments from "./ResultCardComments"; 11 | 12 | export default { 13 | title: "ResultCard/ResultCardComments", 14 | component: ResultCardComments, 15 | argTypes: { 16 | backgroundColor: { control: "color" }, 17 | }, 18 | } as ComponentMeta; 19 | 20 | const Template: ComponentStory = (args) => ( 21 | 22 | ); 23 | 24 | export const Primary = Template.bind({}); 25 | Primary.args = { 26 | shouldShowComments: true, 27 | toggleShouldShowComments: () => {}, 28 | commentsUrl: "", 29 | providerType: ProviderType.REDDIT, 30 | onClickSubmittedBy: () => {}, 31 | onFetchComments: createMockOnFetchComments(0, mockComments), 32 | useSettingsStore: mockUseSettingsStore, 33 | }; 34 | -------------------------------------------------------------------------------- /src/containers/ResultCardComments.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { stripHtml } from "string-strip-html"; 3 | 4 | import { Comment, ProviderType } from "../providers/providers"; 5 | import { 6 | KEY_BOLD_INITIAL_CHARS_OF_WORDS, 7 | KEY_INCOGNITO_MODE, 8 | KEY_SHOULD_USE_OLD_REDDIT_LINK, 9 | } from "../shared/constants"; 10 | import { EventType, logForumCommentEvent } from "../shared/events"; 11 | import { useSettingsStore as useSettingsStoreDI } from "../shared/settings"; 12 | import { boldFrontPortionOfWords } from "../utils/formatText"; 13 | import { log } from "../utils/log"; 14 | import { replaceRedditLinksInComment } from "../utils/results"; 15 | import CardBottomBar from "./CardBottomBar"; 16 | 17 | export interface Props { 18 | shouldShowComments: boolean; 19 | toggleShouldShowComments: () => void; 20 | commentsUrl: string; 21 | providerType: ProviderType; 22 | onClickSubmittedBy: (e: React.MouseEvent) => void; 23 | onFetchComments: ( 24 | commentsUrl: string, 25 | providerType: ProviderType, 26 | callback: (comments: Comment[]) => void 27 | ) => void; 28 | useSettingsStore?: () => any; 29 | } 30 | 31 | const ResultCardComments = ({ 32 | shouldShowComments, 33 | toggleShouldShowComments, 34 | commentsUrl, 35 | providerType, 36 | onClickSubmittedBy, 37 | onFetchComments, 38 | useSettingsStore = useSettingsStoreDI, 39 | }: Props) => { 40 | const [ 41 | settings, 42 | setValueAll, 43 | setKeyValue, 44 | isPersistent, 45 | error, 46 | isLoadingStore, 47 | ] = useSettingsStore(); 48 | const boldInitialCharsOfWords = settings[KEY_BOLD_INITIAL_CHARS_OF_WORDS]; 49 | const shouldUseOldRedditLink = settings[KEY_SHOULD_USE_OLD_REDDIT_LINK]; 50 | const isIncognitoMode = settings[KEY_INCOGNITO_MODE]; 51 | 52 | const [isLoadingComments, setIsLoadingComments] = useState(true); 53 | const [hasFetchedComments, setHasFetchedComments] = useState(false); 54 | const [comments, setComments] = useState([]); 55 | 56 | useEffect(() => { 57 | if (shouldShowComments && !hasFetchedComments) { 58 | setHasFetchedComments(true); 59 | log.debug("Sending message to bg script for comments."); 60 | 61 | onFetchComments(commentsUrl, providerType, (newComments: Comment[]) => { 62 | log.debug("Comments:"); 63 | log.debug(newComments); 64 | setComments( 65 | newComments.map((comment: Comment) => 66 | replaceRedditLinksInComment(comment, shouldUseOldRedditLink) 67 | ) 68 | ); 69 | setIsLoadingComments(false); 70 | }); 71 | } 72 | }, [shouldShowComments, commentsUrl, providerType]); 73 | 74 | if (!shouldShowComments) return null; 75 | 76 | if (isLoadingComments) { 77 | return ( 78 |
79 |
80 |

81 | Loading comments... 82 |

83 |
84 | ); 85 | } 86 | 87 | if (comments.length <= 0) { 88 | return ( 89 |
90 |
91 | No comments found. 92 |
93 |
94 | ); 95 | } 96 | 97 | return ( 98 |
99 |
100 |
{ 104 | toggleShouldShowComments(); 105 | e.stopPropagation(); 106 | }} 107 | /> 108 |
109 |
Top Comments
110 | {comments.map((comment: Comment, index) => { 111 | const commentContent = stripHtml(comment.text).result; 112 | 113 | const onClickCommentTitle = ( 114 | e: React.MouseEvent 115 | ) => { 116 | logForumCommentEvent( 117 | EventType.CLICK_SIDEBAR_FORUM_COMMENT_TITLE, 118 | comment, 119 | isIncognitoMode 120 | ); 121 | e.stopPropagation(); 122 | }; 123 | 124 | return ( 125 | 149 | ); 150 | })} 151 |
152 |
153 |
154 | ); 155 | }; 156 | 157 | export default ResultCardComments; 158 | -------------------------------------------------------------------------------- /src/containers/ResultFeedSettingsPanel.tsx: -------------------------------------------------------------------------------- 1 | import { QuestionMarkCircleIcon } from "@heroicons/react/outline"; 2 | import _ from "lodash"; 3 | import React, { useState } from "react"; 4 | import ReactTooltip from "react-tooltip"; 5 | 6 | import { 7 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_COMMENTS, 8 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_DATE, 9 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_LIKES, 10 | DEFAULT_RESULT_FEED_SORT_EXACT_URL_FIRST, 11 | DEFAULT_RESULT_FEED_SORT_OPTION, 12 | KEY_INCOGNITO_MODE, 13 | KEY_RESULT_FEED_FILTER_BY_MIN_COMMENTS, 14 | KEY_RESULT_FEED_FILTER_BY_MIN_DATE, 15 | KEY_RESULT_FEED_FILTER_BY_MIN_LIKES, 16 | KEY_RESULT_FEED_SORT_EXACT_URL_FIRST, 17 | KEY_RESULT_FEED_SORT_OPTION, 18 | SETTINGS_DEBOUNCE_TIME, 19 | } from "../shared/constants"; 20 | import { EventType, sendEventsToServerViaWorker } from "../shared/events"; 21 | import { 22 | RESULT_FEED_FILTER_BY_MIN_DATE_OPTIONS, 23 | RESULT_FEED_SORT_OPTIONS, 24 | } from "../shared/options"; 25 | import { useSettingsStore } from "../shared/settings"; 26 | import { classNames } from "../utils/classNames"; 27 | import { log } from "../utils/log"; 28 | import SelectMenu from "./SelectMenu"; 29 | import Slider from "./Slider"; 30 | import Toggle from "./Toggle"; 31 | 32 | interface Props { 33 | scrollable?: boolean; 34 | } 35 | 36 | export const ResultFeedSettingsPanel = (props: Props) => { 37 | log.debug("Settings Panel rerender"); 38 | 39 | const { scrollable } = props; 40 | const [ 41 | settings, 42 | setValueAll, 43 | setKeyValue, 44 | isPersistent, 45 | error, 46 | isLoadingStore, 47 | ] = useSettingsStore(); 48 | 49 | const isIncognitoMode = settings[KEY_INCOGNITO_MODE]; 50 | const resultFeedSortExactUrlFirst = 51 | settings[KEY_RESULT_FEED_SORT_EXACT_URL_FIRST]; 52 | const resultFeedSortOption = settings[KEY_RESULT_FEED_SORT_OPTION]; 53 | const resultFeedFilterByMinComments = 54 | settings[KEY_RESULT_FEED_FILTER_BY_MIN_COMMENTS]; 55 | const resultFeedFilterByMinLikes = 56 | settings[KEY_RESULT_FEED_FILTER_BY_MIN_LIKES]; 57 | const resultFeedFilterByMinDate = 58 | settings[KEY_RESULT_FEED_FILTER_BY_MIN_DATE]; 59 | 60 | const setKeyValueWithEvents = (key: string, value: any) => { 61 | setKeyValue(key, value); 62 | sendEventsToServerViaWorker( 63 | { 64 | eventType: EventType.CHANGE_SETTING, 65 | settingKey: key, 66 | settingValue: value, 67 | }, 68 | isIncognitoMode 69 | ); 70 | }; 71 | 72 | const setResultFeedSortOption = (state: any) => 73 | setKeyValueWithEvents(KEY_RESULT_FEED_SORT_OPTION, state); 74 | const setResultFeedSortExactUrlFirst = (state: boolean) => 75 | setKeyValueWithEvents(KEY_RESULT_FEED_SORT_EXACT_URL_FIRST, state); 76 | 77 | const setResultFeedFilterByMinComments = (state: number) => 78 | setKeyValueWithEvents(KEY_RESULT_FEED_FILTER_BY_MIN_COMMENTS, state); 79 | const setResultFeedFilterByMinLikes = (state: number) => 80 | setKeyValueWithEvents(KEY_RESULT_FEED_FILTER_BY_MIN_LIKES, state); 81 | const setResultFeedFilterByMinDate = (state: any) => 82 | setKeyValueWithEvents(KEY_RESULT_FEED_FILTER_BY_MIN_DATE, state); 83 | 84 | const setResultFeedFilterByMinCommentsDebounced = _.debounce((value: any) => { 85 | setResultFeedFilterByMinComments(value); 86 | }, SETTINGS_DEBOUNCE_TIME); 87 | const setResultFeedFilterByMinLikesDebounced = _.debounce((value: any) => { 88 | setResultFeedFilterByMinLikes(value); 89 | }, SETTINGS_DEBOUNCE_TIME); 90 | 91 | const resetSettingsToDefault = () => { 92 | setValueAll((prevState: any) => { 93 | return { 94 | ...prevState, 95 | [KEY_RESULT_FEED_SORT_OPTION]: DEFAULT_RESULT_FEED_SORT_OPTION, 96 | [KEY_RESULT_FEED_SORT_EXACT_URL_FIRST]: 97 | DEFAULT_RESULT_FEED_SORT_EXACT_URL_FIRST, 98 | [KEY_RESULT_FEED_FILTER_BY_MIN_COMMENTS]: 99 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_COMMENTS, 100 | [KEY_RESULT_FEED_FILTER_BY_MIN_LIKES]: 101 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_LIKES, 102 | [KEY_RESULT_FEED_FILTER_BY_MIN_DATE]: 103 | DEFAULT_RESULT_FEED_FILTER_BY_MIN_DATE, 104 | }; 105 | }); 106 | }; 107 | 108 | if (isLoadingStore) return null; 109 | return ( 110 |
111 |
117 |
118 |
Customise your Results
119 | 123 | Reset to default 124 | 125 |
126 | 127 |
Sorting
128 |
129 |
Sort Results by
130 | 135 |
136 |
140 |
141 |
142 | Sort Results with Exact URL Match first 143 |
144 | 145 |
146 |
147 | 151 |
152 |
Filtering
153 |
154 |
Filter by Date
155 | 160 |
161 |
162 |
163 |
Minimum Comment Counts
164 |
165 |
166 | {resultFeedFilterByMinComments} 167 |
168 |
169 | 176 |
177 |
178 |
179 |
Minimum Like Counts
180 |
181 |
182 | {resultFeedFilterByMinLikes} 183 |
184 |
185 | 192 |
193 |
194 | 195 | 196 |
197 | ); 198 | }; 199 | -------------------------------------------------------------------------------- /src/containers/ResultsContainer.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStory } from "@storybook/react"; 2 | import React from "react"; 3 | 4 | import { 5 | createMockOnFetchComments, 6 | mockComments, 7 | mockResults, 8 | mockUseSettingsStore, 9 | } from "../tests/mocks"; 10 | import ResultsContainer from "./ResultsContainer"; 11 | 12 | export default { 13 | title: "ResultCard/ResultsContainer", 14 | component: ResultsContainer, 15 | argTypes: { 16 | backgroundColor: { control: "color" }, 17 | }, 18 | } as ComponentMeta; 19 | 20 | const Template: ComponentStory = (args) => ( 21 | 22 | ); 23 | 24 | export const Primary = Template.bind({}); 25 | Primary.args = { 26 | results: mockResults, 27 | numResults: 10, 28 | useSettingsStore: mockUseSettingsStore, 29 | onFetchComments: createMockOnFetchComments(0, mockComments), 30 | }; 31 | 32 | export const LoadingComments = Template.bind({}); 33 | LoadingComments.args = { 34 | results: mockResults, 35 | numResults: 10, 36 | useSettingsStore: mockUseSettingsStore, 37 | onFetchComments: createMockOnFetchComments(100000000, mockComments), 38 | }; 39 | -------------------------------------------------------------------------------- /src/containers/ResultsContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDownIcon } from "@heroicons/react/outline"; 2 | import React, { useState } from "react"; 3 | 4 | import { Comment, ProviderType, ResultItem } from "../providers/providers"; 5 | import { useSettingsStore as useSettingsStoreDI } from "../shared/settings"; 6 | import { onFetchComments as onFetchCommentsDI } from "../utils/results"; 7 | import ResultCard from "./ResultCard"; 8 | 9 | interface Props { 10 | results: ResultItem[]; 11 | numResults?: number; 12 | useSettingsStore?: () => any; 13 | onFetchComments?: ( 14 | a: string, 15 | b: ProviderType, 16 | c: (comments: Comment[]) => void 17 | ) => void; 18 | } 19 | 20 | const ResultsContainer = ({ 21 | results, 22 | numResults = 8, 23 | useSettingsStore = useSettingsStoreDI, 24 | onFetchComments = onFetchCommentsDI, 25 | }: Props) => { 26 | const [shouldShowMore, setShouldShowMore] = useState(false); 27 | 28 | const partialResults = shouldShowMore 29 | ? results 30 | : results.slice(0, numResults); 31 | const numberMoreToShow = results.length - partialResults.length; 32 | 33 | return ( 34 |
35 | {partialResults.map((result, index) => ( 36 | 43 | ))} 44 | {numberMoreToShow > 0 && ( 45 |
46 |
47 | 53 | setShouldShowMore(true)} 56 | /> 57 |
58 |
59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default ResultsContainer; 65 | -------------------------------------------------------------------------------- /src/containers/SelectMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from "@headlessui/react"; 2 | import { CheckIcon, SelectorIcon } from "@heroicons/react/solid"; 3 | import React, { Fragment, useEffect, useState } from "react"; 4 | 5 | import { classNames } from "../utils/classNames"; 6 | 7 | interface Option { 8 | key: string; 9 | value: string; 10 | } 11 | 12 | interface Props { 13 | defaultOption: Option; 14 | options: Option[]; 15 | onSelected: (value: Option) => void; 16 | label?: string; 17 | } 18 | 19 | const SelectMenu = (props: Props) => { 20 | const { defaultOption, options, onSelected, label } = props; 21 | const [selected, setSelected] = useState