├── .babelrc.js
├── .browserslistrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── nodejs.yml
├── .gitignore
├── .npmrc
├── .postcssrc.js
├── .stylelintignore
├── COPYING
├── README.md
├── assets
├── images
│ ├── arrow-up.svg
│ ├── cloud-off.svg
│ ├── default-theme.png
│ ├── delete-blue.base64.svg
│ ├── delete-dark.base64.svg
│ ├── delete-grey.base64.svg
│ ├── dot.png
│ ├── download.svg
│ ├── drag-handle-dots.png
│ ├── fanfou-logo.svg
│ ├── fav-blue.base64.svg
│ ├── fav-dark.base64.svg
│ ├── fav-grey.base64.svg
│ ├── faved-grey.base64.svg
│ ├── faved-yellow.base64.svg
│ ├── favorite.svg
│ ├── gtalk.svg
│ ├── location.svg
│ ├── mail.svg
│ ├── mobile.svg
│ ├── msn.svg
│ ├── pause.png
│ ├── personalized-theme.png
│ ├── play.png
│ ├── protected.svg
│ ├── question-mark.svg
│ ├── reply-blue.base64.svg
│ ├── reply-dark.base64.svg
│ ├── reply-grey.base64.svg
│ ├── reply.base64.svg
│ ├── repost-blue.base64.svg
│ ├── repost-dark.base64.svg
│ ├── repost-grey.base64.svg
│ ├── repost.base64.svg
│ ├── rss.svg
│ ├── star.svg
│ ├── toggle_down.svg
│ ├── toggle_up.svg
│ ├── unknown-user.jpg
│ ├── uploading-image-icons.svg
│ └── verify.svg
└── sounds
│ ├── ding.mp3
│ └── dingdong.mp3
├── build
├── pack.js
├── shared.js
├── webpack.config.js
├── webpack.css.config.js
└── webpack.js.config.js
├── docs
├── architecture.md
├── contributing.md
└── publish.md
├── jest.config.js
├── media
└── chrome-web-store-badge.png
├── package.json
├── src
├── background
│ ├── environment
│ │ ├── index.js
│ │ ├── messaging.js
│ │ ├── proxiedAudio.js
│ │ ├── proxiedCreateTab.js
│ │ ├── proxiedFetch.js
│ │ ├── settings.js
│ │ ├── storage-areas
│ │ │ ├── index.js
│ │ │ ├── local.js
│ │ │ ├── session.js
│ │ │ └── sync.js
│ │ └── storage.js
│ ├── feature
│ │ ├── createFeatureClass.js
│ │ └── createSubfeatureClass.js
│ ├── index.js
│ └── modules
│ │ ├── index.js
│ │ ├── notification.js
│ │ └── storage.js
├── constants
│ ├── .eslintrc.js
│ ├── action-types.js
│ ├── assets.js
│ ├── custom-event-types.js
│ ├── extension-origin.js
│ ├── index.js
│ ├── message-types.js
│ └── others.js
├── content
│ ├── environment
│ │ ├── bridge.js
│ │ ├── index.js
│ │ ├── injectMainStyle.js
│ │ ├── injectScript.js
│ │ ├── messaging.js
│ │ └── settings.js
│ ├── feature
│ │ ├── createFeatureClass.js
│ │ └── createSubfeatureClass.js
│ ├── index.js
│ └── modules
│ │ ├── index.js
│ │ ├── notification.js
│ │ ├── scrollManager.js
│ │ ├── statusFormIntersectionObserver.js
│ │ ├── storage.js
│ │ └── timelineElementObserver.js
├── entries
│ ├── background-content-page.js
│ └── settings.js
├── features
│ ├── .eslintrc.js
│ ├── auto-pager
│ │ ├── @page.js
│ │ └── metadata.js
│ ├── batch-manage-relationships
│ │ ├── friend-requests@page.js
│ │ ├── friend-requests@page.less
│ │ ├── friends-and-followers@page.js
│ │ ├── friends-and-followers@page.less
│ │ └── metadata.js
│ ├── batch-remove-private-messages
│ │ ├── @page.js
│ │ ├── @page.less
│ │ └── metadata.js
│ ├── batch-remove-statuses
│ │ ├── @page.js
│ │ ├── @page.less
│ │ └── metadata.js
│ ├── better-at-autocomplete
│ │ ├── disable-old-one@page.js
│ │ ├── enable-new-one@page.js
│ │ ├── enable-new-one@page.less
│ │ └── metadata.js
│ ├── box-shadows
│ │ ├── @content.less
│ │ └── metadata.js
│ ├── check-friendship
│ │ ├── @page.js
│ │ └── metadata.js
│ ├── check-saved-searches
│ │ ├── constants.js
│ │ ├── metadata.js
│ │ ├── service@background.js
│ │ ├── sidebar-indicators@page.js
│ │ └── sidebar-indicators@page.less
│ ├── enrich-statuses
│ │ ├── @page.js
│ │ ├── @page.less
│ │ ├── manual-tests.md
│ │ ├── metadata.js
│ │ └── utils
│ │ │ ├── createUrlUnshortener.js
│ │ │ ├── isExternalLink.js
│ │ │ ├── isPlainLink.js
│ │ │ ├── isShortUrl.js
│ │ │ ├── urlHandlers.js
│ │ │ └── urlTransformers.js
│ ├── favorite-fanfouers
│ │ ├── home@page.js
│ │ ├── home@page.less
│ │ ├── metadata.js
│ │ ├── shared.js
│ │ ├── user-profile@page.js
│ │ └── user-profile@page.less
│ ├── fix-photo-zoom
│ │ ├── @page.js
│ │ └── metadata.js
│ ├── floating-status-form
│ │ ├── floating-status-form@page.js
│ │ ├── floating-status-form@page.less
│ │ ├── manual-tests.md
│ │ ├── metadata.js
│ │ ├── replay-and-repost@page.js
│ │ └── replay-and-repost@page.less
│ ├── go-top-button
│ │ ├── @page.js
│ │ ├── @page.less
│ │ └── metadata.js
│ ├── google-analytics
│ │ ├── @content.js
│ │ └── metadata.js
│ ├── index.js
│ ├── keyboard-shortcuts
│ │ ├── @page.js
│ │ └── metadata.js
│ ├── notifications
│ │ ├── metadata.js
│ │ ├── service@background.js
│ │ └── update-details@background.js
│ ├── process-unread-statuses
│ │ ├── metadata.js
│ │ ├── process-unread-statuses@page.js
│ │ ├── scroll-to-show@page.js
│ │ └── scroll-to-show@page.less
│ ├── remove-app-recommendations
│ │ ├── @content.less
│ │ └── metadata.js
│ ├── remove-brackets
│ │ ├── @content.js
│ │ └── metadata.js
│ ├── remove-logo-beta
│ │ ├── @content.js
│ │ ├── @content.less
│ │ └── metadata.js
│ ├── remove-personalized-theme
│ │ ├── @content.js
│ │ ├── @content.less
│ │ ├── fanfou-default-theme.css
│ │ └── metadata.js
│ ├── retinafy-photos
│ │ ├── metadata.js
│ │ ├── photo-album@page.js
│ │ ├── photo-entry@page.js
│ │ ├── shared.js
│ │ ├── status@page.js
│ │ └── timeline@page.js
│ ├── share-new-avatar
│ │ ├── @page.js
│ │ └── metadata.js
│ ├── share-to-fanfou
│ │ ├── @background.js
│ │ ├── fix-style@content.js
│ │ ├── fix-style@content.less
│ │ └── metadata.js
│ ├── show-contextual-statuses
│ │ ├── @page.js
│ │ ├── @page.less
│ │ ├── constants.js
│ │ ├── fix-reply-and-repost@page.js
│ │ └── metadata.js
│ ├── sidebar-statistics
│ │ ├── @page.js
│ │ ├── @page.less
│ │ └── metadata.js
│ ├── status-form-enhancements
│ │ ├── ajax-form@page.js
│ │ ├── autofocus-textarea@page.js
│ │ ├── fix-dnd-upload@page.js
│ │ ├── fix-dnd-upload@page.less
│ │ ├── fix-upload-images@page.js
│ │ ├── manual-tests.md
│ │ ├── metadata.js
│ │ ├── misc@page.less
│ │ ├── paste-image-from-clipboard@page.js
│ │ ├── refresh-status-count@page.js
│ │ ├── revoke-event-listeners@page.js
│ │ └── textarea-state@page.js
│ ├── translucent-sidebar
│ │ ├── @content.less
│ │ └── metadata.js
│ ├── update-timestamps
│ │ ├── @page.js
│ │ └── metadata.js
│ └── user-switcher
│ │ ├── login-form@page.js
│ │ ├── manual-tests.md
│ │ ├── metadata.js
│ │ ├── user-switcher@page.js
│ │ └── user-switcher@page.less
├── libs
│ ├── Deferred.js
│ ├── ElementCollection.js
│ ├── Timeout.js
│ ├── Tooltip.js
│ ├── animatedScrollTop.js
│ ├── arrayRemove.js
│ ├── arrayUniquePush.js
│ ├── asyncSingleton.js
│ ├── blobToBase64.js
│ ├── collapseSelection.js
│ ├── compareDomains.js
│ ├── compareDomains.test.js
│ ├── expose.js
│ ├── extensionUnloaded.js
│ ├── extractText.js
│ ├── fade.js
│ ├── findElementWithSpecifiedContentInArray.js
│ ├── findUserThemeStyleElement.js
│ ├── formatDate.js
│ ├── getCurrentPageOwnerUserId.js
│ ├── getExtensionOrigin.js
│ ├── getExtensionVersion.js
│ ├── getLoggedInUserId.js
│ ├── getLoggedInUserProfilePageUrl.js
│ ├── indexOf.js
│ ├── isElementInDocument.js
│ ├── isExtensionUpgraded.js
│ ├── isExtensionUpgraded.test.js
│ ├── isFanfouWebUrl.js
│ ├── isFanfouWebUrl.test.js
│ ├── isHotkey.js
│ ├── isLegacyVersion.js
│ ├── isLegacyVersion.test.js
│ ├── isNaN.js
│ ├── isStatusElement.js
│ ├── jsonp.js
│ ├── keepRetry.js
│ ├── loadAsset.js
│ ├── localStorageWrappers.js
│ ├── log.js
│ ├── memoize.js
│ ├── memoize.test.js
│ ├── migrate.js
│ ├── neg.js
│ ├── neg.test.js
│ ├── noop.js
│ ├── omitBy.js
│ ├── pageDetect.js
│ ├── parseFilename.js
│ ├── parseFilename.test.js
│ ├── parseHTML.js
│ ├── parseQueryString.js
│ ├── parseQueryString.test.js
│ ├── parseUrl.js
│ ├── parseUrl.test.js
│ ├── playSound.js
│ ├── preactRender.js
│ ├── prependElement.js
│ ├── promiseAny.js
│ ├── promiseEvery.js
│ ├── promisifyChromeApi.js
│ ├── replaceExtensionOrigin.js
│ ├── requireFanfouLib.js
│ ├── safelyInvokeFn.js
│ ├── safelyInvokeFns.js
│ ├── stringCases.js
│ ├── stringCases.test.js
│ ├── timestamp.js
│ ├── toggleVisibility.js
│ ├── truncateFilename.js
│ ├── truncateFilename.test.js
│ ├── truncateUrl.js
│ ├── untilElementRemoved.js
│ ├── waitForHead.js
│ └── wrapper.js
├── page
│ ├── environment
│ │ ├── bridge.js
│ │ ├── index.js
│ │ └── settings.js
│ ├── feature
│ │ ├── createFeatureClass.js
│ │ └── createSubfeatureClass.js
│ ├── index.js
│ ├── modules
│ │ ├── checkMyNewStatus.js
│ │ ├── index.js
│ │ ├── proxiedAudio.js
│ │ ├── proxiedCreateTab.js
│ │ ├── proxiedFetch.js
│ │ └── storage.js
│ └── styles
│ │ ├── 00-global.less
│ │ ├── 10-animation.less
│ │ ├── 10-helpers.less
│ │ ├── 10-remove-link-underlines.less
│ │ ├── 20-new-style-operation-icons.less
│ │ ├── 20-others.less
│ │ ├── 90-react-tooltip-lite.less
│ │ ├── 90-sharer.less
│ │ ├── 90-simplified-view.less
│ │ └── index.js
├── settings
│ ├── components
│ │ ├── App.js
│ │ ├── CloudSyncingDisabledTip.js
│ │ ├── ExternalLink.js
│ │ ├── HelpAndSupport.js
│ │ └── VersionHistory.js
│ ├── getTabDefs.js
│ ├── messaging.js
│ ├── settings.js
│ └── styles
│ │ ├── index.js
│ │ ├── settings.less
│ │ └── ui-kit-for-chrome-extensions
│ │ ├── chrome_shared.css
│ │ └── widgets.css
└── version-history
│ ├── index.js
│ ├── parseVersionHistory.js
│ └── versionHistory
├── static
├── icons
│ ├── icon-128.png
│ ├── icon-16.png
│ ├── icon-24.png
│ ├── icon-256.png
│ ├── icon-32.png
│ ├── icon-48.png
│ └── icon-640.png
├── manifest.json
└── settings.html
└── stylelint.config.js
/.babelrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | [ '@babel/plugin-transform-react-jsx', {
4 | pragma: 'h',
5 | } ],
6 | [ '@babel/plugin-proposal-class-properties', {
7 | loose: true,
8 | } ],
9 | '@babel/plugin-proposal-optional-chaining',
10 | '@babel/plugin-proposal-do-expressions',
11 | '@babel/plugin-proposal-export-default-from',
12 | '@babel/plugin-proposal-function-bind',
13 | 'macros',
14 | ],
15 |
16 | env: {
17 | test: {
18 | plugins: [
19 | '@babel/plugin-transform-modules-commonjs',
20 | ],
21 | },
22 | },
23 | }
24 |
--------------------------------------------------------------------------------
/.browserslistrc:
--------------------------------------------------------------------------------
1 | # Browsers that we support
2 |
3 | last 4 chrome versions
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [ 'riophae', 'plugin:react/recommended' ],
4 | parser: 'babel-eslint',
5 |
6 | globals: {
7 | chrome: true,
8 | },
9 |
10 | plugins: [ 'react' ],
11 |
12 | settings: {
13 | 'import/resolver': {
14 | node: null,
15 | webpack: {
16 | config: 'build/webpack.config.js',
17 | },
18 | },
19 | 'import/extensions': [ '.js', '.json', '.css', '.less' ],
20 |
21 | react: {
22 | pragma: 'h',
23 | version: require('preact/compat').version,
24 | },
25 | },
26 |
27 | rules: {
28 | 'react/no-unknown-property': [ 2, { ignore: [ 'class' ] }],
29 | 'react/prop-types': 0,
30 | 'unicorn/consistent-function-scoping': 0,
31 | 'no-warning-comments': 0,
32 | 'require-atomic-updates': 0,
33 | },
34 |
35 | overrides: [ {
36 | files: [ '*.test.js' ],
37 | env: {
38 | jest: true,
39 | },
40 | } ],
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [10.x, 12.x, 13.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v1
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - name: npm install, build, and test
21 | run: |
22 | node --version
23 | npm --version
24 | npm install
25 | npm run build --if-present
26 | npm test
27 | env:
28 | CI: true
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | coverage
2 | .cache-loader
3 | dist
4 | *.zip
5 |
6 | .DS_Store
7 | .gitconfig
8 |
9 | node_modules
10 | *.log
11 |
12 | .project
13 | .vscode
14 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.postcssrc.js:
--------------------------------------------------------------------------------
1 | // https://github.com/michael-ciniawsky/postcss-load-config
2 |
3 | module.exports = {
4 | plugins: {
5 | autoprefixer: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/.stylelintignore:
--------------------------------------------------------------------------------
1 | dist
2 | src/settings/styles/ui-kit-for-chrome-extensions
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # 太空饭否
4 |
5 | [](https://github.com/fanfoujs/space-fanfou/actions)
6 | [](https://github.com/fanfoujs/space-fanfou/releases)
7 | [](https://github.com/fanfoujs/space-fanfou/blob/master/LICENSE)
8 |
9 | ### 简介
10 |
11 | 太空饭否是一个免费、用心的开源项目,是目前最强大最好用的饭否浏览器扩展。可以给饭否添加回复和转发展开、桌面通知、浮动输入框、多用户切换、消息批量管理、自动翻页等功能,并且使饭否页面变得更美更舒心,符合您的使用习惯。
12 |
13 | ### 团队
14 |
15 |
16 |
17 | **[@太空小孩](https://fanfou.com/anegie)**|**[@锐风](https://fanfou.com/ruif)**|**[@饭小默](https://fanfou.com/lito)**|**[@Xidorn](https://fanfou.com/xidorn)**|**[@.rex](https://fanfou.com/zhasm)**
18 | :-----:|:-----:|:-----:|:-----:|:-----:
19 |
20 | ### 下载安装
21 |
22 | 请使用 Chrome 浏览器访问网上应用店获取插件下载。
23 |
24 |
25 |
26 |
27 |
28 | ### 参阅
29 |
30 |
31 |
32 | **→ [官方饭否](https://fanfou.com/spacefanfou)**|**→ [入门手册](https://spacekid.me/spacefanfou/)**
33 | :-----:|:-----:
34 |
35 | ### 协议
36 |
37 | 基于 [GPL v3](COPYING) 协议发布。版权所有 © 2011-2020 太空饭否开发组。
38 |
--------------------------------------------------------------------------------
/assets/images/arrow-up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/cloud-off.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/default-theme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/default-theme.png
--------------------------------------------------------------------------------
/assets/images/delete-blue.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/delete-dark.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/delete-grey.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/dot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/dot.png
--------------------------------------------------------------------------------
/assets/images/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/drag-handle-dots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/drag-handle-dots.png
--------------------------------------------------------------------------------
/assets/images/fav-blue.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/fav-dark.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/fav-grey.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/faved-grey.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/faved-yellow.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/favorite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/gtalk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/location.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/mail.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/mobile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/msn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/pause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/pause.png
--------------------------------------------------------------------------------
/assets/images/personalized-theme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/personalized-theme.png
--------------------------------------------------------------------------------
/assets/images/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/play.png
--------------------------------------------------------------------------------
/assets/images/protected.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/question-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/reply-blue.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/reply-dark.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/reply-grey.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/reply.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/repost-blue.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/repost-dark.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/repost-grey.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/repost.base64.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/rss.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/star.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/toggle_down.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/toggle_up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/images/unknown-user.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/images/unknown-user.jpg
--------------------------------------------------------------------------------
/assets/images/verify.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/sounds/ding.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/sounds/ding.mp3
--------------------------------------------------------------------------------
/assets/sounds/dingdong.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/assets/sounds/dingdong.mp3
--------------------------------------------------------------------------------
/build/pack.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 |
3 | const path = require('path')
4 | const cp = require('child_process')
5 | const chalk = require('chalk')
6 | const timestamp = require('tinydate')('{YYYY}/{MM}/{DD} {HH}:{mm}:{ss}')
7 | const manifest = require('../static/manifest')
8 |
9 | function log(message) {
10 | console.log(`[${chalk.cyan(timestamp())}] ${message}`)
11 | }
12 |
13 | function pack() {
14 | const version = manifest.version_name || manifest.version
15 | const zipName = `space-fanfou-${version}.zip`
16 | const pkgRootPath = path.join(__dirname, '..')
17 | const outPath = path.join(pkgRootPath, zipName)
18 | const distPath = path.join(pkgRootPath, 'dist')
19 | const command = `rm -f ${JSON.stringify(outPath)} && cd ${JSON.stringify(distPath)} && zip -r ${JSON.stringify(outPath)} *`
20 |
21 | log(`正在创建:${chalk.green(zipName)}`)
22 |
23 | cp.exec(command, error => {
24 | if (error) {
25 | log(chalk.red('创建失败'))
26 | console.log(error)
27 | } else {
28 | log(chalk.green('创建成功'))
29 | }
30 | })
31 | }
32 | pack()
33 |
--------------------------------------------------------------------------------
/build/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | require('./webpack.js.config')('background', 'background-content-page'),
3 | require('./webpack.js.config')('content', 'background-content-page'),
4 | require('./webpack.js.config')('page', 'background-content-page'),
5 | require('./webpack.js.config')('settings', 'settings'),
6 | require('./webpack.css.config'),
7 | ]
8 |
--------------------------------------------------------------------------------
/build/webpack.css.config.js:
--------------------------------------------------------------------------------
1 | const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
3 | const OmitJSforCSSPlugin = require('webpack-omit-js-for-css-plugin')
4 | const {
5 | approot,
6 | defaultArgv,
7 | generateBaseConfig,
8 | generateStyleLoader,
9 | generateFileLoaderForImages,
10 | generateUrlLoaderForImages,
11 | } = require('./shared')
12 |
13 | module.exports = (_, { mode } = defaultArgv) => ({
14 | name: 'css',
15 |
16 | entry: {
17 | page: approot('src/page/styles/index.js'),
18 | settings: approot('src/settings/styles/index.js'),
19 | },
20 |
21 | output: {
22 | path: approot('dist'),
23 | // 这里必须得是 .js,代表的是最终打包的结果,并不是我们想要的 CSS 文件
24 | filename: '[name].js',
25 | },
26 |
27 | ...generateBaseConfig(mode),
28 |
29 | module: {
30 | rules: [
31 | generateStyleLoader({ extract: true, mode }),
32 | generateUrlLoaderForImages(),
33 | generateFileLoaderForImages({ publicPath: '/' }),
34 | ],
35 | },
36 |
37 | plugins: [
38 | // 通过这个插件把打包结果中的 CSS 代码提取出来,写入单独的 .css 文件
39 | new MiniCssExtractPlugin(),
40 | // 通过这个插件阻止 webpack 输出打包结果文件(.js),只保留 .css 文件
41 | new OmitJSforCSSPlugin(),
42 | mode === 'production' && new OptimizeCssAssetsPlugin(),
43 | ].filter(Boolean),
44 | })
45 |
--------------------------------------------------------------------------------
/build/webpack.js.config.js:
--------------------------------------------------------------------------------
1 | const TerserWebpackPlugin = require('terser-webpack-plugin')
2 | const CopyWebpackPlugin = require('copy-webpack-plugin')
3 | const { EXTENSION_ORIGIN_PLACEHOLDER } = require('esm')(module)('../src/constants/extension-origin')
4 | const {
5 | approot,
6 | defaultArgv,
7 | generateBaseConfig,
8 | generateStyleLoader,
9 | generateFileLoaderForImages,
10 | generateUrlLoaderForImages,
11 | generateFileLoaderForOtherAssets,
12 | } = require('./shared')
13 |
14 | module.exports = (id, entryFile) => (_, { mode } = defaultArgv) => ({
15 | name: 'js',
16 |
17 | entry: {
18 | [id]: approot(`src/entries/${entryFile}.js`),
19 | },
20 |
21 | output: {
22 | path: approot('dist'),
23 | filename: '[name].js',
24 | },
25 |
26 | ...generateBaseConfig(mode),
27 |
28 | module: {
29 | rules: [
30 | {
31 | test: /\.js$/,
32 | use: [
33 | {
34 | loader: 'cache-loader',
35 | options: {
36 | cacheIdentifier: require('cache-loader/package').version + mode + id,
37 | },
38 | },
39 | 'babel-loader',
40 | {
41 | loader: 'ifdef-loader',
42 | options: {
43 | DEVELOPMENT: mode === 'development',
44 | PRODUCTION: mode === 'production',
45 | ENV_BACKGROUND: id === 'background',
46 | ENV_CONTENT: id === 'content',
47 | ENV_PAGE: id === 'page',
48 | },
49 | },
50 | ],
51 | },
52 | generateStyleLoader({ mode }),
53 | generateUrlLoaderForImages(),
54 | generateFileLoaderForImages({ publicPath: `${EXTENSION_ORIGIN_PLACEHOLDER}/` }),
55 | generateFileLoaderForOtherAssets(),
56 | ],
57 | },
58 |
59 | plugins: [
60 | new CopyWebpackPlugin([ {
61 | from: approot('static'),
62 | to: approot('dist'),
63 | } ]),
64 | ],
65 |
66 | optimization: {
67 | // 使代码保持在可读的状态,方便用户反馈 bug 后 debug
68 | minimizer: [
69 | new TerserWebpackPlugin({
70 | terserOptions: {
71 | ecma: 8,
72 | compress: {
73 | defaults: false,
74 | dead_code: true, // eslint-disable-line camelcase
75 | evaluate: true,
76 | unused: true,
77 | },
78 | mangle: false,
79 | output: {
80 | beautify: true,
81 | indent_level: 2, // eslint-disable-line camelcase
82 | },
83 | },
84 | extractComments: false,
85 | }),
86 | ],
87 | },
88 | })
89 |
--------------------------------------------------------------------------------
/docs/architecture.md:
--------------------------------------------------------------------------------
1 | # 架构
2 |
3 | ### 太空饭否分为哪几个部分?
4 |
5 | 太空饭否分为四个部分:
6 |
7 | - Background Scripts - 扩展的背景页面脚本
8 | - Content Scripts - 通过 manifest.json 中 `content_scripts` 部分声明的脚本
9 | - Page Scripts - 上面 Content Scripts 通过 `` 注入到页面的脚本,和网站自身脚本处于同一执行环境
10 | - Settings - 设置页面
11 |
12 | 以上四部分的 webpack entry 文件位于 `src/entries` 目录中,而前三者共用了同一个 entry 文件。原因是,它们都负责实现太空饭否的各种功能,因此会引用 `src/features` 中的文件,共用一个 entry 可以避免重复打包这部分代码。
13 |
14 | ### Background Scripts 是做什么的?
15 |
16 | 部分功能如检查是否有新 @ 提醒需要持续运行,所以放置在 Background Scripts 中。这部分不能直接与饭否页面接触。
17 |
18 | ### Content Scripts 和 Page Scripts 是做什么的?
19 |
20 | 负责在饭否网页上实现太空饭否的样式与功能,即与页面接触的部分。这两者实际上是非常相似的,但是也存在一些差别。
21 |
22 | ### 为什么同时存在 Content Scripts 和 Page Scripts?
23 |
24 | 一般情况下,修改页面或添加内容以实现功能,只需要 Content Scripts 就足够了。但是太空饭否某些功能必须调用饭否页面的 JS 接口才可以实现(比如要用到 jQuery 和 YUI 来禁用掉饭否原有的一些功能)。但是 Content Scripts 是在一个隔离的环境中执行的,无法访问页面这一侧的对象,也就无法调用饭否自己的 JS 接口。而 Page Scripts 就没有了这些限制。
25 |
26 | 此外,Content Scripts 还有一个缺点是,脚本改动后,必须重启扩展才能生效。而 Page Scripts 没有这个问题。
27 |
28 | 但是 Page Scripts 也不完美:
29 |
30 | 1. 不能像 Content Scripts 那样通过 Chrome 扩展 API 和 Background Scripts 通信。解决的办法是,利用 `CustomEvent` 实现了一个桥,先和 Content Scripts 通信,然后再由后者负责转发到 Background Scripts;Background Scripts 作出回复后再由 Content Scripts 转发给 Page Scripts。
31 |
32 | 2. Content Scripts 通过 `` 把 Page Scripts 注入到页面后,后者总是会至少延迟数百毫秒才开始执行。如果是对加载速度比较敏感的需求(比如要修改页面样式),则会受到影响。因此这类需求往往在 Content Scripts 中实现。
33 |
34 | 因为 Content Scripts 改动后必须重启扩展才能生效,会影响到开发效率,所以对于二者皆可的情况,一般优先选择 Page Scripts。
35 |
36 | 待续……
37 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | # 贡献代码
2 |
3 | ### 开发
4 |
5 | 1. fork 并 clone 项目
6 | 1. 安装依赖 `npm install`
7 | 1. 进入开发模式 `npm run dev`,webpack 会持续监听文件变化并重新构建
8 | 1. 在 Chrome 中加载 `dist` 目录
9 | 1. 请确保 `npm test` 测试通过
10 |
11 | ### 约定
12 |
13 | - 从 1.0.0 开始,使用[语义化版本控制](https://semver.org)
14 | - 只提供必要的设置项,降低用户的决策负担
15 | - 添加依赖时尽可能选择小巧、简单的包,维持打包体积、性能(比如使用 [just](https://github.com/angus-c/just) 替代 [lodash](https://lodash.com)、[Preact](https://preactjs.com/) 替代 [React](https://reactjs.org/),以及 [tinydate](https://github.com/lukeed/tinydate) 替代 [moment.js](https://momentjs.com/))
16 | - 太空饭否添加上去的 CSS 类名或 ID 名,应该以 `sf-` 为前缀,且使用连字符风格(如 `sf-foo-bar`),避免和饭否原有的样式命名发生冲突
17 | - 尽量不对图片作 base64 编码,因为会影响到性能
18 | - 尽量不去调用饭否的 jQuery / YUI
19 | - 使用图片素材时应考虑到 HiDPI 显示器的适配
20 | - SVG 图片应该使用 [svgo](https://github.com/svg/svgo) 作优化处理
21 | - 在 merge pull request 时应选择「Squash and merge」
22 | - 代码注释和 git commit message 应使用中文
23 |
--------------------------------------------------------------------------------
/docs/publish.md:
--------------------------------------------------------------------------------
1 | # 发布
2 |
3 | ### 如何选择新版本号
4 |
5 | 从 1.0.0 开始,太空饭否使用[语义化版本控制](https://semver.org)。当发布新版本时,选择新版本号应遵循:
6 |
7 | - 如果没有引入新功能,只是在现有基础上修补和改进,则应发布 patch(`_._.+`)更新;
8 | - 如果引入了新功能,则应该发布 minor(`_.+._`)更新;
9 | - 如果大幅度调整了代码或设计,则应该发布 major(`+._._`)更新。
10 |
11 | ### 更新历史的显示位置
12 |
13 | - 「设置」→「更新历史」,显示完整的更新历史
14 | - 扩展启动时弹出通知,显示本次版本更新内容的概要(如果刚刚升级到了新版本、开启了相关设置,并且 `versionHistory` 中包含了该版本的更新内容)
15 |
16 | ### 如何编写更新历史内容
17 |
18 | - 更新历史应该介绍该版本在功能、设计及用户体验方面新引入或修正的内容
19 | - 应该避免无意义的文字,如「修正了一些 bug」,尽量不打扰用户
20 | - 如果更新内容过多,可以在前面写一行概要,用于作为桌面通知内容显示
21 |
22 | ### 如何发布新版?
23 |
24 | 1. 修改 `static/manifest.json` 中的版本号
25 | 1. 在 `src/version-history/versionHistory` 中添加更新说明(可选)
26 | 1. 构建 `npm run release`
27 | 1. 将自动打包生成的 zip 文件提交到商店审核
28 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | browser: true,
3 | resolver: 'jest-webpack-resolver',
4 | jestWebpackResolver: {
5 | silent: true,
6 | webpackConfig: './build/webpack.config.js',
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/media/chrome-web-store-badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fanfoujs/space-fanfou/e2068914e179dc64f1d1e3b8263b2b7c4a92aa49/media/chrome-web-store-badge.png
--------------------------------------------------------------------------------
/src/background/environment/index.js:
--------------------------------------------------------------------------------
1 | import messaging from './messaging'
2 | import storage from './storage'
3 | import settings from './settings'
4 | import proxiedFetch from './proxiedFetch'
5 | import proxiedAudio from './proxiedAudio'
6 | import proxiedCreateTab from './proxiedCreateTab'
7 |
8 | export default async function createBackgroundEnvironment() {
9 | require('webext-inject-on-install')
10 |
11 | await Promise.all([
12 | messaging.install(),
13 | storage.install(),
14 | settings.install(),
15 | proxiedFetch.install(),
16 | proxiedAudio.install(),
17 | proxiedCreateTab.install(),
18 | ])
19 |
20 | return { messaging, settings }
21 | }
22 |
--------------------------------------------------------------------------------
/src/background/environment/messaging.js:
--------------------------------------------------------------------------------
1 | import safelyInvokeFns from '@libs/safelyInvokeFns'
2 | import arrayUniquePush from '@libs/arrayUniquePush'
3 | import arrayRemove from '@libs/arrayRemove'
4 | import log from '@libs/log'
5 | import { BROADCASTING_MESSAGE } from '@constants'
6 |
7 | const messageHandlers = {}
8 | const connectedPorts = []
9 | const broadcastListeners = []
10 |
11 | function createMessageHandler(port) {
12 | return async ({ senderId, message }) => {
13 | const handler = messageHandlers[message.action]
14 | let respondedMessage
15 |
16 | if (handler) {
17 | try {
18 | respondedMessage = await handler(message.payload)
19 | } catch (error) {
20 | log.error(error)
21 | }
22 |
23 | // 页面端不确定是否一定会收到回复,却必须注册监听 callback
24 | // 因此无论 handler 有没有回复消息(respondedMessage 可能为 undefined)
25 | // 都要给页面端发送消息,以注销掉监听回复的 callback
26 | // 因为 handler 是异步的,在它执行结束后消息源标签页可能已经关闭了
27 | // 需要先检查是否已经 disconnect
28 | if (!isPortDisconnected(port)) {
29 | port.postMessage({ senderId, message: respondedMessage })
30 | }
31 | } else {
32 | throw new Error(`未知消息类型 「${message.action}」`)
33 | }
34 | }
35 | }
36 |
37 | function isPortDisconnected(port) {
38 | return !connectedPorts.includes(port)
39 | }
40 |
41 | const messaging = {
42 | install() {
43 | chrome.runtime.onConnect.addListener(port => {
44 | connectedPorts.push(port)
45 | port.onMessage.addListener(createMessageHandler(port))
46 | port.onDisconnect.addListener(() => arrayRemove(connectedPorts, port))
47 | })
48 | },
49 |
50 | registerHandler(actionType, handler) {
51 | if (messageHandlers[actionType]) {
52 | throw new Error(`重复注册 「${actionType}」 类型的消息处理器`)
53 | } else {
54 | messageHandlers[actionType] = handler
55 | }
56 | },
57 |
58 | unregisterHandler(actionType) {
59 | if (!delete messageHandlers[actionType]) {
60 | throw new Error(`不存在 「${actionType}」 类型的消息处理器,因此取消注册失败`)
61 | }
62 | },
63 |
64 | registerBroadcastListener(fn) {
65 | arrayUniquePush(broadcastListeners, fn)
66 | },
67 |
68 | broadcastMessage(message) {
69 | for (const port of connectedPorts) {
70 | port.postMessage({
71 | type: BROADCASTING_MESSAGE,
72 | message,
73 | })
74 | }
75 |
76 | this.handleBroadcastMessage(message)
77 | },
78 |
79 | handleBroadcastMessage(message) {
80 | safelyInvokeFns({
81 | fns: broadcastListeners,
82 | args: [ message ],
83 | })
84 | },
85 | }
86 |
87 | export default messaging
88 |
--------------------------------------------------------------------------------
/src/background/environment/proxiedAudio.js:
--------------------------------------------------------------------------------
1 | // 把原本在 page script 的音频播放放到 background script
2 | // 因为 Chrome 限制非活动的标签页播放音频
3 |
4 | import messaging from './messaging'
5 | import playSound from '@libs/playSound'
6 | import { PROXIED_AUDIO } from '@constants'
7 |
8 | function registerHandler() {
9 | messaging.registerHandler(PROXIED_AUDIO, payload => {
10 | const { audioUrl } = payload
11 |
12 | playSound(audioUrl)
13 | })
14 | }
15 |
16 | export default {
17 | install() {
18 | registerHandler()
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/src/background/environment/proxiedCreateTab.js:
--------------------------------------------------------------------------------
1 | // 把原本在 page script 的 window.open() 放到 background script
2 | // 避免被 Chrome 当成恶意弹窗屏蔽掉
3 |
4 | import messaging from './messaging'
5 | import { PROXIED_CREATE_TAB } from '@constants'
6 |
7 | function registerHandler() {
8 | messaging.registerHandler(PROXIED_CREATE_TAB, payload => {
9 | const { url, openInBackgroundTab = false } = payload
10 |
11 | chrome.tabs.create({
12 | url,
13 | active: !openInBackgroundTab,
14 | })
15 | })
16 | }
17 |
18 | export default {
19 | install() {
20 | registerHandler()
21 | },
22 | }
23 |
--------------------------------------------------------------------------------
/src/background/environment/proxiedFetch.js:
--------------------------------------------------------------------------------
1 | // 把原本在 page script 的 AJAX 请求放到 background script
2 | // 用于绕开跨域限制
3 | // 但是仍然需要把要请求的资源的域名列在 manifest.json 的 content_security_policy 里面
4 | // https://developer.chrome.com/apps/xhr
5 |
6 | import wretch from 'wretch'
7 | import messaging from './messaging'
8 | import { PROXIED_FETCH_GET } from '@constants'
9 |
10 | function registerHandler() {
11 | messaging.registerHandler(PROXIED_FETCH_GET, async payload => {
12 | const { url, query, responseType = 'text' } = payload
13 | let error, responseText, responseJSON
14 | let w = wretch(url)
15 |
16 | if (query) w = w.query(query)
17 |
18 | try {
19 | w = await w.get()
20 |
21 | if (responseType === 'text') {
22 | responseText = await w.text()
23 | } else if (responseType === 'json') {
24 | responseJSON = await w.json()
25 | }
26 | } catch (exception) {
27 | error = exception
28 | }
29 |
30 | return { error, responseText, responseJSON }
31 | })
32 | }
33 |
34 | export default {
35 | install() {
36 | registerHandler()
37 | },
38 | }
39 |
--------------------------------------------------------------------------------
/src/background/environment/storage-areas/index.js:
--------------------------------------------------------------------------------
1 | import local from './local'
2 | import sync from './sync'
3 | import session from './session'
4 | import safelyInvokeFns from '@libs/safelyInvokeFns'
5 | import arrayUniquePush from '@libs/arrayUniquePush'
6 | import arrayRemove from '@libs/arrayRemove'
7 |
8 | function initStorageAreas() {
9 | const storageAreas = { local, sync, session }
10 | const listeners = []
11 | const createListener = storageAreaName => changes => {
12 | for (const [ key, { oldValue, newValue } ] of Object.entries(changes)) {
13 | safelyInvokeFns({
14 | fns: listeners,
15 | args: [ {
16 | key,
17 | storageAreaName,
18 | oldValue,
19 | newValue,
20 | } ],
21 | })
22 | }
23 | }
24 |
25 | // sessionStorage 不需要监听改动
26 | for (const storageAreaName of [ 'local', 'sync' ]) {
27 | storageAreas[storageAreaName].listen(createListener(storageAreaName))
28 | }
29 |
30 | return {
31 | ...storageAreas,
32 | listen: fn => arrayUniquePush(listeners, fn),
33 | unlisten: fn => arrayRemove(listeners, fn),
34 | }
35 | }
36 |
37 | export default initStorageAreas()
38 |
--------------------------------------------------------------------------------
/src/background/environment/storage-areas/local.js:
--------------------------------------------------------------------------------
1 | import promisifyChromeApi from '@libs/promisifyChromeApi'
2 | import safelyInvokeFns from '@libs/safelyInvokeFns'
3 |
4 | function initLocalStorage() {
5 | const get = promisifyChromeApi(::chrome.storage.local.get)
6 | const set = promisifyChromeApi(::chrome.storage.local.set)
7 | const remove = promisifyChromeApi(::chrome.storage.local.remove)
8 | const listeners = []
9 |
10 | chrome.storage.local.onChanged.addListener(changes => {
11 | safelyInvokeFns({
12 | fns: listeners,
13 | args: [ changes ],
14 | })
15 | })
16 |
17 | return {
18 | async read(key) {
19 | return (await get(key))[key]
20 | },
21 |
22 | readAll() {
23 | return get(null)
24 | },
25 |
26 | async write(key, value) {
27 | await set({ [key]: value })
28 | },
29 |
30 | async writeAll(object) {
31 | await set(object)
32 | },
33 |
34 | async delete(key) {
35 | await remove(key)
36 | },
37 |
38 | listen(fn) {
39 | listeners.push(fn)
40 | },
41 | }
42 | }
43 |
44 | export default initLocalStorage()
45 |
--------------------------------------------------------------------------------
/src/background/environment/storage-areas/session.js:
--------------------------------------------------------------------------------
1 | function initSessionStorage() {
2 | const memoryStorage = {}
3 |
4 | return {
5 | read(key) {
6 | // eslint-disable-next-line no-prototype-builtins
7 | const isExists = memoryStorage.hasOwnProperty(key)
8 | const value = isExists ? memoryStorage[key] : null
9 |
10 | return value
11 | },
12 |
13 | readAll() {
14 | return { ...memoryStorage }
15 | },
16 |
17 | write(key, value) {
18 | memoryStorage[key] = value
19 | },
20 |
21 | delete(key) {
22 | delete memoryStorage[key]
23 | },
24 | }
25 | }
26 |
27 | export default initSessionStorage()
28 |
--------------------------------------------------------------------------------
/src/background/environment/storage-areas/sync.js:
--------------------------------------------------------------------------------
1 | import deepEqual from 'fast-deep-equal'
2 | import promisifyChromeApi from '@libs/promisifyChromeApi'
3 | import safelyInvokeFns from '@libs/safelyInvokeFns'
4 |
5 | function initSyncStorage() {
6 | const get = promisifyChromeApi(::chrome.storage.sync.get)
7 | const set = promisifyChromeApi(::chrome.storage.sync.set)
8 | const remove = promisifyChromeApi(::chrome.storage.sync.remove)
9 | const listeners = []
10 |
11 | chrome.storage.sync.onChanged.addListener(changes => {
12 | safelyInvokeFns({
13 | fns: listeners,
14 | args: [ changes ],
15 | })
16 | })
17 |
18 | return {
19 | async read(key) {
20 | return (await get(key))[key]
21 | },
22 |
23 | readAll() {
24 | return get(null)
25 | },
26 |
27 | async write(key, value) {
28 | const oldValue = await get(key)
29 |
30 | // 写入操作有限额,因此只在值确实发生了变化的情况下写入
31 | // Chrome 应该自带这个判断,但是实测并没有
32 | if (!deepEqual(oldValue, value)) {
33 | // 但是这个操作仍然可能因为超出限额而报错,需要注意
34 | await set({ [key]: value })
35 | }
36 | },
37 |
38 | async writeAll(object) {
39 | const oldValue = {}
40 |
41 | for (const key of Object.keys(object)) {
42 | oldValue[key] = await get(key)
43 | }
44 |
45 | if (!deepEqual(oldValue, object)) {
46 | await set(object)
47 | }
48 | },
49 |
50 | async delete(key) {
51 | await remove(key)
52 | },
53 |
54 | listen(fn) {
55 | listeners.push(fn)
56 | },
57 | }
58 | }
59 |
60 | export default initSyncStorage()
61 |
--------------------------------------------------------------------------------
/src/background/feature/createSubfeatureClass.js:
--------------------------------------------------------------------------------
1 | import pick from 'just-pick'
2 | import every from '@libs/promiseEvery'
3 |
4 | export default ({ modules }) => class Subfeature {
5 | constructor({ featureName, subfeatureName, script, parent }) {
6 | this.featureName = featureName
7 | this.subfeatureName = subfeatureName
8 | this.parent = parent
9 | this.initContext()
10 |
11 | const featureScriptObj = script(this.context)
12 |
13 | this.migrations = featureScriptObj.migrations
14 | this.script = pick(featureScriptObj, [
15 | 'onLoad',
16 | 'onSettingsChange',
17 | 'onUnload',
18 | ])
19 | }
20 |
21 | initContext() {
22 | this.waitReadyFns = []
23 |
24 | this.context = pick(this, [
25 | 'requireModules',
26 | 'readOptionValue',
27 | ])
28 | }
29 |
30 | requireModules = moduleNames => {
31 | const requiredModules = {}
32 |
33 | for (const moduleName of moduleNames) {
34 | const module = modules[moduleName]
35 |
36 | if (!module) throw new Error(`未知 module:${moduleName}`)
37 |
38 | this.waitReadyFns.push(module.ready)
39 | requiredModules[moduleName] = module
40 | }
41 |
42 | return requiredModules
43 | }
44 |
45 | readOptionValue = key => {
46 | const optionName = `${this.featureName}/${key}`
47 | const optionValue = this.parent.optionValuesCache[optionName]
48 |
49 | return optionValue
50 | }
51 |
52 | async load() {
53 | await every(this.waitReadyFns.map(fn => fn()))
54 | await this.script.onLoad?.()
55 | }
56 |
57 | async unload() {
58 | await this.script.onUnload?.()
59 | }
60 |
61 | handleSettingsChange() {
62 | // eslint-disable-next-line no-unused-expressions
63 | this.script.onSettingsChange?.()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/background/index.js:
--------------------------------------------------------------------------------
1 | export { default as createEnvironment } from './environment'
2 | export { default as createFeatureClass } from './feature/createFeatureClass'
3 | export { default as createSubfeatureClass } from './feature/createSubfeatureClass'
4 | export { default as modules } from './modules'
5 |
--------------------------------------------------------------------------------
/src/background/modules/index.js:
--------------------------------------------------------------------------------
1 | function loadModules() {
2 | const modules = {}
3 | const context = require.context('./', false, /\.js$/)
4 |
5 | for (const key of context.keys()) {
6 | if (key.endsWith(__filename)) continue
7 |
8 | const moduleName = key.replace(/^\.\/|\.js$/g, '')
9 | const module = context(key).default
10 |
11 | modules[moduleName] = module
12 | }
13 |
14 | return modules
15 | }
16 |
17 | export default loadModules()
18 |
--------------------------------------------------------------------------------
/src/background/modules/notification.js:
--------------------------------------------------------------------------------
1 | import pick from 'just-pick'
2 | import debounce from 'just-debounce-it'
3 | import settings from '@background/environment/settings'
4 | import wrapper from '@libs/wrapper'
5 | import playSound from '@libs/playSound'
6 | import noop from '@libs/noop'
7 |
8 | const DEFAULT_NOTIFICATION_TIMEOUT = 15 * 1000
9 | const SOUND_URL = require('@assets/sounds/ding.mp3')
10 |
11 | const notificationMap = {}
12 |
13 | // 使用 debounce 避免多个通知同时弹出导致重复播放提示音(音量会偏大)
14 | const playSoundForNotification = debounce(() => {
15 | if (settings.read('notifications/playSound')) {
16 | playSound(SOUND_URL)
17 | }
18 | }, 1)
19 |
20 | function createNotification(opts) {
21 | const {
22 | id,
23 | title = '太空饭否',
24 | message,
25 | timeout = DEFAULT_NOTIFICATION_TIMEOUT,
26 | buttonDefs = [],
27 | } = opts
28 | const buttons = buttonDefs.map(buttonDef => pick(buttonDef, [ 'title' ]))
29 |
30 | if (!id) {
31 | throw new Error('必须指定通知的 id')
32 | }
33 |
34 | // 如果存在活跃的同名实例,先销毁它
35 | destroyNotification(id)
36 |
37 | chrome.notifications.create(id, {
38 | type: 'basic',
39 | iconUrl: '/icons/icon-256.png',
40 | title,
41 | message,
42 | buttons,
43 | // 避免被 Windows 自动收入到通知中心,否则之后就没办法用代码清除了
44 | // 而且一旦被收入通知中心,下次就不能再弹同 ID 的通知
45 | requireInteraction: true,
46 | // 禁用系统默认的提示音
47 | silent: true,
48 | })
49 |
50 | playSoundForNotification()
51 | opts.timeoutId = setTimeout(() => {
52 | destroyNotification(id)
53 | }, timeout)
54 |
55 | notificationMap[id] = opts
56 | }
57 |
58 | function destroyNotification(id) {
59 | const opts = notificationMap[id]
60 |
61 | if (opts) {
62 | clearTimeout(opts.timeoutId)
63 | chrome.notifications.clear(id)
64 | delete notificationMap[id]
65 | }
66 | }
67 |
68 | function onNotificationClicked(id) {
69 | const opts = notificationMap[id]
70 |
71 | if (opts) {
72 | const handler = opts.onClick || noop
73 |
74 | handler()
75 | destroyNotification(id)
76 | }
77 | }
78 |
79 | function onButtonClicked(id, buttonIndex) {
80 | const opts = notificationMap[id]
81 |
82 | if (opts) {
83 | const handler = opts.buttonDefs?.[buttonIndex]?.onClick || noop
84 |
85 | handler()
86 | destroyNotification(id)
87 | }
88 | }
89 |
90 | export default wrapper({
91 | install() {
92 | chrome.notifications.onClicked.addListener(onNotificationClicked)
93 | chrome.notifications.onButtonClicked.addListener(onButtonClicked)
94 | },
95 |
96 | create: createNotification,
97 |
98 | hide: destroyNotification,
99 | })
100 |
--------------------------------------------------------------------------------
/src/background/modules/storage.js:
--------------------------------------------------------------------------------
1 | import pick from 'just-pick'
2 | import storage from '@background/environment/storage'
3 | import wrapper from '@libs/wrapper'
4 |
5 | export default wrapper(pick(storage, [ 'read', 'write', 'delete' ]))
6 |
--------------------------------------------------------------------------------
/src/constants/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | rules: {
3 | 'import/prefer-default-export': 0,
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/src/constants/action-types.js:
--------------------------------------------------------------------------------
1 | export const STORAGE_READ = 'STORAGE_READ'
2 | export const STORAGE_WRITE = 'STORAGE_WRITE'
3 | export const STORAGE_DELETE = 'STORAGE_DELETE'
4 | export const STORAGE_CHANGED = 'STORAGE_CHANGED'
5 |
6 | export const SETTINGS_READ = 'SETTINGS_READ'
7 | // export const SETTINGS_WRITE = 'SETTINGS_WRITE'
8 | export const SETTINGS_READ_ALL = 'SETTINGS_READ_ALL'
9 | export const SETTINGS_WRITE_ALL = 'SETTINGS_WRITE_ALL'
10 | export const SETTINGS_CHANGED = 'SETTINGS_CHANGED'
11 |
12 | export const GET_OPTION_DEFS = 'GET_OPTION_DEFS'
13 |
14 | export const PROXIED_FETCH_GET = 'PROXIED_FETCH_GET'
15 | export const PROXIED_AUDIO = 'PROXIED_AUDIO'
16 | export const PROXIED_CREATE_TAB = 'PROXIED_CREATE_TAB'
17 |
--------------------------------------------------------------------------------
/src/constants/assets.js:
--------------------------------------------------------------------------------
1 | // 所有注入到页面的 都应该加上这个类名
2 | export const ASSET_CLASSNAME = 'sf-asset'
3 |
--------------------------------------------------------------------------------
/src/constants/custom-event-types.js:
--------------------------------------------------------------------------------
1 | // content script / page script 间通信用的桥所使用的事件名
2 | export const BRIDGE_EVENT_TYPE = 'SpaceFanfouBridgeMessage'
3 | // 发送消息成功
4 | export const POST_STATUS_SUCCESS_EVENT_TYPE = 'SpaceFanfouPostStatusSuccess'
5 | // 扩展停用
6 | export const EXTENSION_UNLOADED_EVENT_TYPE = 'SpaceFanfouUnloaded'
7 |
--------------------------------------------------------------------------------
/src/constants/extension-origin.js:
--------------------------------------------------------------------------------
1 | // 我们在构建时期无法得知扩展的 id,使用一个占位符来表示
2 | // 在运行时拿到扩展 id 再做替换
3 | // 替换后格式为 chrome-extension://
4 | export const EXTENSION_ORIGIN_PLACEHOLDER = ''
5 | export const EXTENSION_ORIGIN_PLACEHOLDER_RE = new RegExp(EXTENSION_ORIGIN_PLACEHOLDER, 'g')
6 |
--------------------------------------------------------------------------------
/src/constants/index.js:
--------------------------------------------------------------------------------
1 | function loadConstants() {
2 | const context = require.context('./', false, /\.js$/)
3 |
4 | return context.keys().reduce((constants, key) => {
5 | return key.endsWith(__filename)
6 | ? constants
7 | : Object.assign(constants, context(key))
8 | }, {})
9 | }
10 |
11 | module.exports = Object.assign(exports, loadConstants())
12 |
--------------------------------------------------------------------------------
/src/constants/message-types.js:
--------------------------------------------------------------------------------
1 | // 由 background script 向所有运行环境广播消息(包括 background script 自身)
2 | export const BROADCASTING_MESSAGE = 'BROADCASTING_MESSAGE'
3 | // 不期望回复的单程消息
4 | // export const ONE_WAY_MESSAGE = 'ONE_WAY_MESSAGE'
5 | // 期望回复的对话式消息
6 | export const CONVERSATIONAL_MESSAGE = 'CONVERSATIONAL_MESSAGE'
7 |
--------------------------------------------------------------------------------
/src/constants/others.js:
--------------------------------------------------------------------------------
1 | // 在 metadata 中定义选项时,label 中可以使用这个占位符来表示控件的位置
2 | // 比如 可以出现在 label 文本中部
3 | export const CONTROL_PLACEHOLDER = ''
4 |
5 | export const STORAGE_KEY_IS_EXTENSION_UPGRADED = 'is-extension-upgraded'
6 | export const STORAGE_AREA_NAME_IS_EXTENSION_UPGRADED = 'session'
7 |
--------------------------------------------------------------------------------
/src/content/environment/bridge.js:
--------------------------------------------------------------------------------
1 | import messaging from './messaging'
2 | import wrapper from '@libs/wrapper'
3 | import { BRIDGE_EVENT_TYPE, BROADCASTING_MESSAGE } from '@constants'
4 |
5 | async function eventHandler(event) {
6 | const { from, senderId, message } = event.detail
7 |
8 | if (from === 'page') {
9 | const respondedMessage = await bridge.postMessageToBackground(message)
10 |
11 | bridge.postMessageToInjected({
12 | senderId,
13 | message: respondedMessage,
14 | })
15 | }
16 | }
17 |
18 | function handleBroadcastMessage(message) {
19 | const event = new CustomEvent(BRIDGE_EVENT_TYPE, {
20 | detail: {
21 | type: BROADCASTING_MESSAGE,
22 | message,
23 | },
24 | })
25 |
26 | window.dispatchEvent(event)
27 | }
28 |
29 | const bridge = wrapper({
30 | async install() {
31 | await messaging.ready()
32 | messaging.registerBroadcastListener(handleBroadcastMessage)
33 | window.addEventListener(BRIDGE_EVENT_TYPE, eventHandler)
34 | },
35 |
36 | uninstall() {
37 | window.removeEventListener(BRIDGE_EVENT_TYPE, eventHandler)
38 | },
39 |
40 | async postMessageToBackground(message) {
41 | const respondedMessage = await messaging.postMessage(message)
42 |
43 | return respondedMessage
44 | },
45 |
46 | postMessageToInjected({ senderId, message }) {
47 | const event = new CustomEvent(BRIDGE_EVENT_TYPE, {
48 | detail: {
49 | from: 'background',
50 | senderId,
51 | message,
52 | },
53 | })
54 |
55 | window.dispatchEvent(event)
56 | },
57 | })
58 |
59 | export default bridge
60 |
--------------------------------------------------------------------------------
/src/content/environment/index.js:
--------------------------------------------------------------------------------
1 | import messaging from './messaging'
2 | import bridge from './bridge'
3 | import settings from './settings'
4 | import injectScript from './injectScript'
5 | import injectMainStyle from './injectMainStyle'
6 |
7 | export default async function createContentEnvironment() {
8 | messaging.ready()
9 | bridge.ready()
10 | injectScript()
11 | injectMainStyle()
12 | await settings.ready()
13 |
14 | return { messaging, settings }
15 | }
16 |
--------------------------------------------------------------------------------
/src/content/environment/injectMainStyle.js:
--------------------------------------------------------------------------------
1 | import waitForHead from '@libs/waitForHead'
2 | import loadAsset, { insertImmediatelyAfterHead } from '@libs/loadAsset'
3 | import getExtensionOrigin from '@libs/getExtensionOrigin'
4 |
5 | // 必须把主样式插入到饭否主样式( 中的 base.css)之后才能保证有足够优先级覆盖掉原有样式
6 | // (不希望滥用 !important,因为不仅会覆盖掉饭否的样式,我们自己的样式写起来也会受影响)
7 | // 选择插入到 之后而不是之内是因为,经过反复实验只有这样才能最大限度加快加载速度
8 | // 从而避免出现样式抖动现象(用户会先短暂看到饭否原来的样式,然后突然变成太空饭否的样式,体验很糟糕)
9 | export default async function injectMainStyle() {
10 | // 确保此时 已经存在
11 | await waitForHead()
12 |
13 | loadAsset({
14 | type: 'style',
15 | url: `${getExtensionOrigin()}/page.css`,
16 | // 插入到