├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .git_deploy_key.enc ├── .github └── workflows │ ├── beta-publish.yml │ ├── nodejs.yml │ └── website-publish.yml ├── .gitignore ├── .jest.puppeteer.config.js ├── .nvmrc ├── .prettierrc ├── .releaserc.json ├── LICENSE.md ├── PRIVACY.md ├── README.md ├── background └── background.js ├── chrome-img ├── JustNotSorry-240.png ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png └── screenshot-4.png ├── e2e ├── global-setup.js ├── global-teardown.js ├── local.perf.test.js └── utility.js ├── img ├── JustNotSorry-128.png ├── JustNotSorry-16.png ├── JustNotSorry-19.png ├── JustNotSorry-38.png └── JustNotSorry-48.png ├── include.lst ├── jest-puppeteer.config.js ├── just-not-sorry.css ├── manifest.json ├── options ├── options.ejs └── options.js ├── package-lock.json ├── package.json ├── package.sh ├── public └── jns-test.html ├── site ├── .gitignore ├── .ruby-version ├── CNAME ├── Gemfile ├── Gemfile.lock ├── README.md ├── _config.yml ├── _data │ └── .keep ├── _includes │ ├── banner.html │ ├── extension-description.html │ ├── head-custom.html │ └── supported-sites.html ├── _layouts │ └── default.html ├── assets │ └── css │ │ └── style.scss ├── img │ ├── CNBC.svg │ ├── FastCompany.svg │ ├── Forbes.svg │ ├── Glamour.svg │ ├── Gmail.svg │ ├── JustNotSorry-logo.png │ ├── NPR.svg │ ├── NewYorkTimes.svg │ ├── Outlook.svg │ ├── Slate.svg │ ├── Today.svg │ ├── Vogue.svg │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── defmethod-logo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── index.md ├── phrases.md ├── releases.md └── site.webmanifest ├── spec ├── ContentEditableDivSpec.test.js ├── JustNotSorrySpec.test.js ├── RangeFinderSpec.test.js ├── UtilSpec.test.js ├── WarningHighlightSpec.test.js ├── WarningMessagesSpec.test.js ├── WarningSpec.test.js └── setupTests.js ├── src ├── callbacks │ └── ContentEditableDiv.js ├── components │ ├── JustNotSorry.js │ ├── Warning.js │ └── WarningHighlight.js ├── helpers │ ├── RangeFinder.js │ └── util.js ├── index.js └── warnings │ └── phrases.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | ["@babel/plugin-transform-react-jsx", { 14 | "runtime": "automatic" 15 | }], 16 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist/ 4 | gulpfile.js 5 | webpack.config.js 6 | jest-puppeteer.config.js 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:import/errors", 9 | "prettier", 10 | "plugin:react/recommended" 11 | ], 12 | "globals": { 13 | "chrome": true, 14 | "analytics": true, 15 | "window": true, 16 | "document": true, 17 | "global": true, 18 | "page": true, 19 | "browser": true, 20 | "context": true, 21 | "jestPuppeteer": true 22 | }, 23 | "parser": "babel-eslint", 24 | "settings": {}, 25 | "parserOptions": { 26 | "ecmaFeatures": { 27 | "modules": true, 28 | "ecmaVersion": 2018, 29 | "jsx": true 30 | } 31 | }, 32 | "plugins": ["react"], 33 | "rules": { 34 | "import/no-unresolved": "off", 35 | "react/prop-types": 0, 36 | "react/no-unknown-property": [2, { "ignore": ["class"] }] 37 | }, 38 | "overrides": [ 39 | { 40 | "files": "**/*.test.js", 41 | "env": { 42 | "node": true, 43 | "jest": true, 44 | "es6": true 45 | }, 46 | "plugins": ["jest"], 47 | "extends": ["eslint:recommended", "plugin:jest/recommended", "prettier"] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.git_deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/.git_deploy_key.enc -------------------------------------------------------------------------------- /.github/workflows/beta-publish.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Publish to Chrome Web Store 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | publish: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Download release package 15 | run: curl -o just-not-sorry-chrome.zip `jq -j '.release.assets[0].url' $GITHUB_EVENT_PATH` 16 | - name: Upload to Chrome Web Store beta testers 17 | if: github.event.release.prerelease 18 | uses: trmcnvn/chrome-addon@v2 19 | with: 20 | extension: fgnoahpabaeffmkacgedecamkmddkebn # beta extension ID 21 | zip: just-not-sorry-chrome.zip 22 | client-id: ${{ secrets.CHROME_CLIENT_ID_DM }} 23 | client-secret: ${{ secrets.CHROME_CLIENT_SECRET_DM }} 24 | refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN_DM }} 25 | publish-target: "trustedTesters" 26 | 27 | # TODO: add publish to production Chrome Web Store 28 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | paths-ignore: 9 | - 'package.json' 10 | - 'manifest.json' 11 | - 'README.md' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version-file: '.nvmrc' 24 | cache: 'npm' 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Verify that build works 28 | run: npm run build 29 | - name: Run tests 30 | run: npm test 31 | - name: Run e2e tests 32 | run: | 33 | export DISPLAY=:99 34 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & 35 | npm run e2e 36 | - name: Semantic Release 37 | env: 38 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 39 | SSL_PASSPHRASE: ${{ secrets.SSL_PASSPHRASE }} 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | run: | 42 | gpg --passphrase=$GPG_PASSPHRASE --batch --output /tmp/git_deploy_key --decrypt .git_deploy_key.enc 43 | chmod 600 /tmp/git_deploy_key 44 | echo 'echo ${SSL_PASSPHRASE}' > /tmp/askpass && chmod +x /tmp/askpass 45 | eval "$(ssh-agent -s)" 46 | DISPLAY=":0.0" SSH_ASKPASS="/tmp/askpass" setsid ssh-add /tmp/git_deploy_key 0) { 12 | await page.waitForSelector('.jns-highlight', options); 13 | } 14 | const numWarnings = await page.$$('.jns-highlight'); 15 | //longer phrases can wrap lines and create multiple tooltips 16 | await expect(numWarnings.length).toBeGreaterThanOrEqual(expected); 17 | } 18 | 19 | describe('Just Not Sorry', () => { 20 | beforeEach(async () => { 21 | await page.goto(`file://${TEST_PAGE}`); 22 | await page.waitForTimeout(500); 23 | await page.click('#email'); 24 | await assertWarnings(0, { visible: false, timeout: TEST_WAIT_TIME }); 25 | }); 26 | 27 | it('should work', async () => { 28 | await page.keyboard.type(`just not sorry.`); 29 | await page.keyboard.press('Enter'); 30 | await page.keyboard.press('Enter'); 31 | 32 | //blur 33 | await page.keyboard.down('Shift'); 34 | await page.keyboard.press('Tab'); 35 | await page.keyboard.up('Shift'); 36 | await assertWarnings(2, { visible: false, timeout: TEST_WAIT_TIME }); 37 | 38 | //focus 39 | await page.keyboard.press('Tab'); 40 | await assertWarnings(2, { visible: true, timeout: TEST_WAIT_TIME }); 41 | }); 42 | 43 | it('should display 500 words with 200 warnings', async () => { 44 | const fiftyWords = `Just actually sorry. Apologize. I think I'm no expert. Yes, um, literally, very, sort of, If that's okay, um, I should feel, we believe, in my opinion, This might be a silly idea. This might be a stupid question. I may be wrong. If I'm being honest. I guess. Maybe!!!`; 45 | expect(fiftyWords.split(' ').length).toBe(50); 46 | 47 | for (let i = 0; i < 500 / 50; i++) { 48 | await page.keyboard.type(fiftyWords); 49 | await page.keyboard.press('Enter'); 50 | await page.keyboard.press('Enter'); 51 | 52 | //blur 53 | await page.keyboard.down('Shift'); 54 | await page.keyboard.press('Tab', { delay: 500 }); 55 | await page.keyboard.up('Shift'); 56 | const numExpected = 20 * (i + 1); 57 | await assertWarnings(numExpected, { 58 | visible: false, 59 | timeout: TEST_WAIT_TIME, 60 | }); 61 | 62 | //focus 63 | await page.keyboard.press('Tab', { delay: 500 }); 64 | await assertWarnings(numExpected, { 65 | visible: true, 66 | timeout: TEST_WAIT_TIME, 67 | }); 68 | } 69 | }); 70 | 71 | it('should display 1000 words with 400 warnings with blur', async () => { 72 | const fiftyWords = `Just actually sorry. Apologize. I think I'm no expert. Yes, um, literally, very, sort of, If that's okay, um, I should feel, we believe, in my opinion, This might be a silly idea. This might be a stupid question. I may be wrong. If I'm being honest. I guess. Maybe!!!`; 73 | expect(fiftyWords.split(' ').length).toBe(50); 74 | 75 | for (let i = 0; i < 1000 / 50; i++) { 76 | await page.keyboard.type(fiftyWords); 77 | await page.keyboard.press('Enter'); 78 | await page.keyboard.press('Enter'); 79 | 80 | //blur 81 | await page.keyboard.down('Shift'); 82 | await page.keyboard.press('Tab', { delay: 500 }); 83 | await page.keyboard.up('Shift'); 84 | const numExpected = 20 * (i + 1); 85 | await assertWarnings(numExpected, { 86 | visible: false, 87 | timeout: TEST_WAIT_TIME, 88 | }); 89 | 90 | //focus 91 | await page.keyboard.press('Tab', { delay: 500 }); 92 | await assertWarnings(numExpected, { 93 | visible: true, 94 | timeout: TEST_WAIT_TIME, 95 | }); 96 | } 97 | }); 98 | 99 | it('should display 1000 words with 400 warnings with delay', async () => { 100 | const fiftyWords = `Just actually sorry. Apologize. I think I'm no expert. Yes, um, literally, very, sort of, If that's okay, um, I should feel, we believe, in my opinion, This might be a silly idea. This might be a stupid question. I may be wrong. If I'm being honest. I guess. Maybe!!!`; 101 | expect(fiftyWords.split(' ').length).toBe(50); 102 | 103 | for (let i = 0; i < 1000 / 50; i++) { 104 | await page.keyboard.type(fiftyWords); 105 | await page.keyboard.press('Enter'); 106 | await page.keyboard.press('Enter'); 107 | 108 | await page.waitForTimeout(500); 109 | await assertWarnings(20 * (i + 1), { 110 | visible: true, 111 | timeout: TEST_WAIT_TIME, 112 | }); 113 | } 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /e2e/utility.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | // eslint-disable-next-line no-undef 5 | const manifestPath = path.join(__dirname, '..', 'build', 'manifest.json'); 6 | export function e2eSetup() { 7 | fs.readFile(manifestPath, (err, data) => { 8 | if (err) throw err; 9 | const newValue = data 10 | .toString() 11 | .replace( 12 | 'https://mail.google.com/*', 13 | 'file:///*/just-not-sorry/public/jns-test.html' 14 | ); 15 | fs.writeFile(manifestPath, newValue, 'utf-8', function (err) { 16 | if (err) throw err; 17 | }); 18 | }); 19 | } 20 | export function e2eTeardown() { 21 | fs.readFile(manifestPath, (err, data) => { 22 | if (err) throw err; 23 | const newValue = data 24 | .toString() 25 | .replace( 26 | 'file:///*/just-not-sorry/public/jns-test.html', 27 | 'https://mail.google.com/*' 28 | ); 29 | fs.writeFile(manifestPath, newValue, 'utf-8', function (err) { 30 | if (err) throw err; 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /img/JustNotSorry-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/img/JustNotSorry-128.png -------------------------------------------------------------------------------- /img/JustNotSorry-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/img/JustNotSorry-16.png -------------------------------------------------------------------------------- /img/JustNotSorry-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/img/JustNotSorry-19.png -------------------------------------------------------------------------------- /img/JustNotSorry-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/img/JustNotSorry-38.png -------------------------------------------------------------------------------- /img/JustNotSorry-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/img/JustNotSorry-48.png -------------------------------------------------------------------------------- /include.lst: -------------------------------------------------------------------------------- 1 | src/*.js 2 | lib/*.js 3 | img/*.png 4 | options/*.* 5 | just-not-sorry.css 6 | manifest.json 7 | README.md 8 | PRIVACY.md 9 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | //This filename is the default path for jest config 2 | module.exports = { 3 | launch: { 4 | headless: false, 5 | slowMo: false, 6 | devtools: false, 7 | args: [ 8 | `--disable-extensions-except=build`, 9 | `--load-extension=build`, 10 | `--window-size=800,800`, 11 | ], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /just-not-sorry.css: -------------------------------------------------------------------------------- 1 | .jns-highlight { 2 | border-bottom: 2px dashed #ff9933; 3 | position: absolute; 4 | padding: 0; 5 | } 6 | 7 | .jns-tooltip { 8 | max-width: 380px; 9 | border: 1px solid darkgray; 10 | z-index: 9999; 11 | } 12 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Just Not Sorry -- the Chrome extension", 4 | "short_name": "JustNotSorry", 5 | "author": "Steve Brudz, Manish Kakwani, Tami Reiss, and Eric Tillberg of Def Method", 6 | "version": "2.5.3", 7 | "version_name": "2.5.3", 8 | "description": "A Chrome extension that warns you when you write emails using words which undermine your message", 9 | "icons": { 10 | "16": "img/JustNotSorry-16.png", 11 | "48": "img/JustNotSorry-48.png", 12 | "128": "img/JustNotSorry-128.png" 13 | }, 14 | "content_scripts": [ 15 | { 16 | "matches": [ 17 | "https://mail.google.com/*", 18 | "https://outlook.office.com/*", 19 | "https://outlook.live.com/*", 20 | "https://outlook.office365.com/*" 21 | ], 22 | "css": ["./just-not-sorry.css"], 23 | "js": ["bundle.js"] 24 | } 25 | ], 26 | "background": { 27 | "service_worker": "background.js" 28 | }, 29 | "action": { 30 | "default_icon": { 31 | "19": "img/JustNotSorry-19.png", 32 | "38": "img/JustNotSorry-38.png" 33 | }, 34 | "default_title": "Just Not Sorry" 35 | }, 36 | "options_ui": { 37 | "page": "options.html", 38 | "open_in_tab": false 39 | }, 40 | "web_accessible_resources": [], 41 | "permissions": ["notifications"] 42 | } 43 | -------------------------------------------------------------------------------- /options/options.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Just Not Sorry Options 5 | 43 | 44 | 45 | 46 |

47 | We're Just NOT Sorry! Let's stop qualifying our message and 48 | diminishing our voice. Inspired by the writings of Tara Mohr 49 | and others, this Chrome Extension for Gmail will warn you 50 | when you use words or phrases that undermine your message. 51 | Words will be underlined for correction with additional 52 | information about how using the phrase is perceived. 53 | (Don't worry, the underline won't get sent as part of your 54 | email if you decide to ignore it.) 55 |

56 |

57 | Created by Tami Reiss, Steve Brudz, Manish Kakwani, and Eric Tillberg. Maintained by 58 | Def Method. 59 |

60 |

61 | All code has been open-sourced and is available on 62 | GitHub. 63 |

64 |

65 | We never collect anything personally identifiable and don't collect 66 | anything about the email that you're writing. See our 67 | privacy policy 68 | for more information. 69 |

70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | <% _.forEach(allWarnings, (warning) => { %> 81 | 82 | 89 | 92 | 93 | <% }); %> 94 | 95 |
Words and Phrases That Trigger Warnings
phrasemessage
83 |
    84 | <% _.forEach(warning.displayLabel, (label) => { %> 85 |
  • "<%= label %>"
  • 86 | <% }); %> 87 |
88 |
90 | <%= warning.message %> source 91 |
96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /options/options.js: -------------------------------------------------------------------------------- 1 | // javascript that runs on the options page goes here 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "just-not-sorry", 3 | "description": "Chrome extension that warns you when you write emails using words which undermine your message", 4 | "version": "2.5.3", 5 | "author": "Steve Brudz, Manish Kakwani, Tami Reiss, and Eric Tillberg of Def Method", 6 | "license": "MIT", 7 | "repository": "git@github.com:defmethodinc/just-not-sorry.git", 8 | "homepage": "https://github.com/defmethodinc/just-not-sorry", 9 | "bugs": "https://github.com/defmethodinc/just-not-sorry/issues/new", 10 | "scripts": { 11 | "e2e": "npm run build && jest --verbose --config='.jest.puppeteer.config.js' ./e2e", 12 | "test": "jest --verbose ./spec", 13 | "test:watch": "npm run test -- --watch", 14 | "build": "webpack --config webpack.config.js", 15 | "build:watch": "webpack --config webpack.config.js --watch", 16 | "webext:run": "sleep 10 && web-ext run --source-dir ./build/ --start-url gmail.com", 17 | "webext:lint": "web-ext lint -s ./build", 18 | "start:firefox": "concurrently \"npm:build:watch\" \"npm:webext:run\"", 19 | "start:chrome": "concurrently \"npm:build:watch\" \"npm:webext:run -- -t chromium\"", 20 | "format": "prettier --loglevel warn --write \"{src,spec}/*.{js,css}\" \"*.{md,css,json,js}\"", 21 | "lint": "eslint . --cache --fix", 22 | "site:version": "echo \"{\\\"commit\\\": \\\"$(git rev-parse --short HEAD)\\\"}\" > ./site/_data/version.json", 23 | "site:phrases": "cp ./src/warnings/phrases.json ./site/_data", 24 | "predeploy": "npm run site:phrases && npm run site:version", 25 | "deploy": "gh-pages -d site", 26 | "semantic-release": "cross-env GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) semantic-release" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.9.6", 30 | "@babel/plugin-proposal-class-properties": "^7.12.1", 31 | "@babel/plugin-transform-react-jsx": "^7.9.4", 32 | "@babel/preset-env": "^7.9.6", 33 | "@semantic-release/exec": "^6.0.3", 34 | "@semantic-release/git": "^10.0.1", 35 | "@testing-library/jest-dom": "^5.16.5", 36 | "@testing-library/react": "^14.0.0", 37 | "babel": "^6.23.0", 38 | "babel-eslint": "^8.2.6", 39 | "babel-loader": "^8.0.6", 40 | "commitizen": "^4.2.4", 41 | "concurrently": "^5.2.0", 42 | "copy-webpack-plugin": "^9.0.1", 43 | "cz-conventional-changelog": "^3.1.0", 44 | "eslint": "^6.8.0", 45 | "eslint-config-prettier": "^6.11.0", 46 | "eslint-plugin-import": "^2.20.2", 47 | "eslint-plugin-jasmine": "^4.1.1", 48 | "eslint-plugin-jest": "^23.13.1", 49 | "eslint-plugin-react": "^7.20.0", 50 | "gh-pages": "^4.0.0", 51 | "html-webpack-plugin": "^5.3.2", 52 | "husky": "^4.2.5", 53 | "jest": "^29.4.3", 54 | "jest-environment-jsdom": "^29.4.3", 55 | "jest-environment-puppeteer": "^7.0.1", 56 | "jest-puppeteer": "^7.0.1", 57 | "lint-staged": "^10.2.2", 58 | "prettier": "^2.0.5", 59 | "semantic-release": "^19.0.2", 60 | "style-loader": "^3.3.1", 61 | "web-ext": "^7.1.1", 62 | "webpack": "^5.51.1", 63 | "webpack-cli": "^4.8.0", 64 | "webpack-dev-server": "^4.0.0" 65 | }, 66 | "dependencies": { 67 | "css-loader": "^6.7.3", 68 | "prop-types": "^15.7.2", 69 | "range-at-index": "^1.0.4", 70 | "react": "^18.2.0", 71 | "react-dom": "^18.2.0", 72 | "react-tooltip": "^5.8.1" 73 | }, 74 | "husky": { 75 | "hooks": { 76 | "pre-commit": "npm test && lint-staged", 77 | "pre-push": "npm test", 78 | "prepare-commit-msg": "exec < /dev/tty && git cz --hook || true" 79 | } 80 | }, 81 | "config": { 82 | "commitizen": { 83 | "path": "./node_modules/cz-conventional-changelog" 84 | } 85 | }, 86 | "lint-staged": { 87 | "*.js": [ 88 | "prettier --write", 89 | "eslint --cache --fix" 90 | ], 91 | "*.{md,css,json}": [ 92 | "prettier --write" 93 | ] 94 | }, 95 | "jest": { 96 | "testEnvironment": "jsdom", 97 | "setupFilesAfterEnv": [ 98 | "/spec/setupTests.js" 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | VERSION_NAME=$1 3 | VERSION=$(echo "$VERSION_NAME" | sed 's/-beta//') 4 | sed -i.old "s/\"version\": \".*\"/\"version\": \"$VERSION_NAME\"/" package.json 5 | sed -i.old -e "s/\"version_name\": \".*\"/\"version_name\": \"$VERSION_NAME\"/" -e "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" manifest.json 6 | npm run build 7 | mkdir -p dist 8 | cd build 9 | zip -r "../dist/just-not-sorry-chrome.zip" . * 10 | -------------------------------------------------------------------------------- /public/jns-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

JustNotSorry Test

6 |
7 |
8 |
11 |
12 |
13 |
14 |
15 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .jekyll-cache 4 | _site 5 | # copied from src/warnings 6 | _data/phrases.json 7 | # from git commit sha 8 | _data/version.json 9 | -------------------------------------------------------------------------------- /site/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.1 2 | -------------------------------------------------------------------------------- /site/CNAME: -------------------------------------------------------------------------------- 1 | justnotsorry.com -------------------------------------------------------------------------------- /site/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'github-pages', '~> 219', group: :jekyll_plugins 4 | gem 'jekyll-theme-minimal' 5 | 6 | gem "webrick", "~> 1.8" 7 | -------------------------------------------------------------------------------- /site/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (6.0.6.1) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | zeitwerk (~> 2.2, >= 2.2.2) 10 | addressable (2.8.1) 11 | public_suffix (>= 2.0.2, < 6.0) 12 | coffee-script (2.4.1) 13 | coffee-script-source 14 | execjs 15 | coffee-script-source (1.11.1) 16 | colorator (1.1.0) 17 | commonmarker (0.17.13) 18 | ruby-enum (~> 0.5) 19 | concurrent-ruby (1.2.0) 20 | dnsruby (1.61.9) 21 | simpleidn (~> 0.1) 22 | em-websocket (0.5.3) 23 | eventmachine (>= 0.12.9) 24 | http_parser.rb (~> 0) 25 | ethon (0.16.0) 26 | ffi (>= 1.15.0) 27 | eventmachine (1.2.7) 28 | execjs (2.8.1) 29 | faraday (2.7.4) 30 | faraday-net_http (>= 2.0, < 3.1) 31 | ruby2_keywords (>= 0.0.4) 32 | faraday-net_http (3.0.2) 33 | ffi (1.15.5) 34 | forwardable-extended (2.6.0) 35 | gemoji (3.0.1) 36 | github-pages (219) 37 | github-pages-health-check (= 1.17.7) 38 | jekyll (= 3.9.0) 39 | jekyll-avatar (= 0.7.0) 40 | jekyll-coffeescript (= 1.1.1) 41 | jekyll-commonmark-ghpages (= 0.1.6) 42 | jekyll-default-layout (= 0.1.4) 43 | jekyll-feed (= 0.15.1) 44 | jekyll-gist (= 1.5.0) 45 | jekyll-github-metadata (= 2.13.0) 46 | jekyll-mentions (= 1.6.0) 47 | jekyll-optional-front-matter (= 0.3.2) 48 | jekyll-paginate (= 1.1.0) 49 | jekyll-readme-index (= 0.3.0) 50 | jekyll-redirect-from (= 0.16.0) 51 | jekyll-relative-links (= 0.6.1) 52 | jekyll-remote-theme (= 0.4.3) 53 | jekyll-sass-converter (= 1.5.2) 54 | jekyll-seo-tag (= 2.7.1) 55 | jekyll-sitemap (= 1.4.0) 56 | jekyll-swiss (= 1.0.0) 57 | jekyll-theme-architect (= 0.2.0) 58 | jekyll-theme-cayman (= 0.2.0) 59 | jekyll-theme-dinky (= 0.2.0) 60 | jekyll-theme-hacker (= 0.2.0) 61 | jekyll-theme-leap-day (= 0.2.0) 62 | jekyll-theme-merlot (= 0.2.0) 63 | jekyll-theme-midnight (= 0.2.0) 64 | jekyll-theme-minimal (= 0.2.0) 65 | jekyll-theme-modernist (= 0.2.0) 66 | jekyll-theme-primer (= 0.6.0) 67 | jekyll-theme-slate (= 0.2.0) 68 | jekyll-theme-tactile (= 0.2.0) 69 | jekyll-theme-time-machine (= 0.2.0) 70 | jekyll-titles-from-headings (= 0.5.3) 71 | jemoji (= 0.12.0) 72 | kramdown (= 2.3.1) 73 | kramdown-parser-gfm (= 1.1.0) 74 | liquid (= 4.0.3) 75 | mercenary (~> 0.3) 76 | minima (= 2.5.1) 77 | nokogiri (>= 1.10.4, < 2.0) 78 | rouge (= 3.26.0) 79 | terminal-table (~> 1.4) 80 | github-pages-health-check (1.17.7) 81 | addressable (~> 2.3) 82 | dnsruby (~> 1.60) 83 | octokit (~> 4.0) 84 | public_suffix (>= 3.0, < 5.0) 85 | typhoeus (~> 1.3) 86 | html-pipeline (2.14.3) 87 | activesupport (>= 2) 88 | nokogiri (>= 1.4) 89 | http_parser.rb (0.8.0) 90 | i18n (0.9.5) 91 | concurrent-ruby (~> 1.0) 92 | jekyll (3.9.0) 93 | addressable (~> 2.4) 94 | colorator (~> 1.0) 95 | em-websocket (~> 0.5) 96 | i18n (~> 0.7) 97 | jekyll-sass-converter (~> 1.0) 98 | jekyll-watch (~> 2.0) 99 | kramdown (>= 1.17, < 3) 100 | liquid (~> 4.0) 101 | mercenary (~> 0.3.3) 102 | pathutil (~> 0.9) 103 | rouge (>= 1.7, < 4) 104 | safe_yaml (~> 1.0) 105 | jekyll-avatar (0.7.0) 106 | jekyll (>= 3.0, < 5.0) 107 | jekyll-coffeescript (1.1.1) 108 | coffee-script (~> 2.2) 109 | coffee-script-source (~> 1.11.1) 110 | jekyll-commonmark (1.3.1) 111 | commonmarker (~> 0.14) 112 | jekyll (>= 3.7, < 5.0) 113 | jekyll-commonmark-ghpages (0.1.6) 114 | commonmarker (~> 0.17.6) 115 | jekyll-commonmark (~> 1.2) 116 | rouge (>= 2.0, < 4.0) 117 | jekyll-default-layout (0.1.4) 118 | jekyll (~> 3.0) 119 | jekyll-feed (0.15.1) 120 | jekyll (>= 3.7, < 5.0) 121 | jekyll-gist (1.5.0) 122 | octokit (~> 4.2) 123 | jekyll-github-metadata (2.13.0) 124 | jekyll (>= 3.4, < 5.0) 125 | octokit (~> 4.0, != 4.4.0) 126 | jekyll-mentions (1.6.0) 127 | html-pipeline (~> 2.3) 128 | jekyll (>= 3.7, < 5.0) 129 | jekyll-optional-front-matter (0.3.2) 130 | jekyll (>= 3.0, < 5.0) 131 | jekyll-paginate (1.1.0) 132 | jekyll-readme-index (0.3.0) 133 | jekyll (>= 3.0, < 5.0) 134 | jekyll-redirect-from (0.16.0) 135 | jekyll (>= 3.3, < 5.0) 136 | jekyll-relative-links (0.6.1) 137 | jekyll (>= 3.3, < 5.0) 138 | jekyll-remote-theme (0.4.3) 139 | addressable (~> 2.0) 140 | jekyll (>= 3.5, < 5.0) 141 | jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) 142 | rubyzip (>= 1.3.0, < 3.0) 143 | jekyll-sass-converter (1.5.2) 144 | sass (~> 3.4) 145 | jekyll-seo-tag (2.7.1) 146 | jekyll (>= 3.8, < 5.0) 147 | jekyll-sitemap (1.4.0) 148 | jekyll (>= 3.7, < 5.0) 149 | jekyll-swiss (1.0.0) 150 | jekyll-theme-architect (0.2.0) 151 | jekyll (> 3.5, < 5.0) 152 | jekyll-seo-tag (~> 2.0) 153 | jekyll-theme-cayman (0.2.0) 154 | jekyll (> 3.5, < 5.0) 155 | jekyll-seo-tag (~> 2.0) 156 | jekyll-theme-dinky (0.2.0) 157 | jekyll (> 3.5, < 5.0) 158 | jekyll-seo-tag (~> 2.0) 159 | jekyll-theme-hacker (0.2.0) 160 | jekyll (> 3.5, < 5.0) 161 | jekyll-seo-tag (~> 2.0) 162 | jekyll-theme-leap-day (0.2.0) 163 | jekyll (> 3.5, < 5.0) 164 | jekyll-seo-tag (~> 2.0) 165 | jekyll-theme-merlot (0.2.0) 166 | jekyll (> 3.5, < 5.0) 167 | jekyll-seo-tag (~> 2.0) 168 | jekyll-theme-midnight (0.2.0) 169 | jekyll (> 3.5, < 5.0) 170 | jekyll-seo-tag (~> 2.0) 171 | jekyll-theme-minimal (0.2.0) 172 | jekyll (> 3.5, < 5.0) 173 | jekyll-seo-tag (~> 2.0) 174 | jekyll-theme-modernist (0.2.0) 175 | jekyll (> 3.5, < 5.0) 176 | jekyll-seo-tag (~> 2.0) 177 | jekyll-theme-primer (0.6.0) 178 | jekyll (> 3.5, < 5.0) 179 | jekyll-github-metadata (~> 2.9) 180 | jekyll-seo-tag (~> 2.0) 181 | jekyll-theme-slate (0.2.0) 182 | jekyll (> 3.5, < 5.0) 183 | jekyll-seo-tag (~> 2.0) 184 | jekyll-theme-tactile (0.2.0) 185 | jekyll (> 3.5, < 5.0) 186 | jekyll-seo-tag (~> 2.0) 187 | jekyll-theme-time-machine (0.2.0) 188 | jekyll (> 3.5, < 5.0) 189 | jekyll-seo-tag (~> 2.0) 190 | jekyll-titles-from-headings (0.5.3) 191 | jekyll (>= 3.3, < 5.0) 192 | jekyll-watch (2.2.1) 193 | listen (~> 3.0) 194 | jemoji (0.12.0) 195 | gemoji (~> 3.0) 196 | html-pipeline (~> 2.2) 197 | jekyll (>= 3.0, < 5.0) 198 | kramdown (2.3.1) 199 | rexml 200 | kramdown-parser-gfm (1.1.0) 201 | kramdown (~> 2.0) 202 | liquid (4.0.3) 203 | listen (3.8.0) 204 | rb-fsevent (~> 0.10, >= 0.10.3) 205 | rb-inotify (~> 0.9, >= 0.9.10) 206 | mercenary (0.3.6) 207 | minima (2.5.1) 208 | jekyll (>= 3.5, < 5.0) 209 | jekyll-feed (~> 0.9) 210 | jekyll-seo-tag (~> 2.1) 211 | minitest (5.17.0) 212 | nokogiri (1.14.0-arm64-darwin) 213 | racc (~> 1.4) 214 | nokogiri (1.14.0-x86_64-darwin) 215 | racc (~> 1.4) 216 | nokogiri (1.14.0-x86_64-linux) 217 | racc (~> 1.4) 218 | octokit (4.25.1) 219 | faraday (>= 1, < 3) 220 | sawyer (~> 0.9) 221 | pathutil (0.16.2) 222 | forwardable-extended (~> 2.6) 223 | public_suffix (4.0.7) 224 | racc (1.6.2) 225 | rb-fsevent (0.11.2) 226 | rb-inotify (0.10.1) 227 | ffi (~> 1.0) 228 | rexml (3.2.5) 229 | rouge (3.26.0) 230 | ruby-enum (0.9.0) 231 | i18n 232 | ruby2_keywords (0.0.5) 233 | rubyzip (2.3.2) 234 | safe_yaml (1.0.5) 235 | sass (3.7.4) 236 | sass-listen (~> 4.0.0) 237 | sass-listen (4.0.0) 238 | rb-fsevent (~> 0.9, >= 0.9.4) 239 | rb-inotify (~> 0.9, >= 0.9.7) 240 | sawyer (0.9.2) 241 | addressable (>= 2.3.5) 242 | faraday (>= 0.17.3, < 3) 243 | simpleidn (0.2.1) 244 | unf (~> 0.1.4) 245 | terminal-table (1.8.0) 246 | unicode-display_width (~> 1.1, >= 1.1.1) 247 | thread_safe (0.3.6) 248 | typhoeus (1.4.0) 249 | ethon (>= 0.9.0) 250 | tzinfo (1.2.10) 251 | thread_safe (~> 0.1) 252 | unf (0.1.4) 253 | unf_ext 254 | unf_ext (0.0.8.2) 255 | unicode-display_width (1.8.0) 256 | webrick (1.8.1) 257 | zeitwerk (2.6.6) 258 | 259 | PLATFORMS 260 | arm64-darwin-21 261 | x86_64-darwin-20 262 | x86_64-linux 263 | 264 | DEPENDENCIES 265 | github-pages (~> 219) 266 | jekyll-theme-minimal 267 | webrick (~> 1.8) 268 | 269 | BUNDLED WITH 270 | 2.3.9 271 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | # Just Not Sorry web page 2 | 3 | The Just Not Sorry web site is hosted on GitHub Pages at both https://justnotsorry.com and https://defmethodinc.github.io/just-not-sorry/ 4 | 5 | It is built using [Jekyll](https://jekyllrb.com/) and a customized version of the [minimal theme](https://github.com/pages-themes/minimal). 6 | 7 | ## Development 8 | 9 | Prerequisites: 10 | 11 | - ruby 12 | - bundler 13 | 14 | To install dependencies: 15 | 16 | ``` 17 | bundle install 18 | ``` 19 | 20 | Previewing the site locally: 21 | 22 | ``` 23 | bundle exec jekyll serve 24 | ``` 25 | 26 | To make changes, create a new branch, commit your changes to the `site` directory, and submit a PR against the `main` branch. Once the PR is merged, a GitHub Actions CI build will build the site and push it to the `gh-pages` remote branch, which will trigger a deployment to https://justnotsorry.com. 27 | 28 | Note that the "List of Warning Phrases" page is dynamically generated, so any changes to the warning phrases (`../src/warnings/phrases.json`) on the `main` branch will trigger an update to justnotsorry.com. 29 | -------------------------------------------------------------------------------- /site/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | logo: img/JustNotSorry-logo.png 3 | title: Just Not Sorry -------------------------------------------------------------------------------- /site/_data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/_data/.keep -------------------------------------------------------------------------------- /site/_includes/banner.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /site/_includes/extension-description.html: -------------------------------------------------------------------------------- 1 | The Chrome Extension for {% include supported-sites.html %} that helps you send more confident emails by warning you when you use words which undermine your message. -------------------------------------------------------------------------------- /site/_includes/head-custom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head-custom-google-analytics.html %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /site/_includes/supported-sites.html: -------------------------------------------------------------------------------- 1 | 2 | Gmail Gmail and 4 | Outlook Webmail Outlook for web 6 | -------------------------------------------------------------------------------- /site/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% seo %} 9 | 10 | 13 | {% include head-custom.html %} 14 | 15 | 16 |
17 |
18 | {% if site.logo %} 19 | 20 | {% endif %} 21 | 22 |

{{ site.title | default: site.github.repository_name }}

23 | 24 |

{% include extension-description.html %}

25 | 26 | 34 | 35 |
36 |
37 | {% include banner.html %} 38 | {{ content }} 39 | 40 |
41 |
42 |

This project is maintained by
43 | 44 |

45 |

46 | Hosted on GitHub Pages / Theme by orderedlist
47 | Site version {{site.data.version.commit}} / {{ site.time | date: '%B %d, %Y' }} 48 |

49 |
50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /site/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "{{ site.theme }}"; 5 | 6 | a:hover, a:focus { 7 | font-weight: revert; 8 | text-decoration: underline; 9 | } 10 | a.button { 11 | cursor: pointer!important; 12 | border: 0.2em solid #267CB9; 13 | text-decoration: none; 14 | color: white; 15 | background-color: #267CB9; 16 | border-radius: 1em; 17 | padding: 0.5em; 18 | transition: border-color .8s ease 0s, background-color .8s ease 0s; 19 | box-shadow: 0 1px 4px rgba(0,0,0,0.6); 20 | } 21 | a:hover.button, a:focus.button { 22 | font-weight: normal; 23 | background-color: #069; 24 | border-color: #069; 25 | transition: border-color .4s ease 0s, background-color .4s ease 0s; 26 | } 27 | .h-center { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | } 32 | table ul { 33 | padding: 0; 34 | margin: 0; 35 | list-style-type: none; 36 | } 37 | .gallery { 38 | width: 100%; 39 | display: flex; 40 | flex-wrap: wrap; 41 | justify-content: space-evenly; 42 | align-items: center; 43 | margin-bottom: 15px; 44 | gap: 12px; 45 | } 46 | .gallery img { 47 | height: 25px; 48 | } 49 | .gallery img.tall { 50 | height: 40px; 51 | } 52 | .wrapper { 53 | width: 920px; 54 | } 55 | section { 56 | width: 560px; 57 | } 58 | footer { 59 | bottom: 0; 60 | } 61 | li.active a { 62 | color: #069; 63 | font-weight: bold; 64 | cursor: default; 65 | } 66 | li.active a:hover, li.active a:focus { 67 | text-decoration: none; 68 | } 69 | @media print, screen and (max-width: 960px) { 70 | section { 71 | width: auto; 72 | } 73 | } 74 | @media print, screen and (max-width: 480px) { 75 | .logo { 76 | width: 50%; 77 | } 78 | } 79 | .video-container { 80 | position: relative; 81 | padding-bottom: 56.25%; 82 | height: 0; 83 | overflow: hidden; 84 | max-width: 100%; 85 | margin-bottom: 20px; 86 | } 87 | .video-container iframe, 88 | .video-container object, 89 | .video-container embed { 90 | position: absolute; 91 | top: 0; 92 | left: 0; 93 | width: 100%; 94 | height: 100%; 95 | } 96 | 97 | $color-banner: gold; 98 | $color-banner-ends: darken($color-banner, 5); 99 | 100 | .banner { 101 | padding: 10px; 102 | background: $color-banner; 103 | position: relative; 104 | box-shadow: 0 3px 1px -1px rgba(0, 0, 0, 0.4); 105 | margin: 0 auto 30px; 106 | top: 12px; 107 | height: 20px; 108 | 109 | &:before, &:after { 110 | content: ""; 111 | width: 12px; 112 | bottom: -10px; 113 | position: absolute; 114 | display: block; 115 | border: 20px solid $color-banner-ends; 116 | box-shadow: 0 2px 1px -1px rgba(0, 0, 0, 0.4); 117 | z-index: -2; 118 | } 119 | 120 | &:before { 121 | border-color: $color-banner-ends $color-banner-ends $color-banner-ends transparent; 122 | left: -36px; 123 | border-right-width: 1.05em; 124 | } 125 | 126 | &:after { 127 | border-color: $color-banner-ends transparent $color-banner-ends $color-banner-ends; 128 | right: -36px; 129 | border-left-width: 1.05em; 130 | } 131 | } 132 | 133 | .inner-banner { 134 | &:before, &:after { 135 | content: ""; 136 | bottom: -0.75em; 137 | position: absolute; 138 | display: block; 139 | border-style: solid; 140 | border-color: #555 transparent transparent transparent; 141 | z-index: -1; 142 | } 143 | 144 | &:before { 145 | left: 0; 146 | border-width: .8em 0 0 .8em; 147 | } 148 | 149 | &:after { 150 | right: 0; 151 | border-width: .8em .8em 0 0; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /site/img/CNBC.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 15 | 17 | 21 | 29 | 32 | 36 | 39 | 41 | 43 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /site/img/FastCompany.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 24 | 28 | 29 | 32 | 36 | 37 | 40 | 44 | 45 | 47 | 51 | 52 | 74 | 76 | 77 | 79 | image/svg+xml 80 | 82 | 83 | 84 | 85 | 86 | 91 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /site/img/Forbes.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 16 | 18 | image/svg+xml 19 | 21 | 22 | 23 | 24 | 25 | 27 | 31 | 32 | -------------------------------------------------------------------------------- /site/img/Glamour.svg: -------------------------------------------------------------------------------- 1 | Glamour 2 | -------------------------------------------------------------------------------- /site/img/Gmail.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | logo 26 | 27 | 28 | 29 | 49 | 50 | logo 52 | Created with Sketch. 54 | 56 | 59 | 65 | 71 | 77 | 83 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /site/img/JustNotSorry-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/JustNotSorry-logo.png -------------------------------------------------------------------------------- /site/img/NPR.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 23 | 27 | 28 | 31 | 35 | 36 | 39 | 43 | 44 | 47 | 51 | 52 | 55 | 59 | 60 | 63 | 68 | 69 | 72 | 76 | 77 | 80 | 84 | 85 | 88 | 92 | 93 | 96 | 100 | 101 | 102 | 124 | 133 | 134 | 136 | 137 | 139 | image/svg+xml 140 | 142 | 143 | 144 | 145 | 146 | 151 | 156 | 161 | 166 | 169 | 174 | 175 | 178 | 183 | 184 | 187 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /site/img/NewYorkTimes.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/img/Outlook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ]> 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 48 | 52 | 55 | 58 | 61 | 63 | 66 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | 78 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /site/img/Slate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 42 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 64 | 69 | 75 | 80 | 85 | 90 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /site/img/Today.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | 28 | 29 | 30 | 31 | 33 | 58 | 61 | 64 | 69 | 74 | 79 | 80 | 83 | 88 | 94 | 100 | 105 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /site/img/Vogue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /site/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /site/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /site/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/apple-touch-icon.png -------------------------------------------------------------------------------- /site/img/defmethod-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/defmethod-logo.png -------------------------------------------------------------------------------- /site/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/favicon-16x16.png -------------------------------------------------------------------------------- /site/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/favicon-32x32.png -------------------------------------------------------------------------------- /site/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defmethodinc/just-not-sorry/b02a8ced23c3543af237bae6e24aa04eb55586c6/site/img/favicon.ico -------------------------------------------------------------------------------- /site/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | 5 |
6 | 7 |
8 | 9 |

10 | Install Just Not Sorry 11 |

12 | 13 | {% include extension-description.html %} 14 | 15 | Just Not Sorry has been covered in [NYTimes](https://www.nytimes.com/2019/09/27/style/exclamation-points.html), [Glamour](https://www.glamourmagazine.co.uk/article/just-not-sorry-app), [Vogue](https://www.vogue.com/article/just-not-sorry-plugin), The Today Show, [CNBC](https://www.cnbc.com/2019/04/16/saying-im-sorry-can-make-people-think-poorly-of-you-research-heres-what-successful-people-do-instead.html), [Slate](https://slate.com/human-interest/2015/12/new-chrome-app-helps-women-stop-saying-just-and-sorry-in-emails.html), [NPR](https://www.npr.org/2016/01/01/461714341/just-not-sorry-gmail-tackles-qualifying-words-in-professional-communication), [Fast Company](https://www.fastcompany.com/3055071/new-gmail-plug-in-highlights-words-and-phrases-that-undermine-your-messag), [Forbes](https://www.forbes.com/sites/carolinecastrillon/2019/07/14/how-women-can-stop-apologizing-and-take-their-power-back/), and many many more news outlets. 16 | 17 | 28 | 29 | Inspired by the writings of Tara Mohr and others, Just Not Sorry will warn you when you use words or phrases that undermine your message. Commonly used qualifying words and phrases are underlined for you to choose how you want to address them. 30 | 31 | Created by [Tami Reiss](https://linkedin.com/in/tamireiss), Steve Brudz, Manish Kakwani, and Eric Tillberg of [Def Method](https://www.defmethod.com/). Read the [backstory here](https://medium.com/@tamireiss/just-not-sorry-the-backstory-33f54b30fe48). 32 | -------------------------------------------------------------------------------- /site/phrases.md: -------------------------------------------------------------------------------- 1 | # List of Warning Phrases 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% for phrase in site.data.phrases %} 12 | 13 | 20 | 23 | 24 | {% endfor %} 25 | 26 |
phrasemessage
14 |
    15 | {% for label in phrase.displayLabel %} 16 |
  • "{{label}}"
  • 17 | {% endfor %} 18 |
19 |
21 | {{phrase.message}} source 22 |
27 | -------------------------------------------------------------------------------- /site/releases.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ### v2.4.0 - Nov 22, 2022 4 | 5 | - Upgraded to [v3 of the Chrome Web Extension Manifest](https://developer.chrome.com/docs/extensions/mv3/intro/) to stay current with Chrome Web Store requirements 6 | 7 | ### v2.3.0 - Mar 31, 2022 8 | 9 | - Added "if that makes sense" as a warning 10 | 11 | ### v2.2.0 - Mar 31, 2022 12 | 13 | - Added support for Outlook for web using the [https://outlook.office365.com](https://outlook.office365.com) url 14 | 15 | ### v2.1.2 - Jan 13, 2022 16 | 17 | - Added support for Outlook for web - [https://outlook.live.com/](https://outlook.live.com/) and [https://outlook.office.com/](https://outlook.office.com/) 18 | - Major rewrite of code for better maintainability 19 | - Added display of all warning phrases to the options UI, along with links to the source articles. Click the JustNotSorry extension button in your toolbar to see them. 20 | - Improved message box when you hover over an underline 21 | 22 | ### v1.6.3 - Apr 27, 2020 23 | 24 | - Performance enhancements for longer emails 25 | 26 | ### v1.6.2 - Apr 20, 2020 27 | 28 | - Fixed issue with JustNotSorry breaking Send button in Gmail. 29 | 30 | ### v1.6.1 - Apr 15, 2020 31 | 32 | - Fixed issue with positioning of underlines 33 | - Removed Google Inbox support because Inbox has been retired 34 | 35 | ### v1.5.1 - Feb 13, 2017 36 | 37 | - Quick fix for crashing tab when used with certain other extensions. 38 | 39 | ### v1.5.0 - Feb 6, 2017 40 | 41 | - This release adds Inbox support (in addition to the existing Gmail support) and fixes a couple of small UX bugs. 42 | 43 | ### v1.0.0 - Apr 3, 2016 44 | 45 | - Additional warning phrases 46 | - Improved highlighting 47 | 48 | ### v0.2.6 - Jan 18, 2016 49 | 50 | - This release fixes issues with the cursor jumping to the previous paragraph when all the characters in a paragraph are deleted. 51 | 52 | ### v0.2.1 - Dec 18, 2015 53 | 54 | - The v0.2.0 package was missing options.html so the extension would not install. This release fixes that problem. 55 | 56 | ### v0.2.0 - Dec 18, 2015 57 | 58 | - Capture data about who is installing JustNotSorry via Google Analytics. 59 | 60 | ### v0.1.3 - Dec 18, 2015 61 | 62 | - Fixed issue where Gmail would crash when both JustNotSorry and SalesforceIQ Chrome Extensions were installed. 63 | 64 | ### v0.1.2 - Dec 16, 2015 65 | 66 | - The first public release that was published to the Chrome Web Store. 67 | -------------------------------------------------------------------------------- /site/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"Just Not Sorry","short_name":"Just Not Sorry","icons":[{"src":"/img/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/img/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /spec/ContentEditableDivSpec.test.js: -------------------------------------------------------------------------------- 1 | import { forEachUniqueContentEditable } from '../src/callbacks/ContentEditableDiv.js'; 2 | 3 | const buildMutation = (type, target) => { 4 | return { type, target }; 5 | }; 6 | 7 | describe('handleContentEditableDivChange', () => { 8 | let mockCallback, handler; 9 | beforeEach(() => { 10 | mockCallback = jest.fn((mutation) => mutation); 11 | handler = forEachUniqueContentEditable(mockCallback); 12 | }); 13 | describe('when target is node with contentEditable attribute', () => { 14 | it('should not call the action if mutation is not type childList', () => { 15 | const div = document.createElement('div'); 16 | div.setAttribute('contentEditable', 'true'); 17 | 18 | const mutation = buildMutation('characterData', div); 19 | handler([mutation]); 20 | 21 | expect(mockCallback.mock.calls.length).toBe(0); 22 | }); 23 | 24 | it('should not call the action callback more than once for each unique mutation of type childList', () => { 25 | const div = document.createElement('div'); 26 | div.setAttribute('contentEditable', 'true'); 27 | div.setAttribute('id', 'uniqueID'); 28 | 29 | const mutation = buildMutation('childList', div); 30 | handler([mutation]); 31 | handler([mutation]); 32 | expect(mockCallback.mock.calls.length).toBe(1); 33 | }); 34 | 35 | it('should call the action callback once for each unique mutation of type childList', () => { 36 | const div = document.createElement('div'); 37 | div.setAttribute('contentEditable', 'true'); 38 | 39 | const mutation = buildMutation('childList', div); 40 | handler([mutation]); 41 | 42 | expect(mockCallback.mock.calls.length).toBe(1); 43 | expect(mockCallback.mock.calls[0][0]).toBe(mutation); 44 | }); 45 | }); 46 | 47 | describe('when target is node without contentEditable attribute', () => { 48 | it('should not call the action callback for each mutation of type childList', () => { 49 | const div = document.createElement('div'); 50 | 51 | const mutation = buildMutation('childList', div); 52 | handler([mutation]); 53 | 54 | expect(mockCallback.mock.calls.length).toBe(0); 55 | }); 56 | 57 | it('should not call the action if mutation is not type childList', () => { 58 | const div = document.createElement('div'); 59 | 60 | const mutation = buildMutation('characterData', div); 61 | handler([mutation]); 62 | 63 | expect(mockCallback.mock.calls.length).toBe(0); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /spec/JustNotSorrySpec.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import JustNotSorry from '../src/components/JustNotSorry.js'; 3 | import { fireEvent, render, screen, waitFor } from '@testing-library/react'; 4 | import { act } from 'react-dom/test-utils'; 5 | 6 | jest.useFakeTimers(); 7 | 8 | document.createRange = jest.fn(() => ({ 9 | setStart: jest.fn(), 10 | setEnd: jest.fn(), 11 | commonAncestorContainer: { 12 | nodeName: 'BODY', 13 | ownerDocument: document, 14 | }, 15 | startContainer: 16 | "The word 'very' does not communicate enough information. Find a stronger, more meaningful adverb, or omit it completely. --Andrea Ayres", 17 | getClientRects: jest.fn(() => [{}]), 18 | })); 19 | 20 | const emailContaining = (text) => { 21 | const email = document.createElement('div'); 22 | email.setAttribute('id', 'test'); 23 | email.setAttribute('contentEditable', 'true'); 24 | email.append(text ?? 'some text'); 25 | document.body.appendChild(email); 26 | return email; 27 | }; 28 | 29 | describe('JustNotSorry', () => { 30 | let mutationObserverMock; 31 | 32 | const simulateEvent = (node, event) => { 33 | act(() => { 34 | expect(mutationObserverMock.mock.instances.length).toBe(1); 35 | const documentObserver = mutationObserverMock.mock.instances[0]; 36 | documentObserver.trigger([{ type: 'childList', target: node }]); 37 | expect(fireEvent[event](node)).toBe(true); 38 | jest.runOnlyPendingTimers(); 39 | }); 40 | }; 41 | 42 | beforeEach(() => { 43 | mutationObserverMock = jest.fn(function MutationObserver(callback) { 44 | this.observe = jest.fn(); 45 | this.disconnect = jest.fn(); 46 | this.trigger = (mockedMutationList) => { 47 | callback(mockedMutationList, this); 48 | }; 49 | }); 50 | global.MutationObserver = mutationObserverMock; 51 | }); 52 | 53 | it('listens for structural changes to the content editable div in document body', () => { 54 | render(); 55 | 56 | const observerInstances = mutationObserverMock.mock.instances; 57 | expect(observerInstances.length).toBe(1); 58 | expect(observerInstances[0].observe).toHaveBeenCalledWith(document.body, { 59 | childList: true, 60 | subtree: true, 61 | }); 62 | }); 63 | 64 | it('on event does nothing when given an empty string', async () => { 65 | const email = emailContaining(''); 66 | render( 67 | 76 | ); 77 | simulateEvent(email, 'focus'); 78 | await waitFor(() => { 79 | expect(screen.queryAllByTestId('jns-warning').length).toEqual(0); 80 | }); 81 | }); 82 | 83 | it('on event checks for warnings', async () => { 84 | const email = emailContaining('just not'); 85 | render( 86 | 95 | ); 96 | simulateEvent(email, 'focus'); 97 | 98 | await waitFor(() => { 99 | expect(screen.getAllByTestId('jns-warning').length).toEqual(1); 100 | }); 101 | }); 102 | 103 | it('should clear warnings on blur event', async () => { 104 | const div = emailContaining('just not'); 105 | 106 | render( 107 | 116 | ); 117 | simulateEvent(div, 'blur'); 118 | 119 | await waitFor(() => { 120 | expect(screen.queryAllByTestId('jns-warning').length).toEqual(0); 121 | }); 122 | }); 123 | 124 | it('does not add warnings for partial matches', async () => { 125 | const email = emailContaining('test justify test'); 126 | 127 | render( 128 | 137 | ); 138 | simulateEvent(email, 'focus'); 139 | 140 | await waitFor(() => { 141 | expect(screen.queryAllByTestId('jns-warning').length).toEqual(0); 142 | }); 143 | }); 144 | 145 | it('catches the warnings when email contains div with phrase', async () => { 146 | const div = emailContaining(`just not 147 |
just not
`); 148 | 149 | render( 150 | 159 | ); 160 | simulateEvent(div, 'focus'); 161 | 162 | await waitFor(() => { 163 | expect(screen.getAllByTestId('jns-warning').length).toEqual(2); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /spec/RangeFinderSpec.test.js: -------------------------------------------------------------------------------- 1 | import { calculateWarnings } from '../src/helpers/RangeFinder'; 2 | 3 | describe('RangeFinder', () => { 4 | const PHRASE_TO_FIND = { 5 | regex: new RegExp('textToFind', 'ig'), 6 | message: 'text found!', 7 | }; 8 | let element; 9 | beforeEach(() => { 10 | element = document.createElement('div'); 11 | }); 12 | it('should return empty array if given empty array', () => { 13 | const ranges = calculateWarnings(element, []); 14 | expect(ranges.length).toBe(0); 15 | }); 16 | 17 | it('should return array with message and valid range for each match found', () => { 18 | element.textContent = 'textToFind'; 19 | 20 | const ranges = calculateWarnings(element, [PHRASE_TO_FIND]); 21 | 22 | expect(ranges.length).toBe(1); 23 | expect(ranges).toEqual([ 24 | { message: 'text found!', rangeToHighlight: new Range() }, 25 | ]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /spec/UtilSpec.test.js: -------------------------------------------------------------------------------- 1 | import * as Util from '../src/helpers/util.js'; 2 | 3 | describe('Util', () => { 4 | describe('#debounce', () => { 5 | let func; 6 | let debouncedFunc; 7 | 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | func = jest.fn(); 11 | debouncedFunc = Util.debounce(func, Util.WAIT_TIME); 12 | }); 13 | 14 | it('should execute a function only once if the debounced function is invoked several times within the wait time', () => { 15 | for (let i = 0; i < 3; i++) { 16 | debouncedFunc(); 17 | } 18 | jest.runAllTimers(); 19 | expect(func).toBeCalledTimes(1); 20 | }); 21 | 22 | it('should execute a function as many times as the debounced function if called beyond the wait time', () => { 23 | for (let i = 0; i < 3; i++) { 24 | setTimeout(() => debouncedFunc(), Util.WAIT_TIME + 100); 25 | jest.advanceTimersByTime(Util.WAIT_TIME + 100); 26 | expect(func).toBeCalledTimes(i); 27 | } 28 | jest.runAllTimers(); 29 | expect(func).toBeCalledTimes(3); 30 | }); 31 | }); 32 | 33 | describe('match', () => { 34 | it('should find a match if it exists', () => { 35 | const div = document.createElement('div'); 36 | div.textContent = 'just checking'; 37 | 38 | const ranges = Util.match(div, /just/gi); 39 | expect(ranges.length).toEqual(1); 40 | }); 41 | 42 | it('should find multiple a match if it exists', () => { 43 | const div = document.createElement('div'); 44 | div.textContent = 'just checking just'; 45 | 46 | const ranges = Util.match(div, /just/gi); 47 | expect(ranges.length).toEqual(2); 48 | }); 49 | 50 | it('should not find a match if it doesnt exist', () => { 51 | const div = document.createElement('div'); 52 | div.textContent = 'just checking'; 53 | 54 | const ranges = Util.match(div, /bogus/gi); 55 | expect(ranges.length).toEqual(0); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /spec/WarningHighlightSpec.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WarningHighlight from '../src/components/WarningHighlight.js'; 3 | import { render, waitFor, screen } from '@testing-library/react'; 4 | 5 | describe('', () => { 6 | const testProps = { 7 | style: { 8 | left: '10px', 9 | height: '3px', 10 | width: '25px', 11 | }, 12 | message: 'test-message', 13 | }; 14 | 15 | beforeEach(() => { 16 | render( 17 | 30 | ); 31 | }); 32 | 33 | it('should return a highlight div', async () => { 34 | await waitFor(() => { 35 | const jnsHighlights = screen.getAllByTestId('jns-highlight'); 36 | expect(jnsHighlights.length).toBe(1); 37 | expect(jnsHighlights[0].tagName).toEqual('DIV'); 38 | }); 39 | }); 40 | 41 | it('should have the correct data and style attributes', async () => { 42 | await waitFor(() => { 43 | const jnsHighlights = screen.getAllByTestId('jns-highlight'); 44 | expect(jnsHighlights.length).toBe(1); 45 | const jnsHighlight = jnsHighlights[0]; 46 | expect(jnsHighlight.tagName).toEqual('DIV'); 47 | expect(jnsHighlight.dataset.tooltipContent).toEqual('test-message'); 48 | expect(jnsHighlight).toHaveStyle('left: 10px; height: 3px; width: 25px;'); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /spec/WarningMessagesSpec.test.js: -------------------------------------------------------------------------------- 1 | import WARNING_MESSAGES from '../src/warnings/phrases.json'; 2 | 3 | describe('WARNING_MESSAGES', () => { 4 | function isBlank(str) { 5 | return !str || /^\s*$/.test(str); 6 | } 7 | 8 | // from http://stackoverflow.com/a/8317014 9 | function isValidUrl(str) { 10 | return ( 11 | str && 12 | /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( 13 | str 14 | ) 15 | ); 16 | } 17 | 18 | // from http://stackoverflow.com/a/14313213 19 | function findNonASCIIChars(str) { 20 | // eslint-disable-next-line no-control-regex 21 | return str.match(/[^\x00-\x7F]+/g); 22 | } 23 | 24 | function findIndexOfFirstNonASCIIChar(str) { 25 | // eslint-disable-next-line no-control-regex 26 | return str.search(/[^\x00-\x7F]+/); 27 | } 28 | 29 | it('contains an array of warnings', () => { 30 | expect(WARNING_MESSAGES instanceof Array).toBeTruthy(); 31 | }); 32 | 33 | WARNING_MESSAGES.forEach(function (warning, index) { 34 | describe( 35 | 'for warning at index ' + index + ' (pattern: "' + warning.pattern + '")', 36 | () => { 37 | describe('the pattern', () => { 38 | it('should be present', () => { 39 | expect( 40 | Object.prototype.hasOwnProperty.call(warning, 'pattern') 41 | ).toBeTruthy(); 42 | }); 43 | 44 | it('should be non-blank', () => { 45 | expect(isBlank(warning.pattern)).toBeFalsy(); 46 | }); 47 | }); 48 | 49 | describe('the displayLabel', () => { 50 | it('should be present', () => { 51 | expect(isBlank(warning.displayLabel)).toBeFalsy(); 52 | }); 53 | it('should be an array', () => { 54 | expect(Array.isArray(warning.displayLabel)).toBeTruthy(); 55 | }); 56 | }); 57 | describe('the source', () => { 58 | it('should be present', () => { 59 | expect( 60 | Object.prototype.hasOwnProperty.call(warning, 'source') 61 | ).toBeTruthy(); 62 | }); 63 | 64 | it('should be non-blank', () => { 65 | expect(isBlank(warning.source)).toBeFalsy(); 66 | }); 67 | 68 | it('should be a valid url', () => { 69 | expect(isValidUrl(warning.source)).toBeTruthy(); 70 | }); 71 | }); 72 | 73 | describe('the message', () => { 74 | it('should be present', () => { 75 | expect( 76 | Object.prototype.hasOwnProperty.call(warning, 'message') 77 | ).toBeTruthy(); 78 | }); 79 | 80 | it('should be non-blank', () => { 81 | expect(isBlank(warning.message)).toBeFalsy(); 82 | }); 83 | 84 | it('should contain only ASCII characters', () => { 85 | expect(findNonASCIIChars(warning.message)).toEqual(null); 86 | expect(findIndexOfFirstNonASCIIChar(warning.message)).toEqual(-1); 87 | }); 88 | }); 89 | } 90 | ); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /spec/WarningSpec.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitFor, screen } from '@testing-library/react'; 3 | import Warning from '../src/components/Warning.js'; 4 | 5 | describe('', () => { 6 | it('should not return a warning div', async () => { 7 | render(); 8 | await waitFor(() => { 9 | const jnsWarnings = screen.queryAllByTestId('jns-warning'); 10 | expect(jnsWarnings.length).toBe(0); 11 | }); 12 | }); 13 | 14 | it('should return a warning div', async () => { 15 | const rangeToHighlight = { 16 | setStart: jest.fn(), 17 | setEnd: jest.fn(), 18 | commonAncestorContainer: { 19 | nodeName: 'BODY', 20 | ownerDocument: document, 21 | }, 22 | getClientRects: jest.fn(() => []), 23 | }; 24 | 25 | render( 26 | 31 | ); 32 | await waitFor(() => { 33 | const jnsWarnings = screen.getAllByTestId('jns-warning'); 34 | expect(jnsWarnings.length).toBe(1); 35 | expect(jnsWarnings[0].tagName).toEqual('DIV'); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /spec/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | // Work-around for jsdom not supporting offsetParent 4 | // https://github.com/jsdom/jsdom/issues/1261 5 | Object.defineProperty(HTMLElement.prototype, 'offsetParent', { 6 | get() { 7 | return this.parentNode; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/callbacks/ContentEditableDiv.js: -------------------------------------------------------------------------------- 1 | const isEmailMessageBody = ({ target, type }) => 2 | target.hasAttribute('contentEditable') && type === 'childList'; 3 | 4 | export const forEachUniqueContentEditable = (action) => { 5 | const uniqueIds = new Set(); 6 | let tempId = 0; 7 | return (mutations) => { 8 | mutations.forEach((mutation) => { 9 | if (isEmailMessageBody(mutation) && !uniqueIds.has(mutation.target.id)) { 10 | const id = mutation.target.id || tempId++; 11 | uniqueIds.add(id); 12 | action(mutation); 13 | } 14 | }); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/JustNotSorry.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Warning from './Warning.js'; 4 | import * as Util from '../helpers/util.js'; 5 | import { forEachUniqueContentEditable } from '../callbacks/ContentEditableDiv'; 6 | import { calculateWarnings } from '../helpers/RangeFinder'; 7 | 8 | const JustNotSorry = ({ onEvents, phrases }) => { 9 | const email = useRef(null); 10 | const [observer, setObserver] = useState(null); 11 | const [warnings, setWarnings] = useState([]); 12 | 13 | const hideWarnings = () => setWarnings([]); 14 | 15 | const showWarnings = (target) => { 16 | email.current = target; 17 | setWarnings(calculateWarnings(target, phrases)); 18 | }; 19 | 20 | const applyEventListeners = ({ target }) => { 21 | const searchHandler = Util.debounce( 22 | () => showWarnings(target), 23 | Util.WAIT_TIME 24 | ); 25 | onEvents.map((onEvent) => target.addEventListener(onEvent, searchHandler)); 26 | target.addEventListener('blur', hideWarnings); 27 | }; 28 | 29 | useEffect(() => { 30 | if (observer) return; 31 | 32 | const callback = forEachUniqueContentEditable(applyEventListeners); 33 | const obs = new MutationObserver(callback); 34 | obs.observe(document.body, { 35 | subtree: true, 36 | childList: true, 37 | }); 38 | setObserver(obs); 39 | return () => { 40 | if (observer) { 41 | observer.disconnect(); 42 | } 43 | }; 44 | }, [ 45 | forEachUniqueContentEditable, 46 | applyEventListeners, 47 | setObserver, 48 | observer, 49 | ]); 50 | 51 | const isEmailOpen = () => email.current && email.current.offsetParent; 52 | 53 | if (isEmailOpen() && warnings.length > 0) { 54 | const currentEmail = email.current; 55 | const parentRect = currentEmail.offsetParent.getBoundingClientRect(); 56 | const warningComponents = warnings.map((warning, i) => { 57 | let key = i; 58 | if (warning?.startContainer?.parentElement) { 59 | const { offsetTop, offsetLeft } = warning.startContainer.parentElement; 60 | key = `${offsetTop + warning.startOffset}x${ 61 | offsetLeft + warning.endOffset 62 | }`; 63 | } 64 | return ( 65 | 72 | ); 73 | }); 74 | return ReactDOM.createPortal(warningComponents, currentEmail.offsetParent); 75 | } 76 | }; 77 | 78 | JustNotSorry.defaultProps = { 79 | onEvents: ['input', 'focus', 'cut'], 80 | phrases: [], 81 | }; 82 | export default JustNotSorry; 83 | -------------------------------------------------------------------------------- /src/components/Warning.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WarningHighlight from './WarningHighlight.js'; 3 | 4 | export default function Warning(props) { 5 | if (!props.textArea) { 6 | return; 7 | } 8 | const clientRects = props.range.getClientRects(); 9 | const highlights = []; 10 | for (let i = 0; i < clientRects.length; i++) { 11 | const number = props.number + i * 10; 12 | highlights.push( 13 | 20 | ); 21 | } 22 | return
{highlights}
; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/WarningHighlight.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tooltip } from 'react-tooltip'; 3 | 4 | const YPOS_ADJUSTMENT = 3; 5 | function calculatePosition(container, bounds) { 6 | return { 7 | top: `${ 8 | bounds.top + bounds.height - container.top - YPOS_ADJUSTMENT 9 | }px`, 10 | left: `${bounds.left - container.left}px`, 11 | width: `${bounds.width}px`, 12 | height: `${bounds.height * 0.2}px`, 13 | }; 14 | } 15 | 16 | export default function Highlight({ number, message, container, bounds }) { 17 | return ( 18 |
25 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers/RangeFinder.js: -------------------------------------------------------------------------------- 1 | import * as Util from './util'; 2 | 3 | export function calculateWarnings(target, patternsToFind) { 4 | const ranges = []; 5 | let nextNode; 6 | const textNodeIterator = document.createNodeIterator( 7 | target, 8 | NodeFilter.SHOW_TEXT 9 | ); 10 | while ((nextNode = textNodeIterator.nextNode()) !== null) { 11 | ranges.push( 12 | ...patternsToFind.flatMap((pattern) => { 13 | return Util.match(nextNode, pattern.regex).map((range) => ({ 14 | message: pattern.message, 15 | rangeToHighlight: range, 16 | })); 17 | }) 18 | ); 19 | } 20 | return ranges; 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/util.js: -------------------------------------------------------------------------------- 1 | // from underscore.js 2 | // Returns a function, that, as long as it continues to be invoked, will not 3 | // be triggered. The function will be called after it stops being called for 4 | // N milliseconds. If `immediate` is passed, trigger the function on the 5 | // leading edge, instead of the trailing. 6 | import RangeAtIndex from 'range-at-index'; 7 | 8 | export function debounce(func, wait, immediate) { 9 | var timeout; 10 | return function () { 11 | var context = this, 12 | args = arguments; 13 | var later = function () { 14 | timeout = null; 15 | if (!immediate) func.apply(context, args); 16 | }; 17 | var callNow = immediate && !timeout; 18 | clearTimeout(timeout); 19 | timeout = setTimeout(later, wait); 20 | if (callNow) func.apply(context, args); 21 | }; 22 | } 23 | 24 | // from dom-regexp-match 25 | export function match(el, regexp) { 26 | const ranges = []; 27 | const text = el.textContent; 28 | let m; 29 | while ((m = regexp.exec(text)) !== null) { 30 | const offset = m.index + m[0].length; 31 | ranges.push(new RangeAtIndex(el, m.index, offset)); 32 | // if the RegExp doesn't have the "global" flag then bail, 33 | // to avoid an infinite loop 34 | if (!regexp.global) break; 35 | } 36 | return ranges; 37 | } 38 | 39 | export const WAIT_TIME = 500; 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReactDOM from 'react-dom/client'; 3 | import 'react-tooltip/dist/react-tooltip.css'; 4 | import JustNotSorry from './components/JustNotSorry'; 5 | import PHRASES from './warnings/phrases.json'; 6 | 7 | const MESSAGE_PATTERNS = PHRASES.map((phrase) => ({ 8 | regex: new RegExp(phrase.pattern, 'gi'), 9 | message: phrase.message, 10 | })); 11 | 12 | ReactDOM.hydrateRoot( 13 | document.body, 14 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/warnings/phrases.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "displayLabel": ["Just"], 4 | "pattern": "\\b(just)\\b", 5 | "source": "http://www.taramohr.com/8-ways-women-undermine-themselves-with-their-words/", 6 | "message": "\"Just\" demeans what you have to say. \"Just\" shrinks your power. It's time to say goodbye to the justs. --Tara Sophia Mohr" 7 | }, 8 | { 9 | "displayLabel": ["Actually"], 10 | "pattern": "\\b(actually)\\b", 11 | "source": "http://www.taramohr.com/8-ways-women-undermine-themselves-with-their-words/", 12 | "message": "\"Actually\" communicates a sense of surprise that you have something to say. Of course you want to add something. Of course you have questions. There's nothing surprising about it. --Tara Sophia Mohr" 13 | }, 14 | { 15 | "displayLabel": ["Sorry"], 16 | "pattern": "\\b(sorry)\\b", 17 | "source": "http://www.fastcompany.com/3032112/strong-female-lead/sorry-not-sorry-why-women-need-to-stop-apologizing-for-everything", 18 | "message": "Using \"sorry\" frequently undermines your gravitas and makes you appear unfit for leadership. --Sylvia Ann Hewlett" 19 | }, 20 | { 21 | "displayLabel": ["Apologize", "Apologies", "Forgive"], 22 | "pattern": "\\b(apologize|apologies|forgive)\\b", 23 | "source": "http://www.fastcompany.com/3032112/strong-female-lead/sorry-not-sorry-why-women-need-to-stop-apologizing-for-everything", 24 | "message": "Apologizing unnecessarily puts you in a subservient position and makes people lose respect for you --Bonnie Marcus" 25 | }, 26 | { 27 | "displayLabel": ["I think", "We think"], 28 | "pattern": "\\b(I think|We think)\\b", 29 | "source": "http://www.fastcompany.com/3049609/the-future-of-work/4-types-of-useless-phrases-you-need-to-eliminate-from-your-emails", 30 | "message": "\"I think\" undermines your idea and displays an overall lack of self-confidence. --Lydia Dishman" 31 | }, 32 | { 33 | "displayLabel": [ 34 | "I'm no expert", 35 | "We're no expert", 36 | "We're no experts", 37 | "We're not experts" 38 | ], 39 | "pattern": "\\b(I'm no expert|We're no expert|We're no experts|We're not experts)\\b", 40 | "source": "http://www.fastcompany.com/3049609/the-future-of-work/4-types-of-useless-phrases-you-need-to-eliminate-from-your-emails", 41 | "message": "\"I'm no expert\" undermines your idea and displays an overall lack of self-confidence. --Lydia Dishman" 42 | }, 43 | { 44 | "displayLabel": ["Yes, but"], 45 | "pattern": "\\b(Yes, but)\\b", 46 | "source": "http://www.strategicserendipityforlife.com/documents/Articles/Communication_8TipsForFearlessCommunicationInTheWorkplace.pdf", 47 | "message": "The \"Yes, but\" syndrome is entirely counterproductive, particularly in a work setting. You will become an integral part of any team if you are willing to build ideas rather than discard them. --Victoria Simon, Ph.D. and Holly Pedersen, Ph.D." 48 | }, 49 | { 50 | "displayLabel": ["Literally"], 51 | "pattern": "\\b(literally)\\b", 52 | "source": "https://expresswriters.com/50-weak-words-and-phrases-to-cut-out-of-your-blogging/", 53 | "message": "If something is literal, your readers should know it without you needing to use this word to clarify it. More often than not, the word \"literally\" makes writing sound flabby and juvenile, which is probably not what you're going for. --Julia McCoy" 54 | }, 55 | { 56 | "displayLabel": ["Very"], 57 | "pattern": "\\b(very)\\b", 58 | "source": "http://blog.crew.co/5-weak-words-to-avoid/", 59 | "message": "The word 'very' does not communicate enough information. Find a stronger, more meaningful adverb, or omit it completely. --Andrea Ayres" 60 | }, 61 | { 62 | "displayLabel": ["Kind of", "Sort of"], 63 | "pattern": "\\b(kind of|sort of)\\b", 64 | "source": "http://www.strategicserendipityforlife.com/documents/Articles/Communication_8TipsForFearlessCommunicationInTheWorkplace.pdf", 65 | "message": "This qualifier weakens the message as well as the authority of the writer. --Victoria Simon, Ph.D. and Holly Pedersen, Ph.D." 66 | }, 67 | { 68 | "displayLabel": [ 69 | "Does that make sense", 70 | "Did that make sense", 71 | "Does this make sense", 72 | "Did this make sense", 73 | "If that makes sense", 74 | "If that's okay" 75 | ], 76 | "pattern": "(does that make sense|did that make sense|does this make sense|did this make sense|if that makes sense|if that's (okay|ok))", 77 | "source": "https://goop.com/how-women-undermine-themselves-with-words/", 78 | "message": "\"does that make sense\" comes across either as condescending (like your audience can't understand) or it implies you feel you've been incoherent. A better way to close is something like \"I look forward to hearing your thoughts.\" You can leave it up to the other party to let you know if they are confused about something, rather than implying that you \"didn't make sense.\" --Tara Sophia Mohr" 79 | }, 80 | { 81 | "displayLabel": ["Try", "Trying", "Tried"], 82 | "pattern": "\\b(try|trying|tried)\\b", 83 | "source": "http://www.lifehack.org/articles/communication/7-things-not-to-say-and-7-things-to-start-saying.html", 84 | "message": "\"Do or do not. There is no try.\" --Yoda" 85 | }, 86 | { 87 | "displayLabel": ["I should"], 88 | "pattern": "\\b(I should)\\b", 89 | "source": "http://www.lifehack.org/articles/communication/7-things-not-to-say-and-7-things-to-start-saying.html", 90 | "message": "The word \"should\" is inherently negative. \"Should\" implies a lose: lose situation and it's just not conducive to positive outcomes in life. It's a form of criticism, and it's best left out of your everyday language. Instead of beating yourself up for what you should have done, focus on what you have the power to change. -- Zoe B" 91 | }, 92 | { 93 | "displayLabel": ["I feel"], 94 | "pattern": "\\b(I feel)\\b", 95 | "source": "http://www.freelancewriting.com/articles/ten-words-to-avoid-when-writing.php", 96 | "message": "If you write an opinion, the reader understands that you also believe it is right. --David Bowman" 97 | }, 98 | { 99 | "displayLabel": ["I believe", "We believe", "We feel"], 100 | "pattern": "\\b(I believe|we believe|we feel)\\b", 101 | "source": "https://hbr.org/2011/12/replace-meaningless-words-with", 102 | "message": "Phrases containing \"we believe,\" \"we think,\" and \"we feel\" pervade presentation narratives to such a degree that they spill over into sentences where caution is unnecessary...the spillage weakens what should otherwise be assertive language. --Jerry Weissman" 103 | }, 104 | { 105 | "displayLabel": ["I'm just saying"], 106 | "pattern": "\\b(I'm just saying)\\b", 107 | "source": "http://101books.net/2012/03/02/7-annoying-words-that-should-die-a-horrible-death/", 108 | "message": "I think what you're saying is that you said something. If you're using it to mitigate something that may be offensive or embarrassing, then don't say it. Say something else. Otherwise, say what you're saying without the \"just saying.\" We already know you're saying it... after all, you just said it! --Robert Bruce" 109 | }, 110 | { 111 | "displayLabel": ["In my opinion"], 112 | "pattern": "\\b(In my opinion)\\b", 113 | "source": "https://preciseedit.wordpress.com/2009/06/19/in-my-opinion-i-think-that-i-believe-this-is-bad-writing/", 114 | "message": "Phrases such as \"in my opinion,\" \"I think that,\" and \"I believe\" create three problems for writers: 1. They delay the writer's message; 2. They demonstrate insecurity; and 3. They tell the reader what he already knows. Remove that phrase, or any similar phrase, and get to the point. --David Bowman" 115 | }, 116 | { 117 | "displayLabel": [ 118 | "This might be a stupid question", 119 | "This might be a silly idea" 120 | ], 121 | "pattern": "\\b(This might be a stupid question|This might be a silly idea)\\b", 122 | "source": "http://www.vogue.com/13362056/things-working-women-should-never-email/", 123 | "message": "Like they said in school, there are no stupid questions. Well, sometimes there are--but ask, don't caveat. --Alexandra Macon" 124 | }, 125 | { 126 | "displayLabel": ["I may be wrong", "I might be wrong", "I could be wrong"], 127 | "pattern": "\\b(I may be wrong|I might be wrong|I could be wrong)\\b", 128 | "source": "http://www.vogue.com/13362056/things-working-women-should-never-email/", 129 | "message": "Don't lessen the impact of what you say before you say it. --Alexandra Macon" 130 | }, 131 | { 132 | "displayLabel": [ 133 | "I'm just being honest here", 134 | "If I'm being honest", 135 | "Honestly", 136 | "to be honest", 137 | "if I'm honest", 138 | "to be perfectly honest", 139 | "in all honesty" 140 | ], 141 | "pattern": "\\b(I'm just being honest here|If I'm being honest|Honestly|to be honest|if I'm honest|to be perfectly honest|in all honesty)\\b", 142 | "source": "https://lifehacker.com/the-verbal-tee-ups-that-often-reveal-dishonesty-1505870461", 143 | "message": "Tee-ups like this may make the reader shut down and respond negatively to your comment. --Thorin Klosowski" 144 | }, 145 | { 146 | "displayLabel": ["I guess"], 147 | "pattern": "\\b(I guess)\\b", 148 | "source": "https://www.inc.com/mary-rezek/cut-kinda-sorta-i-guess-how-to-end-your-filler-word-bad-habits.html", 149 | "message": "If you're sure of something, \"guessing\" detracts from your message and opens doubt in the reader's mind. --Mary Rezek" 150 | }, 151 | { 152 | "displayLabel": ["Maybe"], 153 | "pattern": "\\b(Maybe)\\b", 154 | "source": "https://www.monster.com/career-advice/article/7-words-that-make-you-sound-less-confident-in-emails-0916", 155 | "message": "By adding \"maybe\" to your sentence, it makes it seem like you aren't confident in your answer/suggestion. Say what you mean. If you mean no, say no, if you mean yes, say yes." 156 | }, 157 | { 158 | "displayLabel": ["!!!"], 159 | "pattern": "!{3,}", 160 | "source": "https://www.theatlantic.com/technology/archive/2018/06/exclamation-point-inflation/563774/?utm_source=feed", 161 | "message": "We get it, you're excited!!! Use that many exclamation points with your friends, not your coworkers or clients. This is not social media. --Julie Beck" 162 | } 163 | ] 164 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = { 6 | plugins: [ 7 | new HtmlWebpackPlugin({ 8 | chunks: ['options'], 9 | filename: 'options.html', 10 | template: 'options/options.ejs', 11 | templateParameters: { 12 | allWarnings: require('./src/warnings/phrases.json'), 13 | }, 14 | }), 15 | new CopyPlugin({ 16 | patterns: [ 17 | { from: 'img', to: 'img' }, 18 | { from: 'manifest.json' }, 19 | { from: 'just-not-sorry.css' }, 20 | ], 21 | }), 22 | ], 23 | mode: 'production', 24 | entry: { 25 | bundle: './src/index.js', 26 | options: './options/options.js', 27 | background: './background/background.js', 28 | }, 29 | output: { 30 | path: path.join(__dirname, 'build'), 31 | filename: '[name].js', 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.css$/i, 37 | use: ['style-loader', 'css-loader'], 38 | }, 39 | { 40 | test: /\.jsx?$/, 41 | exclude: /(node_modules)/, 42 | use: { 43 | loader: 'babel-loader', 44 | options: { 45 | presets: ['@babel/preset-env'], 46 | plugins: [['@babel/plugin-transform-react-jsx']], 47 | }, 48 | }, 49 | }, 50 | ], 51 | }, 52 | resolve: { 53 | extensions: ['.js', '.jsx'], 54 | }, 55 | devtool: 'inline-source-map', 56 | performance: { 57 | hints: process.env.NODE_ENV === 'production' ? 'warning' : false, 58 | }, 59 | devServer: { 60 | static: { 61 | directory: path.join(__dirname, 'public'), 62 | }, 63 | compress: true, 64 | port: 9021, 65 | }, 66 | }; 67 | --------------------------------------------------------------------------------