├── .eslintrc.js
├── .github
└── workflows
│ ├── codeql-analysis.yml
│ ├── git-issue-release.yml
│ └── nodejs.yml
├── .gitignore
├── .prettierrc.js
├── LICENSE
├── README.md
├── __tests__
├── ButtonSetter.test.js
├── ButtonSetterTweetDeck.test.js
├── Constants.test.js
├── Utils.test.js
├── __snapshots__
│ ├── ButtonSetter.test.js.snap
│ ├── ButtonSetterTweetDeck.test.js.snap
│ ├── Utils.test.js.snap
│ └── popup.test.js.snap
├── options.test.js
└── popup.test.js
├── babel.config.js
├── biome.json
├── coverage
└── badge.svg
├── description-for-edge-submisson.txt
├── dist
├── css
│ └── popup.css
├── html
│ └── popup.html
├── icons
│ └── icon.png
└── manifest.json
├── images
├── detail1.jpg
├── detail1.png
├── detail2.jpg
├── detail2.png
├── options.png
├── timeline1.jpg
├── timeline1.png
├── timeline2.jpg
└── timeline2.png
├── jest.config.js
├── jest.setup.js
├── package.json
├── pnpm-lock.yaml
├── privacy-policy.md
├── renovate.json5
├── scripts
└── make_user_script.sh
├── src
├── ButtonSetter.ts
├── ButtonSetterTweetDeck.ts
├── constants.ts
├── extension-contexts
│ ├── background.ts
│ ├── options.ts
│ └── popup.tsx
├── input.css
├── main.ts
└── utils.ts
├── tailwind.config.js
├── tooi-forGreaseTamperMonkey.user.js
├── tsconfig.json
└── webpack.config.js
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true,
5 | 'jest/globals': true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'plugin:react/recommended',
11 | 'plugin:jest/recommended',
12 | 'plugin:prettier/recommended',
13 | ],
14 | globals: {
15 | chrome: 'readonly',
16 | window: true,
17 | document: 'readonly',
18 | },
19 | parser: '@typescript-eslint/parser',
20 | parserOptions: {
21 | ecmaFeatures: {
22 | jsx: true,
23 | },
24 | ecmaVersion: 2018,
25 | sourceType: 'module',
26 | project: './tsconfig.json',
27 | },
28 | plugins: ['react', '@typescript-eslint', 'jest'],
29 | rules: {
30 | 'no-console': 'error',
31 | eqeqeq: 'error',
32 | '@typescript-eslint/explicit-function-return-type': 'off',
33 | '@typescript-eslint/no-non-null-assertion': 'off',
34 | 'react/react-in-jsx-scope': 'off',
35 | },
36 | overrides: [
37 | {
38 | files: ['src/**/*.ts', 'src/**/*.tsx'],
39 | rules: {
40 | '@typescript-eslint/explicit-function-return-type': 'error',
41 | },
42 | },
43 | ],
44 | settings: {
45 | react: {
46 | version: 'detect',
47 | },
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '17 9 * * 0'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v4
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v3
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v3
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v3
72 |
--------------------------------------------------------------------------------
/.github/workflows/git-issue-release.yml:
--------------------------------------------------------------------------------
1 | name: git-issue-release
2 |
3 | on:
4 | pull_request: # Automatically create or update issues when pull request is merged.
5 | types: [closed]
6 | release: # Automatically close the latest issue when release is published.
7 | types: [published]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | action:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: git-issue-release
15 | uses: kouki-dan/git-issue-release@v1
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 | with:
19 | release-issue-title: 'Next release'
20 | release-issue-title-published: 'Next release: :tag_name: released'
21 | release-tag-pattern: ^v # Use it to find the latest release. `^v` means starts with v.
22 | release-label: 'release' # Use it to find release issues. Labels are not created automatically, so create them before use it.
23 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push, workflow_dispatch]
4 |
5 | permissions:
6 | contents: read
7 | jobs:
8 | check:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - name: pnpm install
13 | uses: pnpm/action-setup@v4
14 | with:
15 | version: 9
16 | run_install: true
17 |
18 | - name: build
19 | run: pnpm run build
20 |
21 | - name: test
22 | run: pnpm run test
23 | env:
24 | CI: true
25 | # for jest
26 | FORCE_COLOR: true
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.swp
3 | node_modules
4 | *.log
5 |
6 | /coverage/
7 | !/coverage/coverage-summary.json
8 | !/coverage/badge.svg
9 |
10 | /dist/js/
11 | /scripts/tmp/
12 |
13 | /tmp/
14 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | // .prettierrc.js
2 | module.exports = {
3 | // printWidth: 100,
4 | // parser: "flow",
5 | singleQuote: true,
6 | trailingComma: 'es5',
7 | arrowParens: "avoid",
8 | };
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # twitter画像原寸ボタン
2 |
3 | [](https://github.com/hogashi/twitterOpenOriginalImage/releases)
4 | [](https://github.com/hogashi/twitterOpenOriginalImage/actions?query=workflow%3A%22Node.js+CI%22)
5 | [](https://github.com/hogashi/twitterOpenOriginalImage/actions?query=branch%3Amaster)
6 |
7 | 画像ツイートの画像の原寸を新しいタブで開く、GoogleChrome拡張機能です。
8 | TwitterWeb公式、TweetDeckで動作します。
9 |
10 | ## インストール
11 |
12 | - Chromeウェブストアからインストール: https://chrome.google.com/webstore/detail/kmcomcgcopagkhcbmcmcfhpcmdolfijg
13 | - Edge Addons websiteからインストール: https://microsoftedge.microsoft.com/addons/detail/dkooamjhbcblfbjabpnhefbajlbjoilb
14 |
15 | ## 使い方
16 |
17 | 画像ツイートに付いた"Original"ボタンをクリックすると、原寸画像が新しいタブに開かれます。
18 |
19 |
20 |
21 | - "原寸画像"とは、次のような画像を指します: `https://pbs.twimg.com/media/CE-1mwkVAAE6Rop?format=jpg&name=orig`
22 | - これは通常の(縮小された)画像のURL ( `https://pbs.twimg.com/media/CE-1mwkVAAE6Rop.jpg` ) に `?name=orig` を足したものです
23 | - 縮小の閾値は随時変更されているようなので、 `?name=orig` を付けたものが必ず原寸とは限りません
24 |
25 | ### 設定
26 |
27 | 1. Chromeのウィンドウ右上にある拡張機能のボタンをクリックする(またはChromeの拡張機能の設定からこの拡張機能の"オプション"をクリックする)
28 | 1. 使いたい機能にチェックを入れ、"設定を保存"をクリックする
29 |
30 |
31 |
32 | ## 注意
33 |
34 | 求める権限に「閲覧履歴の読み取り」がありますが、閲覧履歴を読み取ることはしていません。
35 | タブ間の設定共有のために、タブ操作の権限を使っているため、それが「閲覧履歴の読み取り」と表示されています。
36 |
37 | - 公式ドキュメント: [chrome.tabs - Google Chrome](https://developer.chrome.com/extensions/tabs)
38 | - プライバシーポリシー: [./privacy-policy.md](./privacy-policy.md)
39 |
40 | ## 連絡先
41 |
42 | - GitHub: [hogashi](https://github.com/hogashi)
43 | - Twitter: [@hogextend](https://twitter.com/hogextend)
44 |
--------------------------------------------------------------------------------
/__tests__/ButtonSetter.test.js:
--------------------------------------------------------------------------------
1 | import { ButtonSetter } from '../src/ButtonSetter';
2 | // import * as main from '../src/main';
3 | import { SHOW_ON_TIMELINE, SHOW_ON_TWEET_DETAIL } from '../src/constants';
4 |
5 | function makeAllEnabledOptions() {
6 | return {
7 | SHOW_ON_TIMELINE: true,
8 | SHOW_ON_TWEET_DETAIL: true,
9 | SHOW_ON_TWEETDECK_TIMELINE: true,
10 | SHOW_ON_TWEETDECK_TWEET_DETAIL: true,
11 | STRIP_IMAGE_SUFFIX: true,
12 | };
13 | }
14 |
15 | describe('ButtonSetter', () => {
16 | describe('setButtonOnTimeline', () => {
17 | it('従来のレイアウト(not React)', () => {
18 | const buttonSetter = new ButtonSetter();
19 | buttonSetter._setButtonOnTimeline = jest.fn();
20 | buttonSetter._setButtonOnReactLayoutTimeline = jest.fn();
21 |
22 | buttonSetter.setButtonOnTimeline(makeAllEnabledOptions());
23 |
24 | expect(buttonSetter._setButtonOnTimeline).toHaveBeenCalledTimes(1);
25 | expect(buttonSetter._setButtonOnReactLayoutTimeline).not.toHaveBeenCalled();
26 | });
27 |
28 | it('新しいレイアウト(React)', () => {
29 | const buttonSetter = new ButtonSetter();
30 | buttonSetter._setButtonOnTimeline = jest.fn();
31 | buttonSetter._setButtonOnReactLayoutTimeline = jest.fn();
32 |
33 | const root = document.createElement('div');
34 | root.setAttribute('id', 'react-root');
35 | document.querySelector('body').appendChild(root);
36 | buttonSetter.setButtonOnTimeline(makeAllEnabledOptions());
37 |
38 | expect(buttonSetter._setButtonOnTimeline).not.toHaveBeenCalled();
39 |
40 | expect(buttonSetter._setButtonOnReactLayoutTimeline).toHaveBeenCalledTimes(1);
41 | });
42 | });
43 |
44 | describe('setButtonOnTweetDetail', () => {
45 | it('詳細ツイートでもボタン置く(従来レイアウトのみ)', () => {
46 | const buttonSetter = new ButtonSetter();
47 | buttonSetter._setButtonOnTweetDetail = jest.fn();
48 |
49 | buttonSetter.setButtonOnTweetDetail(makeAllEnabledOptions());
50 | expect(buttonSetter._setButtonOnTweetDetail).toHaveBeenCalledTimes(1);
51 | });
52 | });
53 |
54 | describe('setButton', () => {
55 | const className = 'hogeclass';
56 | const imgSrcs = ['src1', 'src2'];
57 | const getImgSrcs = () => imgSrcs;
58 | const target = document.createElement('div');
59 | const text = 'Original';
60 |
61 | const buttonSetter = new ButtonSetter();
62 | buttonSetter.setButton({ className, getImgSrcs, target, text });
63 |
64 | it('ボタン設置される', () => {
65 | expect(target.innerHTML).toMatchSnapshot();
66 | });
67 |
68 | /* SKIP: なぜかうまくmockできないので飛ばす */
69 | // it('ボタン押すとonClick呼ばれる', () => {
70 | // main.onOriginalButtonClick = jest.fn();
71 | // const button = target.querySelector('input');
72 | // button.click();
73 | // expect(main.onOriginalButtonClick).toHaveBeenCalledTimes(1);
74 | // expect(main.onOriginalButtonClick.mock.calls[0][0]).toBeInstanceOf(
75 | // MouseEvent
76 | // );
77 | // expect(main.onOriginalButtonClick.mock.calls[0][1]).toStrictEqual(
78 | // imgSrcs
79 | // );
80 | // });
81 | });
82 |
83 | describe('setButtonにボタンのテキストを指定するとそれが入る', () => {
84 | const className = 'hogeclass';
85 | const imgSrcs = ['src1', 'src2'];
86 | const getImgSrcs = () => imgSrcs;
87 | const target = document.createElement('div');
88 | const text = '原寸';
89 |
90 | const buttonSetter = new ButtonSetter();
91 | buttonSetter.setButton({ className, getImgSrcs, target, text });
92 |
93 | it('指定したテキストでボタン設置される', () => {
94 | expect(target.innerHTML).toMatchSnapshot();
95 | });
96 | });
97 |
98 | describe('setReactLayoutButton', () => {
99 | const className = 'hogeclass';
100 | const imgSrcs = ['src1', 'src2'];
101 | const getImgSrcs = () => imgSrcs;
102 | const target = document.createElement('div');
103 | const text = 'Original';
104 |
105 | const buttonSetter = new ButtonSetter();
106 | buttonSetter.setReactLayoutButton({ className, getImgSrcs, target, text });
107 |
108 | it('ボタン設置される', () => {
109 | expect(target.innerHTML).toMatchSnapshot();
110 | });
111 |
112 | /* SKIP: なぜかうまくmockできないので飛ばす */
113 | // it('ボタン押すとonClick呼ばれる', () => {
114 | // main.onOriginalButtonClick = jest.fn();
115 | // const button = target.querySelector('input');
116 | // button.click();
117 | // expect(main.onOriginalButtonClick).toHaveBeenCalledTimes(1);
118 | // expect(main.onOriginalButtonClick.mock.calls[0][0]).toBeInstanceOf(
119 | // MouseEvent
120 | // );
121 | // expect(main.onOriginalButtonClick.mock.calls[0][1]).toStrictEqual(
122 | // imgSrcs
123 | // );
124 | // });
125 | });
126 |
127 | describe('_setButtonOnTimeline', () => {
128 | /**
129 | * @param {string[]} imgSrcs
130 | * @param {HTMLElement[]} extraElements
131 | * @param {boolean} hasButton
132 | */
133 | const makeTweet = (imgSrcs, extraElements = [], hasButton = false) => {
134 | const root = document.createElement('div');
135 | root.classList.add('js-stream-tweet');
136 |
137 | const media = document.createElement('div');
138 | media.classList.add('AdaptiveMedia-container');
139 |
140 | const actionList = document.createElement('div');
141 | actionList.classList.add('ProfileTweet-actionList');
142 | if (hasButton) {
143 | const button = document.createElement('div');
144 | button.classList.add('tooi-button-container-timeline');
145 | actionList.appendChild(button);
146 | }
147 |
148 | imgSrcs.forEach((src) => {
149 | const div = document.createElement('div');
150 | div.classList.add('AdaptiveMedia-photoContainer');
151 |
152 | const img = document.createElement('img');
153 | img.src = src;
154 |
155 | div.appendChild(img);
156 | media.appendChild(div);
157 | });
158 |
159 | extraElements.forEach((element) => media.appendChild(element));
160 |
161 | root.appendChild(media);
162 | root.appendChild(actionList);
163 | document.body.appendChild(root);
164 | };
165 |
166 | beforeEach(() => {
167 | document.body.innerHTML = '';
168 | });
169 |
170 | it('画像1枚ツイート1つにボタンつけようとする', () => {
171 | const imgSrcs = ['https://g.co/img1'];
172 | makeTweet(imgSrcs);
173 |
174 | const buttonSetter = new ButtonSetter();
175 | buttonSetter.setButton = jest.fn();
176 |
177 | const options = makeAllEnabledOptions();
178 | buttonSetter._setButtonOnTimeline(options);
179 |
180 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
181 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual('tooi-button-container-timeline');
182 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
183 | expect(buttonSetter.setButton.mock.calls[0][0].target.classList.contains('ProfileTweet-actionList')).toBeTruthy();
184 | });
185 |
186 | it('画像1枚ツイート3つにボタンつけようとする', () => {
187 | const imgSrcsSet = [['https://g.co/img1'], ['https://g.co/img2'], ['https://g.co/img3']];
188 | imgSrcsSet.forEach((imgSrcs) => {
189 | makeTweet(imgSrcs);
190 | });
191 |
192 | const buttonSetter = new ButtonSetter();
193 | buttonSetter.setButton = jest.fn();
194 |
195 | const options = makeAllEnabledOptions();
196 | buttonSetter._setButtonOnTimeline(options);
197 |
198 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(3);
199 | imgSrcsSet.forEach((imgSrcs, index) => {
200 | expect(buttonSetter.setButton.mock.calls[index][0].className).toStrictEqual('tooi-button-container-timeline');
201 | expect(buttonSetter.setButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
202 | expect(
203 | buttonSetter.setButton.mock.calls[index][0].target.classList.contains('ProfileTweet-actionList'),
204 | ).toBeTruthy();
205 | });
206 | });
207 |
208 | it('画像4枚ツイート3つにボタンつけようとする', () => {
209 | const imgSrcsSet = [
210 | ['https://g.co/img11', 'https://g.co/img12', 'https://g.co/img13', 'https://g.co/img14'],
211 | ['https://g.co/img21', 'https://g.co/img22', 'https://g.co/img23', 'https://g.co/img24'],
212 | ['https://g.co/img31', 'https://g.co/img32', 'https://g.co/img33', 'https://g.co/img34'],
213 | ];
214 | imgSrcsSet.forEach((imgSrcs) => {
215 | makeTweet(imgSrcs);
216 | });
217 |
218 | const buttonSetter = new ButtonSetter();
219 | buttonSetter.setButton = jest.fn();
220 |
221 | const options = makeAllEnabledOptions();
222 | buttonSetter._setButtonOnTimeline(options);
223 |
224 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(3);
225 | imgSrcsSet.forEach((imgSrcs, index) => {
226 | expect(buttonSetter.setButton.mock.calls[index][0].className).toStrictEqual('tooi-button-container-timeline');
227 | expect(buttonSetter.setButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
228 | expect(
229 | buttonSetter.setButton.mock.calls[index][0].target.classList.contains('ProfileTweet-actionList'),
230 | ).toBeTruthy();
231 | });
232 | });
233 |
234 | it('動画/GIFのツイート1つにボタンつけない', () => {
235 | const imgSrcs = [];
236 | makeTweet(imgSrcs, [document.createElement('video')]);
237 |
238 | const buttonSetter = new ButtonSetter();
239 | buttonSetter.setButton = jest.fn();
240 |
241 | const options = makeAllEnabledOptions();
242 | buttonSetter._setButtonOnTimeline(options);
243 |
244 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
245 | });
246 |
247 | it('画像1枚ツイート1つでも,もうボタンあったらボタンつけない', () => {
248 | const imgSrcs = ['https://g.co/img1'];
249 | makeTweet(imgSrcs, [], true);
250 |
251 | const buttonSetter = new ButtonSetter();
252 | buttonSetter.setButton = jest.fn();
253 |
254 | const options = makeAllEnabledOptions();
255 | buttonSetter._setButtonOnTimeline(options);
256 |
257 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
258 | });
259 |
260 | it('画像ツイート1つあっても画像なかったらボタンつけない', () => {
261 | const imgSrcs = [];
262 | makeTweet(imgSrcs);
263 |
264 | const buttonSetter = new ButtonSetter();
265 | buttonSetter.setButton = jest.fn();
266 |
267 | const options = makeAllEnabledOptions();
268 | buttonSetter._setButtonOnTimeline(options);
269 |
270 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
271 | });
272 |
273 | it('ツイート1つあっても画像ツイートじゃなかったらボタンつけない', () => {
274 | const imgSrcs = [];
275 | makeTweet(imgSrcs);
276 |
277 | const container = document.querySelector('.AdaptiveMedia-container');
278 | container.parentNode.removeChild(container);
279 |
280 | const buttonSetter = new ButtonSetter();
281 | buttonSetter.setButton = jest.fn();
282 |
283 | const options = makeAllEnabledOptions();
284 | buttonSetter._setButtonOnTimeline(options);
285 |
286 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
287 | });
288 |
289 | it('actionList(いいねとか)がなかったらボタンつけない', () => {
290 | const imgSrcs = ['https://g.co/img1'];
291 | makeTweet(imgSrcs);
292 |
293 | const actionList = document.querySelector('.ProfileTweet-actionList');
294 | actionList.parentNode.removeChild(actionList);
295 |
296 | const buttonSetter = new ButtonSetter();
297 | buttonSetter.setButton = jest.fn();
298 |
299 | const options = makeAllEnabledOptions();
300 | buttonSetter._setButtonOnTimeline(options);
301 |
302 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
303 | });
304 |
305 | it('画像ツイートなかったら何もしない', () => {
306 | const buttonSetter = new ButtonSetter();
307 | buttonSetter.setButton = jest.fn();
308 |
309 | const options = makeAllEnabledOptions();
310 | buttonSetter._setButtonOnTimeline(options);
311 |
312 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
313 | });
314 |
315 | it('設定がOFFなら何もしない', () => {
316 | const buttonSetter = new ButtonSetter();
317 | buttonSetter.setButton = jest.fn();
318 |
319 | const options = makeAllEnabledOptions();
320 | options[SHOW_ON_TIMELINE] = false;
321 | buttonSetter._setButtonOnTimeline(options);
322 |
323 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
324 | });
325 | });
326 |
327 | describe('_setButtonOnTweetDetail', () => {
328 | /**
329 | * @param {string[]} imgSrcs
330 | * @param {HTMLElement[]} extraElements
331 | * @param {boolean} hasButton
332 | */
333 | const makeTweetDetail = (imgSrcs, extraElements = [], hasButton = false) => {
334 | const root = document.createElement('div');
335 | root.classList.add('permalink-tweet-container');
336 |
337 | const actionList = document.createElement('div');
338 | actionList.classList.add('ProfileTweet-actionList');
339 | if (hasButton) {
340 | const button = document.createElement('div');
341 | button.classList.add('tooi-button-container-detail');
342 | actionList.appendChild(button);
343 | }
344 |
345 | imgSrcs.forEach((src) => {
346 | const media = document.createElement('div');
347 | media.classList.add('AdaptiveMedia-photoContainer');
348 |
349 | const img = document.createElement('img');
350 | img.src = src;
351 |
352 | media.appendChild(img);
353 | root.appendChild(media);
354 | });
355 |
356 | extraElements.forEach((element) => root.appendChild(element));
357 |
358 | root.appendChild(actionList);
359 | document.body.appendChild(root);
360 | };
361 |
362 | beforeEach(() => {
363 | document.body.innerHTML = '';
364 | });
365 |
366 | it('画像1枚ツイート詳細にボタンつけようとする', () => {
367 | const imgSrcs = ['https://g.co/img1'];
368 | makeTweetDetail(imgSrcs);
369 |
370 | const buttonSetter = new ButtonSetter();
371 | buttonSetter.setButton = jest.fn();
372 |
373 | const options = makeAllEnabledOptions();
374 | buttonSetter._setButtonOnTweetDetail(options);
375 |
376 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
377 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual('tooi-button-container-detail');
378 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
379 | expect(buttonSetter.setButton.mock.calls[0][0].target.classList.contains('ProfileTweet-actionList')).toBeTruthy();
380 | });
381 |
382 | it('画像4枚ツイート詳細にボタンつけようとする', () => {
383 | const imgSrcs = ['https://g.co/img1', 'https://g.co/img2', 'https://g.co/img3', 'https://g.co/img4'];
384 | makeTweetDetail(imgSrcs);
385 |
386 | const buttonSetter = new ButtonSetter();
387 | buttonSetter.setButton = jest.fn();
388 |
389 | const options = makeAllEnabledOptions();
390 | buttonSetter._setButtonOnTweetDetail(options);
391 |
392 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
393 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual('tooi-button-container-detail');
394 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
395 | expect(buttonSetter.setButton.mock.calls[0][0].target.classList.contains('ProfileTweet-actionList')).toBeTruthy();
396 | });
397 |
398 | it('画像でないツイート1つにボタンつけない', () => {
399 | const imgSrcs = ['https://g.co/video1'];
400 | makeTweetDetail(imgSrcs);
401 |
402 | const media = document.querySelectorAll('.AdaptiveMedia-photoContainer');
403 | media.forEach((medium) => medium.classList.remove('AdaptiveMedia-photoContainer'));
404 |
405 | const buttonSetter = new ButtonSetter();
406 | buttonSetter.setButton = jest.fn();
407 |
408 | const options = makeAllEnabledOptions();
409 | buttonSetter._setButtonOnTweetDetail(options);
410 |
411 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
412 | });
413 |
414 | it('画像1枚ツイート1つでも,もうボタンあったらボタンつけない', () => {
415 | const imgSrcs = ['https://g.co/img1'];
416 | makeTweetDetail(imgSrcs, [], true);
417 |
418 | const buttonSetter = new ButtonSetter();
419 | buttonSetter.setButton = jest.fn();
420 |
421 | const options = makeAllEnabledOptions();
422 | buttonSetter._setButtonOnTweetDetail(options);
423 |
424 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
425 | });
426 |
427 | it('ツイート1つあっても画像なかったらボタンつけない', () => {
428 | const imgSrcs = ['https://g.co/img1'];
429 | makeTweetDetail(imgSrcs);
430 |
431 | // 画像を全部消す
432 | const media = document.querySelectorAll('.AdaptiveMedia-photoContainer');
433 | media.forEach((medium) => medium.parentNode.removeChild(medium));
434 |
435 | const buttonSetter = new ButtonSetter();
436 | buttonSetter.setButton = jest.fn();
437 |
438 | const options = makeAllEnabledOptions();
439 | buttonSetter._setButtonOnTweetDetail(options);
440 |
441 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
442 | });
443 |
444 | it('actionListがなかったらボタンつけない', () => {
445 | const imgSrcs = ['https://g.co/img1'];
446 | makeTweetDetail(imgSrcs);
447 |
448 | const actionList = document.querySelector('.ProfileTweet-actionList');
449 | actionList.parentNode.removeChild(actionList);
450 |
451 | const buttonSetter = new ButtonSetter();
452 | buttonSetter.setButton = jest.fn();
453 |
454 | const options = makeAllEnabledOptions();
455 | buttonSetter._setButtonOnTweetDetail(options);
456 |
457 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
458 | });
459 |
460 | it('画像ツイートなかったら何もしない', () => {
461 | const buttonSetter = new ButtonSetter();
462 | buttonSetter.setButton = jest.fn();
463 |
464 | const options = makeAllEnabledOptions();
465 | buttonSetter._setButtonOnTweetDetail(options);
466 |
467 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
468 | });
469 |
470 | it('設定がOFFなら何もしない', () => {
471 | const buttonSetter = new ButtonSetter();
472 | buttonSetter.setButton = jest.fn();
473 |
474 | const options = makeAllEnabledOptions();
475 | options[SHOW_ON_TWEET_DETAIL] = false;
476 | buttonSetter.setButtonOnTweetDetail(options);
477 |
478 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
479 | });
480 | });
481 |
482 | describe('_setButtonOnReactLayoutTimeline', () => {
483 | /**
484 | * @param {string[]} imgSrcs
485 | * @param {string} type
486 | * @param {boolean} hasButton
487 | */
488 | const makeReactTweet = (imgSrcs, type = 'photo', hasButton = false) => {
489 | const reactRoot = document.createElement('div');
490 | reactRoot.id = 'react-root';
491 | const main = document.createElement('main');
492 | const section = document.createElement('section');
493 |
494 | const root = document.createElement('article');
495 |
496 | const actionList = document.createElement('div');
497 | actionList.setAttribute('role', 'group');
498 | if (hasButton) {
499 | const button = document.createElement('div');
500 | button.classList.add('tooi-button-container-react-timeline');
501 | actionList.appendChild(button);
502 | }
503 |
504 | imgSrcs.forEach((src, index) => {
505 | const aTag = document.createElement('a');
506 | aTag.href = `https://twitter.com/tos/status/000000/${type}/${index}`;
507 |
508 | const img = document.createElement('img');
509 | img.src = src;
510 |
511 | aTag.appendChild(img);
512 | root.appendChild(aTag);
513 | });
514 |
515 | root.appendChild(actionList);
516 |
517 | section.appendChild(root);
518 | main.appendChild(section);
519 | reactRoot.appendChild(main);
520 | document.body.appendChild(reactRoot);
521 | };
522 |
523 | beforeEach(() => {
524 | document.body.innerHTML = '';
525 | });
526 |
527 | it('画像1枚ツイート1つにボタンつけようとする', () => {
528 | const imgSrcs = ['https://g.co/img1'];
529 | makeReactTweet(imgSrcs);
530 |
531 | const buttonSetter = new ButtonSetter();
532 | buttonSetter.setReactLayoutButton = jest.fn();
533 |
534 | const options = makeAllEnabledOptions();
535 | buttonSetter._setButtonOnReactLayoutTimeline(options);
536 |
537 | expect(buttonSetter.setReactLayoutButton).toHaveBeenCalledTimes(1);
538 | expect(buttonSetter.setReactLayoutButton.mock.calls[0][0].className).toStrictEqual(
539 | 'tooi-button-container-react-timeline',
540 | );
541 | expect(buttonSetter.setReactLayoutButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
542 | expect(buttonSetter.setReactLayoutButton.mock.calls[0][0].target.getAttribute('role')).toStrictEqual('group');
543 | });
544 |
545 | it('画像1枚ツイート3つにボタンつけようとする', () => {
546 | const imgSrcsSet = [['https://g.co/img1'], ['https://g.co/img2'], ['https://g.co/img3']];
547 | imgSrcsSet.forEach((imgSrcs) => {
548 | makeReactTweet(imgSrcs);
549 | });
550 |
551 | const buttonSetter = new ButtonSetter();
552 | buttonSetter.setReactLayoutButton = jest.fn();
553 |
554 | const options = makeAllEnabledOptions();
555 | buttonSetter._setButtonOnReactLayoutTimeline(options);
556 |
557 | expect(buttonSetter.setReactLayoutButton).toHaveBeenCalledTimes(3);
558 | imgSrcsSet.forEach((imgSrcs, index) => {
559 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].className).toStrictEqual(
560 | 'tooi-button-container-react-timeline',
561 | );
562 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
563 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].target.getAttribute('role')).toStrictEqual(
564 | 'group',
565 | );
566 | });
567 | });
568 |
569 | it('画像4枚ツイート3つにボタンつけようとする', () => {
570 | const imgSrcsSet = [
571 | ['https://g.co/img11', 'https://g.co/img12', 'https://g.co/img13', 'https://g.co/img14'],
572 | ['https://g.co/img21', 'https://g.co/img22', 'https://g.co/img23', 'https://g.co/img24'],
573 | ['https://g.co/img31', 'https://g.co/img32', 'https://g.co/img33', 'https://g.co/img34'],
574 | ];
575 | imgSrcsSet.forEach((imgSrcs) => {
576 | makeReactTweet(imgSrcs);
577 | });
578 |
579 | const buttonSetter = new ButtonSetter();
580 | buttonSetter.setReactLayoutButton = jest.fn();
581 |
582 | const options = makeAllEnabledOptions();
583 | buttonSetter._setButtonOnReactLayoutTimeline(options);
584 |
585 | expect(buttonSetter.setReactLayoutButton).toHaveBeenCalledTimes(3);
586 | imgSrcsSet.forEach((imgSrcs, index) => {
587 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].className).toStrictEqual(
588 | 'tooi-button-container-react-timeline',
589 | );
590 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
591 | expect(buttonSetter.setReactLayoutButton.mock.calls[index][0].target.getAttribute('role')).toStrictEqual(
592 | 'group',
593 | );
594 | });
595 | });
596 |
597 | it('画像でないツイート1つにボタンつけない', () => {
598 | const imgSrcs = ['https://g.co/video1'];
599 | makeReactTweet(imgSrcs, 'video');
600 |
601 | const buttonSetter = new ButtonSetter();
602 | buttonSetter.setReactLayoutButton = jest.fn();
603 |
604 | const options = makeAllEnabledOptions();
605 | buttonSetter._setButtonOnReactLayoutTimeline(options);
606 |
607 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
608 | });
609 |
610 | it('画像1枚ツイート1つでも,もうボタンあったらボタンつけない', () => {
611 | const imgSrcs = ['https://g.co/img1'];
612 | makeReactTweet(imgSrcs, 'photo', true);
613 |
614 | const buttonSetter = new ButtonSetter();
615 | buttonSetter.setReactLayoutButton = jest.fn();
616 |
617 | const options = makeAllEnabledOptions();
618 | buttonSetter._setButtonOnReactLayoutTimeline(options);
619 |
620 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
621 | });
622 |
623 | it('画像ツイート1つあっても画像なかったらボタンつけない', () => {
624 | const imgSrcs = [];
625 | makeReactTweet(imgSrcs);
626 |
627 | const buttonSetter = new ButtonSetter();
628 | buttonSetter.setReactLayoutButton = jest.fn();
629 |
630 | const options = makeAllEnabledOptions();
631 | buttonSetter._setButtonOnReactLayoutTimeline(options);
632 |
633 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
634 | });
635 |
636 | it('actionList(いいねとか)がなかったらボタンつけない', () => {
637 | const imgSrcs = ['https://g.co/img1'];
638 | makeReactTweet(imgSrcs);
639 |
640 | const actionList = document.querySelector('div[role="group"]');
641 | actionList.parentNode.removeChild(actionList);
642 |
643 | const buttonSetter = new ButtonSetter();
644 | buttonSetter.setReactLayoutButton = jest.fn();
645 |
646 | const options = makeAllEnabledOptions();
647 | buttonSetter._setButtonOnReactLayoutTimeline(options);
648 |
649 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
650 | });
651 |
652 | it('ツイートなかったら何もしない', () => {
653 | document.body.innerHTML = '
';
654 |
655 | const buttonSetter = new ButtonSetter();
656 | buttonSetter.setReactLayoutButton = jest.fn();
657 |
658 | const options = makeAllEnabledOptions();
659 | buttonSetter._setButtonOnReactLayoutTimeline(options);
660 |
661 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
662 | });
663 |
664 | it('設定がOFFなら何もしない', () => {
665 | const buttonSetter = new ButtonSetter();
666 | buttonSetter.setReactLayoutButton = jest.fn();
667 |
668 | const options = makeAllEnabledOptions();
669 | options[SHOW_ON_TIMELINE] = false;
670 | buttonSetter._setButtonOnReactLayoutTimeline(options);
671 |
672 | expect(buttonSetter.setReactLayoutButton).not.toHaveBeenCalled();
673 | });
674 | });
675 |
676 | describe('getActionButtonColor', () => {
677 | /**
678 | * @argument {string?} color 色
679 | */
680 | const makeActionButton = (color) => {
681 | const button = document.createElement('div');
682 | button.classList.add('ProfileTweet-actionButton');
683 | if (color) {
684 | button.style.color = color;
685 | }
686 | document.body.appendChild(button);
687 | };
688 |
689 | beforeEach(() => {
690 | document.body.innerHTML = '';
691 | });
692 |
693 | it('actionButtonなかったらデフォルト', () => {
694 | const buttonSetter = new ButtonSetter();
695 | expect(buttonSetter.getActionButtonColor()).toStrictEqual('#697b8c');
696 | });
697 |
698 | it('actionButtonのcolorなかったらデフォルト', () => {
699 | makeActionButton(null);
700 | const buttonSetter = new ButtonSetter();
701 | expect(buttonSetter.getActionButtonColor()).toStrictEqual('#697b8c');
702 | });
703 |
704 | it('actionButtonのcolorあったらその色が返る', () => {
705 | makeActionButton('#123456');
706 | const buttonSetter = new ButtonSetter();
707 | expect(buttonSetter.getActionButtonColor()).toStrictEqual('rgb(18, 52, 86)');
708 | });
709 | });
710 |
711 | describe('getReactLayoutActionButtonColor', () => {
712 | /**
713 | * @argument {string?} color 色
714 | */
715 | const makeReactActionButton = (color) => {
716 | const group = document.createElement('div');
717 | group.setAttribute('role', 'group');
718 | const button = document.createElement('div');
719 | button.setAttribute('role', 'button');
720 | const child = document.createElement('div');
721 | const svg = document.createElement('svg');
722 | if (color) {
723 | child.style.color = color;
724 | // 本来はsvgにはcolorは当たっていなくて, 親のcolorをgetComputedStyleで取得すると自然ととれる
725 | // テストでは再現できなかったのでstyleを当ててしまう
726 | svg.style.color = color;
727 | }
728 | child.appendChild(svg);
729 | button.appendChild(child);
730 | group.appendChild(button);
731 | document.body.appendChild(group);
732 | };
733 |
734 | beforeEach(() => {
735 | document.body.innerHTML = '';
736 | });
737 |
738 | it('actionButtonなかったらデフォルト', () => {
739 | const buttonSetter = new ButtonSetter();
740 | expect(buttonSetter.getReactLayoutActionButtonColor()).toStrictEqual('#697b8c');
741 | });
742 |
743 | it('actionButtonのcolor空文字ならデフォルト', () => {
744 | makeReactActionButton('');
745 | const buttonSetter = new ButtonSetter();
746 | expect(buttonSetter.getReactLayoutActionButtonColor()).toStrictEqual('#697b8c');
747 | });
748 |
749 | it('actionButtonのcolorなかったらデフォルト', () => {
750 | makeReactActionButton(null);
751 | const buttonSetter = new ButtonSetter();
752 | expect(buttonSetter.getReactLayoutActionButtonColor()).toStrictEqual('#697b8c');
753 | });
754 |
755 | it('actionButtonのcolorあったらその色が返る', () => {
756 | makeReactActionButton('#123456');
757 | const buttonSetter = new ButtonSetter();
758 | expect(buttonSetter.getReactLayoutActionButtonColor()).toStrictEqual('rgb(18, 52, 86)');
759 | });
760 | });
761 | });
762 |
--------------------------------------------------------------------------------
/__tests__/ButtonSetterTweetDeck.test.js:
--------------------------------------------------------------------------------
1 | import { ButtonSetterTweetDeck } from '../src/ButtonSetterTweetDeck';
2 | import { SHOW_ON_TWEETDECK_TIMELINE, SHOW_ON_TWEETDECK_TWEET_DETAIL } from '../src/constants';
3 |
4 | function makeAllEnabledOptions() {
5 | return {
6 | SHOW_ON_TIMELINE: true,
7 | SHOW_ON_TWEET_DETAIL: true,
8 | SHOW_ON_TWEETDECK_TIMELINE: true,
9 | SHOW_ON_TWEETDECK_TWEET_DETAIL: true,
10 | STRIP_IMAGE_SUFFIX: true,
11 | };
12 | }
13 |
14 | describe('ButtonSetterTweetDeck', () => {
15 | describe('setButtonOnTimeline', () => {
16 | /**
17 | * @param {string[]} imgSrcs
18 | * @param {string[]} extraClassNames
19 | * @param {boolean} hasButton
20 | */
21 | const makeTweet = (imgSrcs, extraClassNames = [], hasButton = false) => {
22 | const root = document.createElement('div');
23 | root.classList.add('js-stream-item', 'is-actionable');
24 |
25 | const media = document.createElement('div');
26 | media.classList.add('js-media');
27 |
28 | const footer = document.createElement('footer');
29 | if (hasButton) {
30 | const button = document.createElement('div');
31 | button.classList.add('tooi-button-container-tweetdeck-timeline');
32 | footer.appendChild(button);
33 | }
34 |
35 | imgSrcs.forEach((src) => {
36 | const div = document.createElement('div');
37 | div.classList.add(...['js-media-image-link', ...extraClassNames]);
38 | div.style.backgroundImage = `url("${src}")`;
39 | media.appendChild(div);
40 | });
41 |
42 | root.appendChild(media);
43 | root.appendChild(footer);
44 | document.body.appendChild(root);
45 | };
46 |
47 | beforeEach(() => {
48 | document.body.innerHTML = '';
49 | });
50 |
51 | it('画像1枚ツイート1つにボタンつけようとする', () => {
52 | const imgSrcs = ['https://g.co/img1'];
53 | makeTweet(imgSrcs);
54 |
55 | const buttonSetter = new ButtonSetterTweetDeck();
56 | buttonSetter.setButton = jest.fn();
57 |
58 | const options = makeAllEnabledOptions();
59 | buttonSetter.setButtonOnTimeline(options);
60 |
61 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
62 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual(
63 | 'tooi-button-container-tweetdeck-timeline',
64 | );
65 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
66 | expect(buttonSetter.setButton.mock.calls[0][0].target.tagName).toStrictEqual('FOOTER');
67 | });
68 |
69 | it('画像1枚ツイート3つにボタンつけようとする', () => {
70 | const imgSrcsSet = [['https://g.co/img1'], ['https://g.co/img2'], ['https://g.co/img3']];
71 | imgSrcsSet.forEach((imgSrcs) => {
72 | makeTweet(imgSrcs);
73 | });
74 |
75 | const buttonSetter = new ButtonSetterTweetDeck();
76 | buttonSetter.setButton = jest.fn();
77 |
78 | const options = makeAllEnabledOptions();
79 | buttonSetter.setButtonOnTimeline(options);
80 |
81 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(3);
82 | imgSrcsSet.forEach((imgSrcs, index) => {
83 | expect(buttonSetter.setButton.mock.calls[index][0].className).toStrictEqual(
84 | 'tooi-button-container-tweetdeck-timeline',
85 | );
86 | expect(buttonSetter.setButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
87 | expect(buttonSetter.setButton.mock.calls[index][0].target.tagName).toStrictEqual('FOOTER');
88 | });
89 | });
90 |
91 | it('画像4枚ツイート3つにボタンつけようとする', () => {
92 | const imgSrcsSet = [
93 | ['https://g.co/img11', 'https://g.co/img12', 'https://g.co/img13', 'https://g.co/img14'],
94 | ['https://g.co/img21', 'https://g.co/img22', 'https://g.co/img23', 'https://g.co/img24'],
95 | ['https://g.co/img31', 'https://g.co/img32', 'https://g.co/img33', 'https://g.co/img34'],
96 | ];
97 | imgSrcsSet.forEach((imgSrcs) => {
98 | makeTweet(imgSrcs);
99 | });
100 |
101 | const buttonSetter = new ButtonSetterTweetDeck();
102 | buttonSetter.setButton = jest.fn();
103 |
104 | const options = makeAllEnabledOptions();
105 | buttonSetter.setButtonOnTimeline(options);
106 |
107 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(3);
108 | imgSrcsSet.forEach((imgSrcs, index) => {
109 | expect(buttonSetter.setButton.mock.calls[index][0].className).toStrictEqual(
110 | 'tooi-button-container-tweetdeck-timeline',
111 | );
112 | expect(buttonSetter.setButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
113 | expect(buttonSetter.setButton.mock.calls[index][0].target.tagName).toStrictEqual('FOOTER');
114 | });
115 | });
116 |
117 | it('動画のツイート1つにボタンつけない', () => {
118 | const imgSrcs = ['https://g.co/video1'];
119 | makeTweet(imgSrcs, ['is-video']);
120 |
121 | const buttonSetter = new ButtonSetterTweetDeck();
122 | buttonSetter.setButton = jest.fn();
123 |
124 | const options = makeAllEnabledOptions();
125 | buttonSetter.setButtonOnTimeline(options);
126 |
127 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
128 | });
129 |
130 | it('GIFのツイート1つにボタンつけない', () => {
131 | const imgSrcs = ['https://g.co/gif1'];
132 | makeTweet(imgSrcs, ['is-gif']);
133 |
134 | const buttonSetter = new ButtonSetterTweetDeck();
135 | buttonSetter.setButton = jest.fn();
136 |
137 | const options = makeAllEnabledOptions();
138 | buttonSetter.setButtonOnTimeline(options);
139 |
140 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
141 | });
142 |
143 | it('画像1枚ツイート1つでも,もうボタンあったらボタンつけない', () => {
144 | const imgSrcs = ['https://g.co/img1'];
145 | makeTweet(imgSrcs, [], true);
146 |
147 | const buttonSetter = new ButtonSetterTweetDeck();
148 | buttonSetter.setButton = jest.fn();
149 |
150 | const options = makeAllEnabledOptions();
151 | buttonSetter.setButtonOnTimeline(options);
152 |
153 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
154 | });
155 |
156 | it('ツイート1つあっても画像なかったらボタンつけない', () => {
157 | const imgSrcs = [];
158 | makeTweet(imgSrcs);
159 |
160 | const buttonSetter = new ButtonSetterTweetDeck();
161 | buttonSetter.setButton = jest.fn();
162 |
163 | const options = makeAllEnabledOptions();
164 | buttonSetter.setButtonOnTimeline(options);
165 |
166 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
167 | });
168 |
169 | it('footerがなかったらボタンつけない', () => {
170 | const imgSrcs = ['https://g.co/img1'];
171 | makeTweet(imgSrcs);
172 |
173 | const footer = document.querySelector('footer');
174 | footer.parentNode.removeChild(footer);
175 |
176 | const buttonSetter = new ButtonSetterTweetDeck();
177 | buttonSetter.setButton = jest.fn();
178 |
179 | const options = makeAllEnabledOptions();
180 | buttonSetter.setButtonOnTimeline(options);
181 |
182 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
183 | });
184 |
185 | it('画像ツイートなかったら何もしない', () => {
186 | const buttonSetter = new ButtonSetterTweetDeck();
187 | buttonSetter.setButton = jest.fn();
188 |
189 | const options = makeAllEnabledOptions();
190 | buttonSetter.setButtonOnTimeline(options);
191 |
192 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
193 | });
194 |
195 | it('設定がOFFなら何もしない', () => {
196 | const buttonSetter = new ButtonSetterTweetDeck();
197 | buttonSetter.setButton = jest.fn();
198 |
199 | const options = makeAllEnabledOptions();
200 | options[SHOW_ON_TWEETDECK_TIMELINE] = false;
201 | buttonSetter.setButtonOnTimeline(options);
202 |
203 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
204 | });
205 | });
206 |
207 | describe('setButtonOnTweetDetail', () => {
208 | /**
209 | * @param {string[]} imgSrcs
210 | * @param {string[]} extraClassNames
211 | * @param {boolean} hasButton
212 | */
213 | const makeTweetDetail = (imgSrcs, extraClassNames = [], hasButton = false) => {
214 | const root = document.createElement('div');
215 | root.classList.add('js-tweet-detail');
216 |
217 | const media = document.createElement('div');
218 |
219 | const footer = document.createElement('footer');
220 | if (hasButton) {
221 | const button = document.createElement('div');
222 | button.classList.add('tooi-button-container-tweetdeck-detail');
223 | footer.appendChild(button);
224 | }
225 |
226 | /**
227 | * - 画像が1枚のとき
228 | * - a.js-media-image-link > img.media-img[src="画像URL"]
229 | * - 画像が複数枚のとき
230 | * - a.js-media-image-link.media-image[style="background-image: url("画像URL")"]
231 | * - a ...
232 | */
233 | if (imgSrcs.length === 1) {
234 | const aTag = document.createElement('a');
235 | aTag.classList.add(...['js-media-image-link', ...extraClassNames]);
236 |
237 | const img = document.createElement('img');
238 | img.classList.add('media-img');
239 | img.src = imgSrcs[0];
240 |
241 | aTag.appendChild(img);
242 | media.appendChild(aTag);
243 | } else {
244 | imgSrcs.forEach((src) => {
245 | const aTag = document.createElement('a');
246 | aTag.classList.add(...['js-media-image-link', 'media-image', ...extraClassNames]);
247 | aTag.style.backgroundImage = `url("${src}")`;
248 | media.appendChild(aTag);
249 | });
250 | }
251 |
252 | root.appendChild(media);
253 | root.appendChild(footer);
254 | document.body.appendChild(root);
255 | };
256 |
257 | beforeEach(() => {
258 | document.body.innerHTML = '';
259 | });
260 |
261 | it('画像1枚ツイート詳細にボタンつけようとする', () => {
262 | const imgSrcs = ['https://g.co/img1'];
263 | makeTweetDetail(imgSrcs);
264 |
265 | const buttonSetter = new ButtonSetterTweetDeck();
266 | buttonSetter.setButton = jest.fn();
267 |
268 | const options = makeAllEnabledOptions();
269 | buttonSetter.setButtonOnTweetDetail(options);
270 |
271 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
272 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual('tooi-button-container-tweetdeck-detail');
273 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
274 | expect(buttonSetter.setButton.mock.calls[0][0].target.tagName).toStrictEqual('FOOTER');
275 | });
276 |
277 | it('画像4枚ツイート詳細にボタンつけようとする', () => {
278 | const imgSrcs = ['https://g.co/img1', 'https://g.co/img2', 'https://g.co/img3', 'https://g.co/img4'];
279 | makeTweetDetail(imgSrcs);
280 |
281 | const buttonSetter = new ButtonSetterTweetDeck();
282 | buttonSetter.setButton = jest.fn();
283 |
284 | const options = makeAllEnabledOptions();
285 | buttonSetter.setButtonOnTweetDetail(options);
286 |
287 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(1);
288 | expect(buttonSetter.setButton.mock.calls[0][0].className).toStrictEqual('tooi-button-container-tweetdeck-detail');
289 | expect(buttonSetter.setButton.mock.calls[0][0].getImgSrcs()).toMatchObject(imgSrcs);
290 | expect(buttonSetter.setButton.mock.calls[0][0].target.tagName).toStrictEqual('FOOTER');
291 | });
292 |
293 | it('画像4枚ツイート詳細3つにボタンつけようとする', () => {
294 | const imgSrcsSet = [
295 | ['https://g.co/img11', 'https://g.co/img12', 'https://g.co/img13', 'https://g.co/img14'],
296 | ['https://g.co/img21', 'https://g.co/img22', 'https://g.co/img23', 'https://g.co/img24'],
297 | ['https://g.co/img31', 'https://g.co/img32', 'https://g.co/img33', 'https://g.co/img34'],
298 | ];
299 | imgSrcsSet.forEach((imgSrcs) => {
300 | makeTweetDetail(imgSrcs);
301 | });
302 |
303 | const buttonSetter = new ButtonSetterTweetDeck();
304 | buttonSetter.setButton = jest.fn();
305 |
306 | const options = makeAllEnabledOptions();
307 | buttonSetter.setButtonOnTweetDetail(options);
308 |
309 | expect(buttonSetter.setButton).toHaveBeenCalledTimes(3);
310 | imgSrcsSet.forEach((imgSrcs, index) => {
311 | expect(buttonSetter.setButton.mock.calls[index][0].className).toStrictEqual(
312 | 'tooi-button-container-tweetdeck-detail',
313 | );
314 | expect(buttonSetter.setButton.mock.calls[index][0].getImgSrcs()).toMatchObject(imgSrcs);
315 | expect(buttonSetter.setButton.mock.calls[index][0].target.tagName).toStrictEqual('FOOTER');
316 | });
317 | });
318 |
319 | it('画像でないツイート1つにボタンつけない', () => {
320 | const imgSrcs = ['https://g.co/video1'];
321 | makeTweetDetail(imgSrcs, ['is-video']);
322 |
323 | const media = [...document.querySelectorAll('.media-img'), ...document.querySelectorAll('.media-image')];
324 | media.forEach((medium) => medium.classList.remove('media-img', 'media-image'));
325 |
326 | const buttonSetter = new ButtonSetterTweetDeck();
327 | buttonSetter.setButton = jest.fn();
328 |
329 | const options = makeAllEnabledOptions();
330 | buttonSetter.setButtonOnTweetDetail(options);
331 |
332 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
333 | });
334 |
335 | it('画像1枚ツイート1つでも,もうボタンあったらボタンつけない', () => {
336 | const imgSrcs = ['https://g.co/img1'];
337 | makeTweetDetail(imgSrcs, [], true);
338 |
339 | const buttonSetter = new ButtonSetterTweetDeck();
340 | buttonSetter.setButton = jest.fn();
341 |
342 | const options = makeAllEnabledOptions();
343 | buttonSetter.setButtonOnTweetDetail(options);
344 |
345 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
346 | });
347 |
348 | describe('ツイート1つあっても画像なかったらボタンつけない', () => {
349 | it('img.media-imgがない', () => {
350 | const imgSrcs = ['https://g.co/img1'];
351 | makeTweetDetail(imgSrcs);
352 |
353 | const media = document.querySelector('.media-img');
354 | media.parentNode.removeChild(media);
355 |
356 | const buttonSetter = new ButtonSetterTweetDeck();
357 | buttonSetter.setButton = jest.fn();
358 |
359 | const options = makeAllEnabledOptions();
360 | buttonSetter.setButtonOnTweetDetail(options);
361 |
362 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
363 | });
364 |
365 | it('a.media-imageがない', () => {
366 | const imgSrcs = ['https://g.co/img1', 'https://g.co/img2'];
367 | makeTweetDetail(imgSrcs);
368 |
369 | const media = document.querySelectorAll('.media-image');
370 | Array.from(media).forEach((medium) => medium.parentNode.removeChild(medium));
371 |
372 | const buttonSetter = new ButtonSetterTweetDeck();
373 | buttonSetter.setButton = jest.fn();
374 |
375 | const options = makeAllEnabledOptions();
376 | buttonSetter.setButtonOnTweetDetail(options);
377 |
378 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
379 | });
380 | });
381 |
382 | it('footerがなかったらボタンつけない', () => {
383 | const imgSrcs = ['https://g.co/img1'];
384 | makeTweetDetail(imgSrcs);
385 |
386 | const footer = document.querySelector('footer');
387 | footer.parentNode.removeChild(footer);
388 |
389 | const buttonSetter = new ButtonSetterTweetDeck();
390 | buttonSetter.setButton = jest.fn();
391 |
392 | const options = makeAllEnabledOptions();
393 | buttonSetter.setButtonOnTweetDetail(options);
394 |
395 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
396 | });
397 |
398 | it('画像ツイートなかったら何もしない', () => {
399 | const buttonSetter = new ButtonSetterTweetDeck();
400 | buttonSetter.setButton = jest.fn();
401 |
402 | const options = makeAllEnabledOptions();
403 | buttonSetter.setButtonOnTweetDetail(options);
404 |
405 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
406 | });
407 |
408 | it('設定がOFFなら何もしない', () => {
409 | const buttonSetter = new ButtonSetterTweetDeck();
410 | buttonSetter.setButton = jest.fn();
411 |
412 | const options = makeAllEnabledOptions();
413 | options[SHOW_ON_TWEETDECK_TWEET_DETAIL] = false;
414 | buttonSetter.setButtonOnTweetDetail(options);
415 |
416 | expect(buttonSetter.setButton).not.toHaveBeenCalled();
417 | });
418 | });
419 |
420 | describe('setButton', () => {
421 | const className = 'hogeclass';
422 | const imgSrcs = ['src1', 'src2'];
423 | const getImgSrcs = () => imgSrcs;
424 |
425 | describe('txt-muteある', () => {
426 | document.body.innerHTML = '';
427 |
428 | const root = document.createElement('div');
429 | root.classList.add('txt-mute');
430 | root.style.color = '#123456';
431 |
432 | const target = document.createElement('footer');
433 | root.appendChild(target);
434 | document.body.appendChild(root);
435 |
436 | const text = 'Original';
437 |
438 | const buttonSetter = new ButtonSetterTweetDeck();
439 | buttonSetter.setButton({ className, getImgSrcs, target, text });
440 |
441 | const button = target.querySelector(`a.${className}`);
442 |
443 | it('ボタン設置される', () => {
444 | expect(button).toBeTruthy();
445 | expect(target.innerHTML).toMatchSnapshot();
446 | });
447 |
448 | it('ボタンにスタイルついている', () => {
449 | const styles = button.style;
450 | expect(styles.border).toStrictEqual('1px solid rgb(18, 52, 86)'); // #123456 -> rgb(18, 52, 86)
451 | expect(styles.borderRadius).toStrictEqual('2px');
452 | expect(styles.display).toStrictEqual('inline-block');
453 | expect(styles.fontSize).toStrictEqual('0.75em');
454 | expect(styles.marginTop).toStrictEqual('5px');
455 | expect(styles.padding).toStrictEqual('1px 1px 0px');
456 | expect(styles.lineHeight).toStrictEqual('1.5em');
457 | expect(styles.cursor).toStrictEqual('pointer');
458 | });
459 |
460 | /* SKIP: なぜかうまくmockできないので飛ばす */
461 | // it('ボタン押すとonClick呼ばれる', () => {
462 | // main.onOriginalButtonClick = jest.fn();
463 | // button.click();
464 | // expect(main.onOriginalButtonClick).toHaveBeenCalledTimes(1);
465 | // expect(main.onOriginalButtonClick.mock.calls[0][0]).toBeInstanceOf(
466 | // MouseEvent
467 | // );
468 | // expect(main.onOriginalButtonClick.mock.calls[0][1]).toStrictEqual(
469 | // imgSrcs
470 | // );
471 | // });
472 | });
473 |
474 | describe('txt-muteない', () => {
475 | document.body.innerHTML = '';
476 |
477 | const root = document.createElement('div');
478 |
479 | const target = document.createElement('footer');
480 | root.appendChild(target);
481 | document.body.appendChild(root);
482 |
483 | const text = 'Original';
484 |
485 | const buttonSetter = new ButtonSetterTweetDeck();
486 | buttonSetter.setButton({ className, getImgSrcs, target, text });
487 |
488 | const button = target.querySelector(`a.${className}`);
489 |
490 | it('ボタン設置される', () => {
491 | expect(button).toBeTruthy();
492 | expect(target.innerHTML).toMatchSnapshot();
493 | });
494 |
495 | it('ボタンにスタイルついている', () => {
496 | const styles = button.style;
497 | expect(styles.border).toStrictEqual('1px solid #697b8c');
498 | expect(styles.borderRadius).toStrictEqual('2px');
499 | expect(styles.display).toStrictEqual('inline-block');
500 | expect(styles.fontSize).toStrictEqual('0.75em');
501 | expect(styles.marginTop).toStrictEqual('5px');
502 | expect(styles.padding).toStrictEqual('1px 1px 0px');
503 | expect(styles.lineHeight).toStrictEqual('1.5em');
504 | expect(styles.cursor).toStrictEqual('pointer');
505 | });
506 | });
507 |
508 | describe('ボタンのテキストを指定するとそれが入る', () => {
509 | document.body.innerHTML = '';
510 |
511 | const root = document.createElement('div');
512 | root.classList.add('txt-mute');
513 | root.style.color = '#123456';
514 |
515 | const target = document.createElement('footer');
516 | root.appendChild(target);
517 | document.body.appendChild(root);
518 |
519 | const text = '原寸';
520 |
521 | const buttonSetter = new ButtonSetterTweetDeck();
522 | buttonSetter.setButton({ className, getImgSrcs, target, text });
523 |
524 | const button = target.querySelector(`a.${className}`);
525 |
526 | it('ボタン設置される', () => {
527 | expect(button).toBeTruthy();
528 | expect(target.innerHTML).toMatchSnapshot();
529 | });
530 |
531 | it('ボタンにスタイルついている', () => {
532 | const styles = button.style;
533 | expect(styles.border).toStrictEqual('1px solid rgb(18, 52, 86)'); // #123456 -> rgb(18, 52, 86)
534 | expect(styles.borderRadius).toStrictEqual('2px');
535 | expect(styles.display).toStrictEqual('inline-block');
536 | expect(styles.fontSize).toStrictEqual('0.75em');
537 | expect(styles.marginTop).toStrictEqual('5px');
538 | expect(styles.padding).toStrictEqual('1px 1px 0px');
539 | expect(styles.lineHeight).toStrictEqual('1.5em');
540 | expect(styles.cursor).toStrictEqual('pointer');
541 | });
542 |
543 | /* SKIP: なぜかうまくmockできないので飛ばす */
544 | // it('ボタン押すとonClick呼ばれる', () => {
545 | // main.onOriginalButtonClick = jest.fn();
546 | // button.click();
547 | // expect(main.onOriginalButtonClick).toHaveBeenCalledTimes(1);
548 | // expect(main.onOriginalButtonClick.mock.calls[0][0]).toBeInstanceOf(
549 | // MouseEvent
550 | // );
551 | // expect(main.onOriginalButtonClick.mock.calls[0][1]).toStrictEqual(
552 | // imgSrcs
553 | // );
554 | // });
555 | });
556 | });
557 |
558 | describe('getBackgroundImageUrl', () => {
559 | const buttonSetter = new ButtonSetterTweetDeck();
560 | const element = document.createElement('div');
561 |
562 | it('背景画像がURL', () => {
563 | element.style.backgroundImage = 'url("http://g.co/img1")';
564 | expect(buttonSetter.getBackgroundImageUrl(element)).toStrictEqual('http://g.co/img1');
565 | });
566 |
567 | it('背景画像が空文字', () => {
568 | element.style.backgroundImage = '';
569 | expect(buttonSetter.getBackgroundImageUrl(element)).toBeNull();
570 | });
571 | });
572 | });
573 |
--------------------------------------------------------------------------------
/__tests__/Constants.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | GET_LOCAL_STORAGE,
3 | HOST_MOBILE_TWITTER_COM,
4 | HOST_MOBILE_X_COM,
5 | HOST_PRO_TWITTER_COM,
6 | HOST_PRO_X_COM,
7 | HOST_TWEETDECK_TWITTER_COM,
8 | HOST_TWITTER_COM,
9 | HOST_X_COM,
10 | INITIAL_ORIGINAL_BUTTON_TEXT,
11 | OPTIONS_TEXT,
12 | OPTION_KEYS,
13 | OPTION_UPDATED,
14 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
15 | SHOW_ON_TIMELINE,
16 | SHOW_ON_TWEETDECK_TIMELINE,
17 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
18 | SHOW_ON_TWEET_DETAIL,
19 | initialOptionsBool,
20 | isNativeChromeExtension,
21 | isReactView,
22 | isTweetdeck,
23 | isTwitter,
24 | } from '../src/constants';
25 |
26 | describe('定数', () => {
27 | it('設定取得メッセージ', () => {
28 | expect(OPTION_UPDATED).toBe('OPTION_UPDATED');
29 | expect(GET_LOCAL_STORAGE).toBe('GET_LOCAL_STORAGE');
30 | });
31 |
32 | it('公式Web', () => {
33 | expect(HOST_TWITTER_COM).toBe('twitter.com');
34 | expect(HOST_MOBILE_TWITTER_COM).toBe('mobile.twitter.com');
35 | expect(HOST_X_COM).toBe('x.com');
36 | expect(HOST_MOBILE_X_COM).toBe('mobile.x.com');
37 | expect(SHOW_ON_TIMELINE).toBe('SHOW_ON_TIMELINE');
38 | expect(SHOW_ON_TWEET_DETAIL).toBe('SHOW_ON_TWEET_DETAIL');
39 | });
40 |
41 | it('TweetDeck', () => {
42 | expect(HOST_TWEETDECK_TWITTER_COM).toBe('tweetdeck.twitter.com');
43 | expect(HOST_PRO_TWITTER_COM).toBe('pro.twitter.com');
44 | expect(HOST_PRO_X_COM).toBe('pro.x.com');
45 | expect(SHOW_ON_TWEETDECK_TIMELINE).toBe('SHOW_ON_TWEETDECK_TIMELINE');
46 | expect(SHOW_ON_TWEETDECK_TWEET_DETAIL).toBe('SHOW_ON_TWEETDECK_TWEET_DETAIL');
47 | });
48 |
49 | it('初期設定', () => {
50 | expect(initialOptionsBool).toStrictEqual({
51 | SHOW_ON_TIMELINE: true,
52 | SHOW_ON_TWEET_DETAIL: true,
53 | SHOW_ON_TWEETDECK_TIMELINE: true,
54 | SHOW_ON_TWEETDECK_TWEET_DETAIL: true,
55 | ORIGINAL_BUTTON_TEXT_OPTION_KEY: INITIAL_ORIGINAL_BUTTON_TEXT,
56 | });
57 | });
58 |
59 | it('設定ページの設定ごとのキーと日本語', () => {
60 | expect(OPTION_KEYS).toStrictEqual([
61 | SHOW_ON_TIMELINE,
62 | SHOW_ON_TWEET_DETAIL,
63 | SHOW_ON_TWEETDECK_TIMELINE,
64 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
65 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
66 | ]);
67 |
68 | expect(OPTIONS_TEXT).toStrictEqual({
69 | SHOW_ON_TIMELINE: 'タイムライン',
70 | SHOW_ON_TWEET_DETAIL: '(旧表示で)ツイート詳細',
71 | SHOW_ON_TWEETDECK_TIMELINE: 'タイムライン',
72 | SHOW_ON_TWEETDECK_TWEET_DETAIL: '(旧表示で)ツイート詳細',
73 | ORIGINAL_BUTTON_TEXT_OPTION_KEY: 'ボタンのテキスト',
74 | });
75 | });
76 |
77 | describe('どのページかのフラグ', () => {
78 | const originalLocation = window.location;
79 | beforeAll(() => {
80 | delete window.location;
81 | });
82 | afterAll(() => {
83 | window.location = originalLocation;
84 | });
85 |
86 | it('公式Web', () => {
87 | window.location = new URL('https://twitter.com');
88 | expect(isTwitter()).toBeTruthy();
89 | expect(isTweetdeck()).toBeFalsy();
90 | });
91 | describe('TweetDeck', () => {
92 | it('tweetdeck.twitter.com', () => {
93 | window.location = new URL('https://tweetdeck.twitter.com');
94 | expect(isTwitter()).toBeFalsy();
95 | expect(isTweetdeck()).toBeTruthy();
96 | });
97 | it('pro.twitter.com', () => {
98 | window.location = new URL('https://pro.twitter.com');
99 | expect(isTwitter()).toBeFalsy();
100 | expect(isTweetdeck()).toBeTruthy();
101 | });
102 | });
103 | it('画像ページ', () => {
104 | window.location = new URL('https://pbs.twimg.com');
105 | expect(isTwitter()).toBeFalsy();
106 | expect(isTweetdeck()).toBeFalsy();
107 | });
108 | });
109 |
110 | describe('Reactビューかどうかのフラグ', () => {
111 | it('isReactView', () => {
112 | expect(isReactView()).toBeFalsy();
113 | document.querySelector('body').insertAdjacentHTML('beforeend', '');
114 | expect(isReactView()).toBeTruthy();
115 | });
116 | });
117 |
118 | describe('Chrome拡張機能かのフラグ', () => {
119 | const originalChrome = window.chrome;
120 | beforeAll(() => {
121 | delete window.chrome;
122 | });
123 | afterAll(() => {
124 | window.chrome = originalChrome;
125 | });
126 |
127 | it('Chrome拡張機能のとき真', () => {
128 | window.chrome = { runtime: { id: 'id' } };
129 | expect(isNativeChromeExtension()).toBeTruthy();
130 | });
131 | it('Chrome拡張機能でないとき偽', () => {
132 | window.chrome = undefined;
133 | expect(isNativeChromeExtension()).toBeFalsy();
134 | window.chrome = {};
135 | expect(isNativeChromeExtension()).toBeFalsy();
136 | window.chrome = { runtime: {} };
137 | expect(isNativeChromeExtension()).toBeFalsy();
138 | });
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/__tests__/Utils.test.js:
--------------------------------------------------------------------------------
1 | // import * as main from '../src/main';
2 |
3 | import { chrome } from 'jest-chrome';
4 |
5 | import { ButtonSetter } from '../src/ButtonSetter';
6 | import { ButtonSetterTweetDeck } from '../src/ButtonSetterTweetDeck';
7 | import { OPTION_KEYS, initialOptionsBool } from '../src/constants';
8 | import {
9 | collectUrlParams,
10 | downloadImage,
11 | formatUrl,
12 | getButtonSetter,
13 | getImageFilenameByUrl,
14 | onOriginalButtonClick,
15 | openImages,
16 | printException,
17 | setStyle,
18 | updateOptions,
19 | } from '../src/utils';
20 |
21 | const testParams = {
22 | protocol: 'https:',
23 | host: 'pbs.twimg.com',
24 | basename: 'hogefuga123',
25 | pathname: '/media/hogefuga123',
26 | };
27 | const makeTestBaseURL = () => {
28 | const { protocol, host, pathname } = testParams;
29 | return `${protocol}//${host}${pathname}`;
30 | };
31 | const makeResultParams = ({ format, name }) => {
32 | const { protocol, host, pathname } = testParams;
33 | return {
34 | protocol,
35 | host,
36 | pathname,
37 | format,
38 | name: name || null,
39 | };
40 | };
41 |
42 | describe('Utils', () => {
43 | describe('printException', () => {
44 | it('エラーメッセージの表示(予期せぬ状況の確認)', () => {
45 | /* eslint-disable no-console */
46 | console.log = jest.fn();
47 | printException('exception message');
48 | expect(console.log.mock.calls[0][0]).toBeInstanceOf(Error);
49 | /* eslint-enable no-console */
50 | });
51 | });
52 |
53 | describe('URLのツール', () => {
54 | // URLの変換のテストの場合たち
55 | const cases = [
56 | {
57 | title: '何もなし',
58 | url: `${makeTestBaseURL()}`,
59 | params: { format: 'jpg' },
60 | filename: `${testParams.basename}.jpg`,
61 | },
62 | {
63 | title: '.jpg',
64 | url: `${makeTestBaseURL()}.jpg`,
65 | params: { format: 'jpg' },
66 | filename: `${testParams.basename}.jpg`,
67 | },
68 | {
69 | title: '.png',
70 | url: `${makeTestBaseURL()}.png`,
71 | params: { format: 'png' },
72 | filename: `${testParams.basename}.png`,
73 | },
74 | {
75 | title: '.jpg:orig',
76 | url: `${makeTestBaseURL()}.jpg:orig`,
77 | params: { format: 'jpg', name: 'orig' },
78 | filename: `${testParams.basename}-orig.jpg`,
79 | },
80 | {
81 | title: '.jpg:large',
82 | url: `${makeTestBaseURL()}.jpg:large`,
83 | params: { format: 'jpg', name: 'large' },
84 | filename: `${testParams.basename}-large.jpg`,
85 | },
86 | {
87 | title: '?format=jpg',
88 | url: `${makeTestBaseURL()}?format=jpg`,
89 | params: { format: 'jpg' },
90 | filename: `${testParams.basename}.jpg`,
91 | },
92 | {
93 | title: '?format=png',
94 | url: `${makeTestBaseURL()}?format=png`,
95 | params: { format: 'png' },
96 | filename: `${testParams.basename}.png`,
97 | },
98 | {
99 | title: '.jpg?format=jpg',
100 | url: `${makeTestBaseURL()}.jpg?format=jpg`,
101 | params: { format: 'jpg' },
102 | filename: `${testParams.basename}.jpg`,
103 | },
104 | {
105 | title: '.jpg?format=png',
106 | url: `${makeTestBaseURL()}.jpg?format=png`,
107 | params: { format: 'jpg' },
108 | filename: `${testParams.basename}.jpg`,
109 | },
110 | {
111 | title: '.png?format=jpg',
112 | url: `${makeTestBaseURL()}.png?format=jpg`,
113 | params: { format: 'png' },
114 | filename: `${testParams.basename}.png`,
115 | },
116 | {
117 | title: '.jpg?name=large',
118 | url: `${makeTestBaseURL()}.jpg?name=large`,
119 | params: { format: 'jpg', name: 'large' },
120 | filename: `${testParams.basename}-large.jpg`,
121 | },
122 | {
123 | title: '?format=jpg&name=large',
124 | url: `${makeTestBaseURL()}?format=jpg&name=large`,
125 | params: { format: 'jpg', name: 'large' },
126 | filename: `${testParams.basename}-large.jpg`,
127 | },
128 | {
129 | title: '.png?format=jpg&name=orig',
130 | url: `${makeTestBaseURL()}.png?format=jpg&name=orig`,
131 | params: { format: 'png', name: 'orig' },
132 | filename: `${testParams.basename}-orig.png`,
133 | },
134 | {
135 | title: '?format=webp&name=4096x4096',
136 | url: `${makeTestBaseURL()}?format=webp&name=4096x4096`,
137 | params: { format: 'webp', name: '4096x4096' },
138 | filename: `${testParams.basename}-4096x4096.webp`,
139 | },
140 | ];
141 |
142 | describe('collectUrlParams 画像urlの要素を集める', () => {
143 | cases.forEach((singleCase) => {
144 | const { title, url, params } = singleCase;
145 | it(`${title}`, () => {
146 | expect(collectUrlParams(url)).toStrictEqual(makeResultParams(params));
147 | });
148 | });
149 |
150 | it('twitterの画像URLでないときnull', () => {
151 | expect(collectUrlParams('https://twitter.com/tos')).toBe(null);
152 | });
153 | });
154 |
155 | describe('formatUrl 画像URLを https~?format=〜&name=orig に揃える', () => {
156 | cases.forEach((singleCase) => {
157 | const { title, url, params } = singleCase;
158 | it(`${title}`, () => {
159 | if (params.format === 'webp') {
160 | expect(formatUrl(url)).toBe(
161 | `https://pbs.twimg.com/media/hogefuga123?format=${params.format}&name=4096x4096`,
162 | );
163 | } else {
164 | expect(formatUrl(url)).toBe(`https://pbs.twimg.com/media/hogefuga123?format=${params.format}&name=orig`);
165 | }
166 | });
167 | });
168 |
169 | it('twitterの画像URLでないときそのまま', () => {
170 | expect(formatUrl('https://twitter.com/tos')).toBe('https://twitter.com/tos');
171 | });
172 |
173 | it('空文字渡すと null が返る', () => {
174 | expect(formatUrl('')).toBeNull();
175 | });
176 | });
177 |
178 | describe('getImageFilenameByUrl 画像のファイル名をつくる', () => {
179 | cases.forEach((singleCase) => {
180 | const { title, url, filename } = singleCase;
181 | it(`${title}`, () => {
182 | expect(getImageFilenameByUrl(url)).toBe(filename);
183 | });
184 | });
185 |
186 | it('twitterの画像URLでないときnull', () => {
187 | expect(getImageFilenameByUrl('https://twitter.com/tos')).toBe(null);
188 | });
189 | });
190 | });
191 |
192 | describe('openImages 画像を開く', () => {
193 | it('画像URLを1つ渡したとき開く', () => {
194 | window.open = jest.fn();
195 | openImages(['https://pbs.twimg.com/media/1st?format=jpg&name=orig']);
196 | expect(window.open.mock.calls.length).toBe(1);
197 | expect(window.open.mock.calls[0][0]).toBe('https://pbs.twimg.com/media/1st?format=jpg&name=orig');
198 | });
199 |
200 | it('画像URLを2つ渡したとき逆順に開く', () => {
201 | window.open = jest.fn();
202 | openImages([
203 | 'https://pbs.twimg.com/media/1st?format=jpg&name=orig',
204 | 'https://pbs.twimg.com/media/2nd?format=jpg&name=orig',
205 | ]);
206 | expect(window.open.mock.calls.length).toBe(2);
207 | expect(window.open.mock.calls[0][0]).toBe('https://pbs.twimg.com/media/2nd?format=jpg&name=orig');
208 | expect(window.open.mock.calls[1][0]).toBe('https://pbs.twimg.com/media/1st?format=jpg&name=orig');
209 | });
210 |
211 | it('画像URLを4つ渡したとき逆順に開く', () => {
212 | window.open = jest.fn();
213 | openImages([
214 | 'https://pbs.twimg.com/media/1st?format=jpg&name=orig',
215 | 'https://pbs.twimg.com/media/2nd?format=jpg&name=orig',
216 | 'https://pbs.twimg.com/media/3rd?format=jpg&name=orig',
217 | 'https://pbs.twimg.com/media/4th?format=jpg&name=orig',
218 | ]);
219 | expect(window.open.mock.calls.length).toBe(4);
220 | expect(window.open.mock.calls[0][0]).toBe('https://pbs.twimg.com/media/4th?format=jpg&name=orig');
221 | expect(window.open.mock.calls[1][0]).toBe('https://pbs.twimg.com/media/3rd?format=jpg&name=orig');
222 | expect(window.open.mock.calls[2][0]).toBe('https://pbs.twimg.com/media/2nd?format=jpg&name=orig');
223 | expect(window.open.mock.calls[3][0]).toBe('https://pbs.twimg.com/media/1st?format=jpg&name=orig');
224 | });
225 |
226 | it('画像URLでないURLを1つ渡したときもそのまま開く', () => {
227 | window.open = jest.fn();
228 | openImages(['https://twitter.com/tos']);
229 | expect(window.open.mock.calls.length).toBe(1);
230 | expect(window.open.mock.calls[0][0]).toBe('https://twitter.com/tos');
231 | });
232 |
233 | it('空文字を1つ渡したとき開かない', () => {
234 | window.open = jest.fn();
235 | openImages(['']);
236 | expect(window.open.mock.calls.length).toBe(0);
237 | });
238 |
239 | it('URLとundefinedを混ぜたときURLだけ開いてundefinedは開かない', () => {
240 | window.open = jest.fn();
241 | openImages([
242 | 'https://pbs.twimg.com/media/1st?format=jpg&name=orig',
243 | '',
244 | 'https://twitter.com/tos',
245 | 'https://pbs.twimg.com/media/2nd?format=jpg&name=orig',
246 | ]);
247 | expect(window.open.mock.calls.length).toBe(3);
248 | expect(window.open.mock.calls[0][0]).toBe('https://pbs.twimg.com/media/2nd?format=jpg&name=orig');
249 | expect(window.open.mock.calls[1][0]).toBe('https://twitter.com/tos');
250 | expect(window.open.mock.calls[2][0]).toBe('https://pbs.twimg.com/media/1st?format=jpg&name=orig');
251 | });
252 |
253 | it('要素0個の配列を渡したとき開かない', () => {
254 | window.open = jest.fn();
255 | openImages([]);
256 | expect(window.open.mock.calls.length).toBe(0);
257 | });
258 | });
259 |
260 | describe('updateOptions', () => {
261 | describe('Chrome拡張機能のとき', () => {
262 | chrome.runtime.id = 'mock';
263 |
264 | it('初期設定を取得できる', async () => {
265 | chrome.runtime.sendMessage.mockImplementation((_, callback) => callback({ data: initialOptionsBool }));
266 | await expect(updateOptions()).resolves.toStrictEqual(initialOptionsBool);
267 | });
268 |
269 | it('設定した値を取得できる', async () => {
270 | const expected = { ...initialOptionsBool };
271 | OPTION_KEYS.forEach((key, i) => {
272 | expected[key] = i % 2 === 0;
273 | });
274 | chrome.runtime.sendMessage.mockImplementation((_, callback) => callback({ data: { ...expected } }));
275 | await expect(updateOptions()).resolves.toStrictEqual(expected);
276 | });
277 |
278 | it('設定が取得できなかったら初期設定', async () => {
279 | chrome.runtime.sendMessage.mockImplementation((_, callback) => callback({}));
280 | await expect(updateOptions()).resolves.toStrictEqual(initialOptionsBool);
281 | });
282 | });
283 |
284 | describe('Chrome拡張機能でないとき', () => {
285 | const originalChrome = window.chrome;
286 | beforeAll(() => {
287 | delete window.chrome;
288 | window.chrome = undefined;
289 | });
290 | afterAll(() => {
291 | window.chrome = originalChrome;
292 | });
293 |
294 | it('初期設定を取得できる', async () => {
295 | await expect(updateOptions()).resolves.toStrictEqual(initialOptionsBool);
296 | });
297 | });
298 | });
299 |
300 | describe('setStyle DOM要素にスタイルを当てる', () => {
301 | it('スタイル当たる', () => {
302 | const div = document.createElement('div');
303 | expect(div).toMatchSnapshot();
304 | setStyle(div, {
305 | display: 'none',
306 | color: '#123',
307 | 'background-color': 'rgba(12, 34, 56, 0.7)',
308 | });
309 | expect(div).toMatchSnapshot();
310 | });
311 | it('空のスタイル渡すと何もしない', () => {
312 | const div = document.createElement('div');
313 | expect(div).toMatchSnapshot();
314 | setStyle(div, {});
315 | expect(div).toMatchSnapshot();
316 | });
317 | it('すでにあるスタイルは上書きされるが消えることはない', () => {
318 | const div = document.createElement('div');
319 | div.style.color = '#456';
320 | div.style.backgroundColor = '#789abc';
321 | div.style.fontSize = '150px';
322 | expect(div).toMatchSnapshot();
323 | setStyle(div, {
324 | display: 'none',
325 | color: '#123',
326 | 'background-color': 'rgba(12, 34, 56, 0.7)',
327 | });
328 | expect(div).toMatchSnapshot();
329 | });
330 | });
331 |
332 | describe('onOriginalButtonClick ボタンがクリックされたときのコールバック', () => {
333 | it('イベントを止める', () => {
334 | /* SKIP: なぜかうまくmockできないので飛ばす */
335 | // jest.spyOn(main, 'openImages');
336 |
337 | const event = {
338 | preventDefault: jest.fn(),
339 | stopPropagation: jest.fn(),
340 | };
341 | const imgSrcs = ['src1', 'src2'];
342 | onOriginalButtonClick(event, imgSrcs);
343 |
344 | expect(event.preventDefault).toHaveBeenCalledTimes(1);
345 | expect(event.stopPropagation).toHaveBeenCalledTimes(1);
346 |
347 | // expect(openImages).toHaveBeenCalledTimes(1);
348 | // expect(openImages.mock.calls[0][0]).toStrictEqual(imgSrcs);
349 | });
350 | });
351 |
352 | describe('downloadImage 画像をダウンロードする', () => {
353 | let img;
354 | let event = {};
355 | beforeEach(() => {
356 | img = document.createElement('img');
357 | img.src = 'https://pbs.twimg.com/media/hogefuga123.jpg';
358 | document.querySelector('body').appendChild(img);
359 |
360 | event = {
361 | preventDefault: jest.fn(),
362 | };
363 | });
364 | afterEach(() => {
365 | img.parentNode.removeChild(img);
366 | });
367 |
368 | it('Ctrl-s', () => {
369 | event.ctrlKey = true;
370 | event.key = 's';
371 | downloadImage(event);
372 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
373 | expect(event.preventDefault).toHaveBeenCalledTimes(1);
374 | });
375 | it('Cmd-s', () => {
376 | event.metaKey = true;
377 | event.key = 's';
378 | downloadImage(event);
379 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
380 | expect(event.preventDefault).toHaveBeenCalledTimes(1);
381 | });
382 | it('ただのsなら何もしない', () => {
383 | event.key = 's';
384 | downloadImage(event);
385 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
386 | expect(event.preventDefault).not.toHaveBeenCalled();
387 | });
388 | it('ただのCtrlなら何もしない', () => {
389 | event.ctrlKey = true;
390 | downloadImage(event);
391 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
392 | expect(event.preventDefault).not.toHaveBeenCalled();
393 | });
394 | it('ただのCmdなら何もしない', () => {
395 | event.metaKey = true;
396 | downloadImage(event);
397 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
398 | expect(event.preventDefault).not.toHaveBeenCalled();
399 | });
400 | it('imgのsrcがないときCtrl-sしても何もしない', () => {
401 | img.src = '';
402 |
403 | event.ctrlKey = true;
404 | event.key = 's';
405 | downloadImage(event);
406 | // 便宜上event.preventDefaultまで到達したのでダウンロードされているはずとしてテスト
407 | expect(event.preventDefault).not.toHaveBeenCalled();
408 | });
409 | });
410 |
411 | describe('getButtonSetter ボタン設置するクラスのゲッタ', () => {
412 | const originalLocation = window.location;
413 | beforeAll(() => {
414 | delete window.location;
415 | });
416 | afterAll(() => {
417 | window.location = originalLocation;
418 | });
419 |
420 | describe('古いTweetDeckではButtonSetterTweetDeck', () => {
421 | it('tweetdeck.twitter.com', () => {
422 | window.location = new URL('https://tweetdeck.twitter.com');
423 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetterTweetDeck);
424 | });
425 | it('pro.twitter.com', () => {
426 | window.location = new URL('https://pro.twitter.com');
427 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetterTweetDeck);
428 | });
429 | });
430 | it('公式WebではButtonSetter', () => {
431 | window.location = new URL('https://twitter.com');
432 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetter);
433 | });
434 | it('公式Web(モバイル版)ではButtonSetter', () => {
435 | window.location = new URL('https://mobile.twitter.com');
436 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetter);
437 | });
438 | describe('新しいTweetDeckではButtonSetter', () => {
439 | it('tweetdeck.twitter.com', () => {
440 | window.location = new URL('https://tweetdeck.twitter.com');
441 | document.querySelector('body').insertAdjacentHTML('beforeend', '');
442 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetter);
443 | });
444 | it('pro.twitter.com', () => {
445 | window.location = new URL('https://pro.twitter.com');
446 | document.querySelector('body').insertAdjacentHTML('beforeend', '');
447 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetter);
448 | });
449 | });
450 | it('どちらでもなかったらButtonSetter', () => {
451 | window.location = new URL('https://hoge.test');
452 | expect(getButtonSetter()).toBeInstanceOf(ButtonSetter);
453 | });
454 | });
455 | });
456 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/ButtonSetter.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ButtonSetter setButton ボタン設置される 1`] = `""`;
4 |
5 | exports[`ButtonSetter setButtonにボタンのテキストを指定するとそれが入る 指定したテキストでボタン設置される 1`] = `""`;
6 |
7 | exports[`ButtonSetter setReactLayoutButton ボタン設置される 1`] = `""`;
8 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/ButtonSetterTweetDeck.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ButtonSetterTweetDeck setButton txt-muteある ボタン設置される 1`] = `"Original"`;
4 |
5 | exports[`ButtonSetterTweetDeck setButton txt-muteない ボタン設置される 1`] = `"Original"`;
6 |
7 | exports[`ButtonSetterTweetDeck setButton ボタンのテキストを指定するとそれが入る ボタン設置される 1`] = `"原寸"`;
8 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/Utils.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Utils setStyle DOM要素にスタイルを当てる すでにあるスタイルは上書きされるが消えることはない 1`] = `
4 |
7 | `;
8 |
9 | exports[`Utils setStyle DOM要素にスタイルを当てる すでにあるスタイルは上書きされるが消えることはない 2`] = `
10 |
13 | `;
14 |
15 | exports[`Utils setStyle DOM要素にスタイルを当てる スタイル当たる 1`] = ``;
16 |
17 | exports[`Utils setStyle DOM要素にスタイルを当てる スタイル当たる 2`] = `
18 |
21 | `;
22 |
23 | exports[`Utils setStyle DOM要素にスタイルを当てる 空のスタイル渡すと何もしない 1`] = ``;
24 |
25 | exports[`Utils setStyle DOM要素にスタイルを当てる 空のスタイル渡すと何もしない 2`] = ``;
26 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/popup.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Popup renders correctly 1`] = `
4 |
5 |
9 |
12 | Options - 設定
13 |
14 |
17 |
76 |
135 |
138 |
144 |
147 |
154 |
155 |
156 |
157 |
163 |
168 |
169 |
170 | `;
171 |
--------------------------------------------------------------------------------
/__tests__/options.test.js:
--------------------------------------------------------------------------------
1 | import { chrome } from 'jest-chrome';
2 | import { SHOW_ON_TIMELINE, SHOW_ON_TWEETDECK_TWEET_DETAIL, initialOptionsBool } from '../src/constants';
3 |
4 | import { getOptions, setOptions } from '../src/extension-contexts/options';
5 |
6 | let chromeStorage = {};
7 | chrome.storage.sync.set.mockImplementation((items, callback) => {
8 | chromeStorage = { ...chromeStorage, ...items };
9 | callback();
10 | });
11 | chrome.storage.sync.get.mockImplementation((keys, callback) => {
12 | if (typeof keys === 'string') {
13 | callback({ [keys]: chromeStorage[keys] });
14 | } else {
15 | callback(Object.fromEntries(Object.entries(chromeStorage).filter(([k, _]) => keys.find((key) => k === key))));
16 | }
17 | });
18 | beforeEach(() => {
19 | chromeStorage = {};
20 | });
21 |
22 | describe('options', () => {
23 | describe('getOptions', () => {
24 | it('chrome.storageが空の状態で呼んだら, 初期値が返る (chrome.storageは空のまま)', () => {
25 | expect(getOptions()).resolves.toMatchObject(initialOptionsBool);
26 | expect(chromeStorage).toMatchObject({});
27 | });
28 | it('chrome.storageに保存された設定があるなら, それが返る', () => {
29 | chromeStorage = {
30 | ...initialOptionsBool,
31 | [SHOW_ON_TIMELINE]: false,
32 | };
33 | const expected = { ...initialOptionsBool, [SHOW_ON_TIMELINE]: false };
34 | expect(getOptions()).resolves.toMatchObject(expected);
35 | expect(chromeStorage).toMatchObject(expected);
36 | });
37 | });
38 | describe('setOptions', () => {
39 | const expected = { ...initialOptionsBool, [SHOW_ON_TWEETDECK_TWEET_DETAIL]: false };
40 | setOptions(expected);
41 | expect(chrome.storage.sync.set.mock.calls.length).toBe(1);
42 | expect(chrome.storage.sync.set.mock.lastCall[0]).toBe(expected);
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/__tests__/popup.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { chrome } from 'jest-chrome';
4 | import React from 'react';
5 |
6 | import {
7 | OPTIONS_TEXT,
8 | OPTION_KEYS,
9 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
10 | SHOW_ON_TIMELINE,
11 | SHOW_ON_TWEETDECK_TIMELINE,
12 | initialOptionsBool,
13 | } from '../src/constants';
14 | import { Popup } from '../src/extension-contexts/popup';
15 |
16 | let mockOptions = initialOptionsBool;
17 | chrome.storage.sync.set.mockImplementation((newOptions) => {
18 | mockOptions = { ...newOptions };
19 | });
20 | chrome.storage.sync.get.mockImplementation(() => mockOptions);
21 |
22 | describe('Popup', () => {
23 | it('renders correctly', () => {
24 | const optionsText = OPTIONS_TEXT;
25 | const optionKeys = OPTION_KEYS;
26 | const optionsEnabled = { ...initialOptionsBool };
27 |
28 | const props = {
29 | optionsText,
30 | optionKeys,
31 | optionsEnabled,
32 | };
33 |
34 | const { container } = render();
35 | expect(container).toMatchSnapshot();
36 | });
37 |
38 | describe('保存ボタン押すと設定が保存される', () => {
39 | beforeEach(() => {
40 | mockOptions = initialOptionsBool;
41 |
42 | chrome.tabs.query.mockImplementation((_, callback) => {
43 | callback([
44 | {
45 | // 対象タブ
46 | id: 1,
47 | url: 'http://twitter.com',
48 | },
49 | {
50 | // 対象ではないタブ
51 | id: 1,
52 | url: 'http://google.com',
53 | },
54 | {
55 | // 対象ではないタブ
56 | id: 1,
57 | },
58 | {
59 | // 対象ではないタブ
60 | url: 'http://twitter.com',
61 | },
62 | ]);
63 | });
64 |
65 | chrome.tabs.sendMessage.mockImplementation((id, option, callback) => {
66 | callback('mock ok');
67 | });
68 | });
69 |
70 | it('渡した設定がそのまま保存される', async () => {
71 | const user = userEvent.setup();
72 | const optionsText = OPTIONS_TEXT;
73 | const optionKeys = OPTION_KEYS;
74 | const optionsEnabled = { ...initialOptionsBool };
75 | // 初期設定いっこOFFにしてみる
76 | optionsEnabled[SHOW_ON_TIMELINE] = false;
77 |
78 | const props = {
79 | optionsText,
80 | optionKeys,
81 | optionsEnabled,
82 | };
83 |
84 | render();
85 |
86 | // Find and click the save button
87 | const saveButton = screen.getByText('設定を保存');
88 | await user.click(saveButton);
89 |
90 | // 送りたいタブは正しい形式かつ対象ホストなタブのみ
91 | expect(window.chrome.tabs.query.mock.calls.length).toBe(1);
92 | expect(mockOptions).toMatchObject(optionsEnabled);
93 | });
94 |
95 | it('チェックボックスをクリックして保存すると設定変えられる', async () => {
96 | const user = userEvent.setup();
97 | const optionsText = OPTIONS_TEXT;
98 | const optionKeys = OPTION_KEYS;
99 | const optionsEnabled = { ...initialOptionsBool };
100 | // 初期設定いっこOFFにしてみる
101 | optionsEnabled[SHOW_ON_TIMELINE] = false;
102 |
103 | const props = {
104 | optionsText,
105 | optionKeys,
106 | optionsEnabled,
107 | };
108 |
109 | render();
110 |
111 | // Find and click checkboxes
112 | const timelineCheckbox = document.querySelector(`.${SHOW_ON_TIMELINE}`);
113 | const tweetdeckCheckbox = document.querySelector(`.${SHOW_ON_TWEETDECK_TIMELINE}`);
114 | await user.click(timelineCheckbox);
115 | await user.click(tweetdeckCheckbox);
116 |
117 | // Change text input
118 | const textInput = document.querySelector(`.${ORIGINAL_BUTTON_TEXT_OPTION_KEY}`);
119 | await user.clear(textInput);
120 | await user.type(textInput, '原寸');
121 |
122 | // Save
123 | const saveButton = screen.getByText('設定を保存');
124 | await user.click(saveButton);
125 |
126 | expect(mockOptions).toMatchObject({
127 | ...optionsEnabled,
128 | [SHOW_ON_TIMELINE]: true,
129 | [SHOW_ON_TWEETDECK_TIMELINE]: false,
130 | [ORIGINAL_BUTTON_TEXT_OPTION_KEY]: '原寸',
131 | });
132 | });
133 |
134 | it('何度も設定変えられる', async () => {
135 | const user = userEvent.setup();
136 | const optionsText = OPTIONS_TEXT;
137 | const optionKeys = OPTION_KEYS;
138 | const optionsEnabled = { ...initialOptionsBool };
139 | // 初期設定いっこOFFにしてみる
140 | optionsEnabled[SHOW_ON_TIMELINE] = false;
141 |
142 | const props = {
143 | optionsText,
144 | optionKeys,
145 | optionsEnabled,
146 | };
147 |
148 | render();
149 |
150 | // First change
151 | const timelineCheckbox = document.querySelector(`.${SHOW_ON_TIMELINE}`);
152 | await user.click(timelineCheckbox);
153 |
154 | const textInput = document.querySelector(`.${ORIGINAL_BUTTON_TEXT_OPTION_KEY}`);
155 | await user.clear(textInput);
156 | await user.type(textInput, '🎍');
157 |
158 | const saveButton = screen.getByText('設定を保存');
159 | await user.click(saveButton);
160 |
161 | expect(mockOptions).toMatchObject({
162 | ...optionsEnabled,
163 | [SHOW_ON_TIMELINE]: true,
164 | [ORIGINAL_BUTTON_TEXT_OPTION_KEY]: '🎍',
165 | });
166 | });
167 | });
168 | });
169 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | const presets = [
2 | [
3 | '@babel/env',
4 | {
5 | targets: "last 2 versions",
6 | },
7 | ],
8 | '@babel/preset-typescript',
9 | [
10 | '@babel/preset-react',
11 | {
12 | // for new jsx transform in react 17 https://babeljs.io/blog/2020/03/16/7.9.0#a-new-jsx-transform-11154httpsgithubcombabelbabelpull11154
13 | runtime: 'automatic',
14 | }
15 | ],
16 | ];
17 |
18 | module.exports = { presets };
19 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json",
3 | "linter": {
4 | "enabled": true,
5 | "rules": {
6 | "recommended": true,
7 | "performance": {
8 | "noDelete": "off"
9 | },
10 | "style": {
11 | "noNonNullAssertion": "off"
12 | },
13 | "complexity": {
14 | "noForEach": "off"
15 | }
16 | },
17 | "ignore": ["__tests__/**/*.snap"]
18 | },
19 | "formatter": {
20 | "indentStyle": "space",
21 | "lineWidth": 120,
22 | "ignore": ["__tests__/**/*.snap"]
23 | },
24 | "javascript": {
25 | "formatter": {
26 | "quoteStyle": "single"
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/coverage/badge.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/description-for-edge-submisson.txt:
--------------------------------------------------------------------------------
1 | 1. open https://twitter.com/, https://tweetdeck.twitter.com/, https://x.com/ or https://pro.x.com/
2 | 2. this extension adds "Original" button to image-attached-tweets.
3 | 3. click "Original" button on any tweet
4 | 4. this extension opens image(s) of that tweet in new tab(s)
5 |
--------------------------------------------------------------------------------
/dist/css/popup.css:
--------------------------------------------------------------------------------
1 | *, ::before, ::after {
2 | --tw-border-spacing-x: 0;
3 | --tw-border-spacing-y: 0;
4 | --tw-translate-x: 0;
5 | --tw-translate-y: 0;
6 | --tw-rotate: 0;
7 | --tw-skew-x: 0;
8 | --tw-skew-y: 0;
9 | --tw-scale-x: 1;
10 | --tw-scale-y: 1;
11 | --tw-pan-x: ;
12 | --tw-pan-y: ;
13 | --tw-pinch-zoom: ;
14 | --tw-scroll-snap-strictness: proximity;
15 | --tw-gradient-from-position: ;
16 | --tw-gradient-via-position: ;
17 | --tw-gradient-to-position: ;
18 | --tw-ordinal: ;
19 | --tw-slashed-zero: ;
20 | --tw-numeric-figure: ;
21 | --tw-numeric-spacing: ;
22 | --tw-numeric-fraction: ;
23 | --tw-ring-inset: ;
24 | --tw-ring-offset-width: 0px;
25 | --tw-ring-offset-color: #fff;
26 | --tw-ring-color: rgb(59 130 246 / 0.5);
27 | --tw-ring-offset-shadow: 0 0 #0000;
28 | --tw-ring-shadow: 0 0 #0000;
29 | --tw-shadow: 0 0 #0000;
30 | --tw-shadow-colored: 0 0 #0000;
31 | --tw-blur: ;
32 | --tw-brightness: ;
33 | --tw-contrast: ;
34 | --tw-grayscale: ;
35 | --tw-hue-rotate: ;
36 | --tw-invert: ;
37 | --tw-saturate: ;
38 | --tw-sepia: ;
39 | --tw-drop-shadow: ;
40 | --tw-backdrop-blur: ;
41 | --tw-backdrop-brightness: ;
42 | --tw-backdrop-contrast: ;
43 | --tw-backdrop-grayscale: ;
44 | --tw-backdrop-hue-rotate: ;
45 | --tw-backdrop-invert: ;
46 | --tw-backdrop-opacity: ;
47 | --tw-backdrop-saturate: ;
48 | --tw-backdrop-sepia: ;
49 | --tw-contain-size: ;
50 | --tw-contain-layout: ;
51 | --tw-contain-paint: ;
52 | --tw-contain-style: ;
53 | }
54 |
55 | ::backdrop {
56 | --tw-border-spacing-x: 0;
57 | --tw-border-spacing-y: 0;
58 | --tw-translate-x: 0;
59 | --tw-translate-y: 0;
60 | --tw-rotate: 0;
61 | --tw-skew-x: 0;
62 | --tw-skew-y: 0;
63 | --tw-scale-x: 1;
64 | --tw-scale-y: 1;
65 | --tw-pan-x: ;
66 | --tw-pan-y: ;
67 | --tw-pinch-zoom: ;
68 | --tw-scroll-snap-strictness: proximity;
69 | --tw-gradient-from-position: ;
70 | --tw-gradient-via-position: ;
71 | --tw-gradient-to-position: ;
72 | --tw-ordinal: ;
73 | --tw-slashed-zero: ;
74 | --tw-numeric-figure: ;
75 | --tw-numeric-spacing: ;
76 | --tw-numeric-fraction: ;
77 | --tw-ring-inset: ;
78 | --tw-ring-offset-width: 0px;
79 | --tw-ring-offset-color: #fff;
80 | --tw-ring-color: rgb(59 130 246 / 0.5);
81 | --tw-ring-offset-shadow: 0 0 #0000;
82 | --tw-ring-shadow: 0 0 #0000;
83 | --tw-shadow: 0 0 #0000;
84 | --tw-shadow-colored: 0 0 #0000;
85 | --tw-blur: ;
86 | --tw-brightness: ;
87 | --tw-contrast: ;
88 | --tw-grayscale: ;
89 | --tw-hue-rotate: ;
90 | --tw-invert: ;
91 | --tw-saturate: ;
92 | --tw-sepia: ;
93 | --tw-drop-shadow: ;
94 | --tw-backdrop-blur: ;
95 | --tw-backdrop-brightness: ;
96 | --tw-backdrop-contrast: ;
97 | --tw-backdrop-grayscale: ;
98 | --tw-backdrop-hue-rotate: ;
99 | --tw-backdrop-invert: ;
100 | --tw-backdrop-opacity: ;
101 | --tw-backdrop-saturate: ;
102 | --tw-backdrop-sepia: ;
103 | --tw-contain-size: ;
104 | --tw-contain-layout: ;
105 | --tw-contain-paint: ;
106 | --tw-contain-style: ;
107 | }
108 |
109 | /*
110 | ! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com
111 | */
112 |
113 | /*
114 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
115 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
116 | */
117 |
118 | *,
119 | ::before,
120 | ::after {
121 | box-sizing: border-box;
122 | /* 1 */
123 | border-width: 0;
124 | /* 2 */
125 | border-style: solid;
126 | /* 2 */
127 | border-color: #e5e7eb;
128 | /* 2 */
129 | }
130 |
131 | ::before,
132 | ::after {
133 | --tw-content: '';
134 | }
135 |
136 | /*
137 | 1. Use a consistent sensible line-height in all browsers.
138 | 2. Prevent adjustments of font size after orientation changes in iOS.
139 | 3. Use a more readable tab size.
140 | 4. Use the user's configured `sans` font-family by default.
141 | 5. Use the user's configured `sans` font-feature-settings by default.
142 | 6. Use the user's configured `sans` font-variation-settings by default.
143 | 7. Disable tap highlights on iOS
144 | */
145 |
146 | html,
147 | :host {
148 | line-height: 1.5;
149 | /* 1 */
150 | -webkit-text-size-adjust: 100%;
151 | /* 2 */
152 | -moz-tab-size: 4;
153 | /* 3 */
154 | -o-tab-size: 4;
155 | tab-size: 4;
156 | /* 3 */
157 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
158 | /* 4 */
159 | font-feature-settings: normal;
160 | /* 5 */
161 | font-variation-settings: normal;
162 | /* 6 */
163 | -webkit-tap-highlight-color: transparent;
164 | /* 7 */
165 | }
166 |
167 | /*
168 | 1. Remove the margin in all browsers.
169 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
170 | */
171 |
172 | body {
173 | margin: 0;
174 | /* 1 */
175 | line-height: inherit;
176 | /* 2 */
177 | }
178 |
179 | /*
180 | 1. Add the correct height in Firefox.
181 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
182 | 3. Ensure horizontal rules are visible by default.
183 | */
184 |
185 | hr {
186 | height: 0;
187 | /* 1 */
188 | color: inherit;
189 | /* 2 */
190 | border-top-width: 1px;
191 | /* 3 */
192 | }
193 |
194 | /*
195 | Add the correct text decoration in Chrome, Edge, and Safari.
196 | */
197 |
198 | abbr:where([title]) {
199 | -webkit-text-decoration: underline dotted;
200 | text-decoration: underline dotted;
201 | }
202 |
203 | /*
204 | Remove the default font size and weight for headings.
205 | */
206 |
207 | h1,
208 | h2,
209 | h3,
210 | h4,
211 | h5,
212 | h6 {
213 | font-size: inherit;
214 | font-weight: inherit;
215 | }
216 |
217 | /*
218 | Reset links to optimize for opt-in styling instead of opt-out.
219 | */
220 |
221 | a {
222 | color: inherit;
223 | text-decoration: inherit;
224 | }
225 |
226 | /*
227 | Add the correct font weight in Edge and Safari.
228 | */
229 |
230 | b,
231 | strong {
232 | font-weight: bolder;
233 | }
234 |
235 | /*
236 | 1. Use the user's configured `mono` font-family by default.
237 | 2. Use the user's configured `mono` font-feature-settings by default.
238 | 3. Use the user's configured `mono` font-variation-settings by default.
239 | 4. Correct the odd `em` font sizing in all browsers.
240 | */
241 |
242 | code,
243 | kbd,
244 | samp,
245 | pre {
246 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
247 | /* 1 */
248 | font-feature-settings: normal;
249 | /* 2 */
250 | font-variation-settings: normal;
251 | /* 3 */
252 | font-size: 1em;
253 | /* 4 */
254 | }
255 |
256 | /*
257 | Add the correct font size in all browsers.
258 | */
259 |
260 | small {
261 | font-size: 80%;
262 | }
263 |
264 | /*
265 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
266 | */
267 |
268 | sub,
269 | sup {
270 | font-size: 75%;
271 | line-height: 0;
272 | position: relative;
273 | vertical-align: baseline;
274 | }
275 |
276 | sub {
277 | bottom: -0.25em;
278 | }
279 |
280 | sup {
281 | top: -0.5em;
282 | }
283 |
284 | /*
285 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
286 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
287 | 3. Remove gaps between table borders by default.
288 | */
289 |
290 | table {
291 | text-indent: 0;
292 | /* 1 */
293 | border-color: inherit;
294 | /* 2 */
295 | border-collapse: collapse;
296 | /* 3 */
297 | }
298 |
299 | /*
300 | 1. Change the font styles in all browsers.
301 | 2. Remove the margin in Firefox and Safari.
302 | 3. Remove default padding in all browsers.
303 | */
304 |
305 | button,
306 | input,
307 | optgroup,
308 | select,
309 | textarea {
310 | font-family: inherit;
311 | /* 1 */
312 | font-feature-settings: inherit;
313 | /* 1 */
314 | font-variation-settings: inherit;
315 | /* 1 */
316 | font-size: 100%;
317 | /* 1 */
318 | font-weight: inherit;
319 | /* 1 */
320 | line-height: inherit;
321 | /* 1 */
322 | letter-spacing: inherit;
323 | /* 1 */
324 | color: inherit;
325 | /* 1 */
326 | margin: 0;
327 | /* 2 */
328 | padding: 0;
329 | /* 3 */
330 | }
331 |
332 | /*
333 | Remove the inheritance of text transform in Edge and Firefox.
334 | */
335 |
336 | button,
337 | select {
338 | text-transform: none;
339 | }
340 |
341 | /*
342 | 1. Correct the inability to style clickable types in iOS and Safari.
343 | 2. Remove default button styles.
344 | */
345 |
346 | button,
347 | input:where([type='button']),
348 | input:where([type='reset']),
349 | input:where([type='submit']) {
350 | -webkit-appearance: button;
351 | /* 1 */
352 | background-color: transparent;
353 | /* 2 */
354 | background-image: none;
355 | /* 2 */
356 | }
357 |
358 | /*
359 | Use the modern Firefox focus style for all focusable elements.
360 | */
361 |
362 | :-moz-focusring {
363 | outline: auto;
364 | }
365 |
366 | /*
367 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
368 | */
369 |
370 | :-moz-ui-invalid {
371 | box-shadow: none;
372 | }
373 |
374 | /*
375 | Add the correct vertical alignment in Chrome and Firefox.
376 | */
377 |
378 | progress {
379 | vertical-align: baseline;
380 | }
381 |
382 | /*
383 | Correct the cursor style of increment and decrement buttons in Safari.
384 | */
385 |
386 | ::-webkit-inner-spin-button,
387 | ::-webkit-outer-spin-button {
388 | height: auto;
389 | }
390 |
391 | /*
392 | 1. Correct the odd appearance in Chrome and Safari.
393 | 2. Correct the outline style in Safari.
394 | */
395 |
396 | [type='search'] {
397 | -webkit-appearance: textfield;
398 | /* 1 */
399 | outline-offset: -2px;
400 | /* 2 */
401 | }
402 |
403 | /*
404 | Remove the inner padding in Chrome and Safari on macOS.
405 | */
406 |
407 | ::-webkit-search-decoration {
408 | -webkit-appearance: none;
409 | }
410 |
411 | /*
412 | 1. Correct the inability to style clickable types in iOS and Safari.
413 | 2. Change font properties to `inherit` in Safari.
414 | */
415 |
416 | ::-webkit-file-upload-button {
417 | -webkit-appearance: button;
418 | /* 1 */
419 | font: inherit;
420 | /* 2 */
421 | }
422 |
423 | /*
424 | Add the correct display in Chrome and Safari.
425 | */
426 |
427 | summary {
428 | display: list-item;
429 | }
430 |
431 | /*
432 | Removes the default spacing and border for appropriate elements.
433 | */
434 |
435 | blockquote,
436 | dl,
437 | dd,
438 | h1,
439 | h2,
440 | h3,
441 | h4,
442 | h5,
443 | h6,
444 | hr,
445 | figure,
446 | p,
447 | pre {
448 | margin: 0;
449 | }
450 |
451 | fieldset {
452 | margin: 0;
453 | padding: 0;
454 | }
455 |
456 | legend {
457 | padding: 0;
458 | }
459 |
460 | ol,
461 | ul,
462 | menu {
463 | list-style: none;
464 | margin: 0;
465 | padding: 0;
466 | }
467 |
468 | /*
469 | Reset default styling for dialogs.
470 | */
471 |
472 | dialog {
473 | padding: 0;
474 | }
475 |
476 | /*
477 | Prevent resizing textareas horizontally by default.
478 | */
479 |
480 | textarea {
481 | resize: vertical;
482 | }
483 |
484 | /*
485 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
486 | 2. Set the default placeholder color to the user's configured gray 400 color.
487 | */
488 |
489 | input::-moz-placeholder, textarea::-moz-placeholder {
490 | opacity: 1;
491 | /* 1 */
492 | color: #9ca3af;
493 | /* 2 */
494 | }
495 |
496 | input::placeholder,
497 | textarea::placeholder {
498 | opacity: 1;
499 | /* 1 */
500 | color: #9ca3af;
501 | /* 2 */
502 | }
503 |
504 | /*
505 | Set the default cursor for buttons.
506 | */
507 |
508 | button,
509 | [role="button"] {
510 | cursor: pointer;
511 | }
512 |
513 | /*
514 | Make sure disabled buttons don't get the pointer cursor.
515 | */
516 |
517 | :disabled {
518 | cursor: default;
519 | }
520 |
521 | /*
522 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
523 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
524 | This can trigger a poorly considered lint error in some tools but is included by design.
525 | */
526 |
527 | img,
528 | svg,
529 | video,
530 | canvas,
531 | audio,
532 | iframe,
533 | embed,
534 | object {
535 | display: block;
536 | /* 1 */
537 | vertical-align: middle;
538 | /* 2 */
539 | }
540 |
541 | /*
542 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
543 | */
544 |
545 | img,
546 | video {
547 | max-width: 100%;
548 | height: auto;
549 | }
550 |
551 | /* Make elements with the HTML hidden attribute stay hidden by default */
552 |
553 | [hidden]:where(:not([hidden="until-found"])) {
554 | display: none;
555 | }
556 |
557 | .relative {
558 | position: relative;
559 | }
560 |
561 | .my-1 {
562 | margin-top: 0.25rem;
563 | margin-bottom: 0.25rem;
564 | }
565 |
566 | .mt-1 {
567 | margin-top: 0.25rem;
568 | }
569 |
570 | .mt-2 {
571 | margin-top: 0.5rem;
572 | }
573 |
574 | .block {
575 | display: block;
576 | }
577 |
578 | .flex {
579 | display: flex;
580 | }
581 |
582 | .h-4 {
583 | height: 1rem;
584 | }
585 |
586 | .h-6 {
587 | height: 1.5rem;
588 | }
589 |
590 | .w-1\/2 {
591 | width: 50%;
592 | }
593 |
594 | .w-4 {
595 | width: 1rem;
596 | }
597 |
598 | .w-4\/5 {
599 | width: 80%;
600 | }
601 |
602 | .flex-col {
603 | flex-direction: column;
604 | }
605 |
606 | .items-center {
607 | align-items: center;
608 | }
609 |
610 | .justify-center {
611 | justify-content: center;
612 | }
613 |
614 | .gap-x-2 {
615 | -moz-column-gap: 0.5rem;
616 | column-gap: 0.5rem;
617 | }
618 |
619 | .self-center {
620 | align-self: center;
621 | }
622 |
623 | .rounded {
624 | border-radius: 0.25rem;
625 | }
626 |
627 | .rounded-md {
628 | border-radius: 0.375rem;
629 | }
630 |
631 | .border-0 {
632 | border-width: 0px;
633 | }
634 |
635 | .border-gray-300 {
636 | --tw-border-opacity: 1;
637 | border-color: rgb(209 213 219 / var(--tw-border-opacity));
638 | }
639 |
640 | .bg-indigo-600 {
641 | --tw-bg-opacity: 1;
642 | background-color: rgb(79 70 229 / var(--tw-bg-opacity));
643 | }
644 |
645 | .p-3 {
646 | padding: 0.75rem;
647 | }
648 |
649 | .px-2 {
650 | padding-left: 0.5rem;
651 | padding-right: 0.5rem;
652 | }
653 |
654 | .px-3 {
655 | padding-left: 0.75rem;
656 | padding-right: 0.75rem;
657 | }
658 |
659 | .py-1\.5 {
660 | padding-top: 0.375rem;
661 | padding-bottom: 0.375rem;
662 | }
663 |
664 | .py-2 {
665 | padding-top: 0.5rem;
666 | padding-bottom: 0.5rem;
667 | }
668 |
669 | .text-center {
670 | text-align: center;
671 | }
672 |
673 | .text-base {
674 | font-size: 1rem;
675 | line-height: 1.5rem;
676 | }
677 |
678 | .text-sm {
679 | font-size: 0.875rem;
680 | line-height: 1.25rem;
681 | }
682 |
683 | .text-xl {
684 | font-size: 1.25rem;
685 | line-height: 1.75rem;
686 | }
687 |
688 | .font-bold {
689 | font-weight: 700;
690 | }
691 |
692 | .font-medium {
693 | font-weight: 500;
694 | }
695 |
696 | .font-semibold {
697 | font-weight: 600;
698 | }
699 |
700 | .leading-6 {
701 | line-height: 1.5rem;
702 | }
703 |
704 | .leading-7 {
705 | line-height: 1.75rem;
706 | }
707 |
708 | .text-gray-900 {
709 | --tw-text-opacity: 1;
710 | color: rgb(17 24 39 / var(--tw-text-opacity));
711 | }
712 |
713 | .text-indigo-600 {
714 | --tw-text-opacity: 1;
715 | color: rgb(79 70 229 / var(--tw-text-opacity));
716 | }
717 |
718 | .text-white {
719 | --tw-text-opacity: 1;
720 | color: rgb(255 255 255 / var(--tw-text-opacity));
721 | }
722 |
723 | .shadow-sm {
724 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
725 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
726 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
727 | }
728 |
729 | .ring-1 {
730 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
731 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
732 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
733 | }
734 |
735 | .ring-inset {
736 | --tw-ring-inset: inset;
737 | }
738 |
739 | .ring-gray-300 {
740 | --tw-ring-opacity: 1;
741 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
742 | }
743 |
744 | .placeholder\:text-gray-400::-moz-placeholder {
745 | --tw-text-opacity: 1;
746 | color: rgb(156 163 175 / var(--tw-text-opacity));
747 | }
748 |
749 | .placeholder\:text-gray-400::placeholder {
750 | --tw-text-opacity: 1;
751 | color: rgb(156 163 175 / var(--tw-text-opacity));
752 | }
753 |
754 | .hover\:bg-indigo-500:hover {
755 | --tw-bg-opacity: 1;
756 | background-color: rgb(99 102 241 / var(--tw-bg-opacity));
757 | }
758 |
759 | .focus\:ring-2:focus {
760 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
761 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
762 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
763 | }
764 |
765 | .focus\:ring-inset:focus {
766 | --tw-ring-inset: inset;
767 | }
768 |
769 | .focus\:ring-indigo-600:focus {
770 | --tw-ring-opacity: 1;
771 | --tw-ring-color: rgb(79 70 229 / var(--tw-ring-opacity));
772 | }
773 |
774 | .focus-visible\:outline:focus-visible {
775 | outline-style: solid;
776 | }
777 |
778 | .focus-visible\:outline-2:focus-visible {
779 | outline-width: 2px;
780 | }
781 |
782 | .focus-visible\:outline-offset-2:focus-visible {
783 | outline-offset: 2px;
784 | }
785 |
786 | .focus-visible\:outline-indigo-600:focus-visible {
787 | outline-color: #4f46e5;
788 | }
789 |
790 | @media (min-width: 640px) {
791 | .sm\:truncate {
792 | overflow: hidden;
793 | text-overflow: ellipsis;
794 | white-space: nowrap;
795 | }
796 |
797 | .sm\:text-3xl {
798 | font-size: 1.875rem;
799 | line-height: 2.25rem;
800 | }
801 |
802 | .sm\:text-sm {
803 | font-size: 0.875rem;
804 | line-height: 1.25rem;
805 | }
806 |
807 | .sm\:leading-6 {
808 | line-height: 1.5rem;
809 | }
810 |
811 | .sm\:tracking-tight {
812 | letter-spacing: -0.025em;
813 | }
814 | }
--------------------------------------------------------------------------------
/dist/html/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Options - twitter画像原寸ボタン
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/dist/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/dist/icons/icon.png
--------------------------------------------------------------------------------
/dist/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "twitter画像原寸ボタン",
4 | "version": "7.1.0",
5 | "description": "twitterの画像ツイートにボタンを追加する拡張機能。追加されたボタンを押すとツイートの画像を原寸で新しいタブに表示する。連絡先: @hogextend",
6 | "author": "hogashi",
7 | "permissions": ["tabs", "storage"],
8 | "icons": {
9 | "16": "icons/icon.png",
10 | "48": "icons/icon.png",
11 | "128": "icons/icon.png"
12 | },
13 | "content_scripts": [
14 | {
15 | "matches": [
16 | "https://twitter.com/*",
17 | "https://x.com/*",
18 | "https://mobile.twitter.com/*",
19 | "https://mobile.x.com/*",
20 | "https://tweetdeck.twitter.com/*",
21 | "https://pro.twitter.com/*",
22 | "https://pro.x.com/*",
23 | "https://pbs.twimg.com/*"
24 | ],
25 | "js": ["js/main.bundle.js"]
26 | }
27 | ],
28 | "background": {
29 | "service_worker": "js/background.bundle.js"
30 | },
31 | "action": {
32 | "default_icon": "icons/icon.png",
33 | "default_title": "twitter画像原寸ボタン",
34 | "default_popup": "html/popup.html"
35 | },
36 | "options_page": "html/popup.html"
37 | }
38 |
--------------------------------------------------------------------------------
/images/detail1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/detail1.jpg
--------------------------------------------------------------------------------
/images/detail1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/detail1.png
--------------------------------------------------------------------------------
/images/detail2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/detail2.jpg
--------------------------------------------------------------------------------
/images/detail2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/detail2.png
--------------------------------------------------------------------------------
/images/options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/options.png
--------------------------------------------------------------------------------
/images/timeline1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/timeline1.jpg
--------------------------------------------------------------------------------
/images/timeline1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/timeline1.png
--------------------------------------------------------------------------------
/images/timeline2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/timeline2.jpg
--------------------------------------------------------------------------------
/images/timeline2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hogashi/twitterOpenOriginalImage/1582234504cf169110b47837040e1baecbebc11c/images/timeline2.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | coverageReporters: ['json-summary', 'lcov', 'text'],
3 | setupFilesAfterEnv: ['./jest.setup.js'],
4 | };
5 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | Object.assign(global, require('jest-chrome'));
2 | import '@testing-library/jest-dom';
3 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitter-open-original-image",
3 | "description": "opens images in original size at twitter",
4 | "repository": "git@github.com:hogashi/twitterOpenOriginalImage.git",
5 | "author": "hogashi ",
6 | "license": "MIT",
7 | "scripts": {
8 | "build:webpack": "webpack --version && webpack --mode production",
9 | "build:tailwindcss": "tailwindcss -i ./src/input.css -o ./dist/css/popup.css",
10 | "build": "run-p build:webpack build:tailwindcss",
11 | "watch:webpack": "webpack --version && webpack --mode production --watch",
12 | "watch:tailwindcss": "tailwindcss -i ./src/input.css -o ./dist/css/popup.css --watch",
13 | "watch": "run-p watch:webpack watch:tailwindcss",
14 | "lint": "biome check --apply-unsafe src __tests__",
15 | "format": "biome format --write src __tests__",
16 | "rome:fix": "run-s lint format",
17 | "jest": "jest --version && jest --verbose --testEnvironment=jsdom --coverage",
18 | "coverage": "make-coverage-badge",
19 | "test": "run-s lint test:ts jest coverage",
20 | "test:lint-format": "rome check src __tests__ && rome format src __tests__",
21 | "test:ts": "tsc --noEmit",
22 | "compress": "zip -r dist.zip dist",
23 | "compress:edge": "run-s edit-manifest-edge compress restore-manifest-edge",
24 | "edit-manifest-edge": "sed -i.bak 's/\\(options_page.*\\)$/\\1, \"update_URL\": \"https:\\/\\/edge.microsoft.com\\/extensionwebstorebase\\/v1\\/crx\"/' dist/manifest.json",
25 | "restore-manifest-edge": "mv -f dist/manifest.json{.bak,}"
26 | },
27 | "devDependencies": {
28 | "@babel/cli": "7.27.0",
29 | "@babel/core": "7.27.1",
30 | "@babel/preset-env": "7.27.1",
31 | "@babel/preset-react": "7.27.1",
32 | "@babel/preset-typescript": "7.27.0",
33 | "@testing-library/jest-dom": "^6.6.3",
34 | "@testing-library/react": "^16.3.0",
35 | "@testing-library/user-event": "^14.6.1",
36 | "@tsconfig/recommended": "1.0.8",
37 | "@types/chrome": "0.0.206",
38 | "@types/filesystem": "0.0.32",
39 | "@types/jest": "29.5.0",
40 | "@types/react": "18.3.0",
41 | "@types/react-dom": "18.3.0",
42 | "@types/react-test-renderer": "18.3.0",
43 | "@typescript-eslint/eslint-plugin": "8.33.0",
44 | "@typescript-eslint/parser": "8.33.0",
45 | "babel-loader": "9.2.1",
46 | "jest": "29.7.0",
47 | "jest-chrome": "^0.8.0",
48 | "jest-environment-jsdom": "^29.3.1",
49 | "make-coverage-badge": "1.2.0",
50 | "npm-run-all2": "7.0.2",
51 | "react-test-renderer": "18.3.1",
52 | "tailwindcss": "3.4.17",
53 | "typescript": "5.8.2",
54 | "webpack": "5.99.0",
55 | "webpack-cli": "6.0.1"
56 | },
57 | "dependencies": {
58 | "@babel/runtime": "7.26.0",
59 | "@biomejs/biome": "1.9.4",
60 | "react": "18.3.1",
61 | "react-dom": "18.3.1"
62 | },
63 | "pnpm": {
64 | "overrides": {
65 | "cheerio": "1.0.0-rc.12"
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/privacy-policy.md:
--------------------------------------------------------------------------------
1 | # 「twitter 画像原寸ボタン」 Privacy Policy / プライバシーポリシー
2 |
3 | - Source code: https://github.com/hogashi/twitterOpenOriginalImage/
4 | - Chrome Web Store: https://chrome.google.com/webstore/detail/kmcomcgcopagkhcbmcmcfhpcmdolfijg
5 | - Twitter: [@hogextend](https://twitter.com/hogextend)
6 |
7 | ## Does / すること
8 |
9 | This Chrome extension does:
10 |
11 | - save settings locally with users' own LocalStorage and [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/)
12 | - tell updated settings to every tabs which this extension is loaded in users' Chrome browser with [`chrome.tabs` API](https://developer.chrome.com/extensions/tabs) (load target URL is described in `"content_scripts" > "matches"` section in [manifest.json](./dist/manifest.json))
13 |
14 | この Chrome 拡張機能は以下をします:
15 |
16 | - ユーザ自身の LocalStorage と [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/) に設定を保存すること
17 | - 更新された設定を、 [`chrome.tabs` API](https://developer.chrome.com/extensions/tabs) を使って、この拡張機能が読み込まれたタブに伝えること (この拡張機能が読み込まれる対象の URL は、 [manifest.json](./dist/manifest.json) の `"content_scripts" > "matches"` 部分に書かれている)
18 |
19 | ## Does Not / しないこと
20 |
21 | This Chrome extension does not:
22 |
23 | - collect any personal data
24 | - send any data to anywhere in Internet without [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/)
25 | - share any data with other people since `chrome.storage.sync` is Google Chrome's personal space
26 |
27 | この拡張機能は以下をしません:
28 |
29 | - 個人情報を収集すること
30 | - [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/) 以外で何かのデータをインターネットのどこかに送ること
31 | - 何かのデータを他の誰かと共有すること (`chrome.storage.sync` は Google Chrome の個々人のデータ保管場所です)
32 |
33 | ## Changelog / 更新
34 |
35 | - 2024/11/09 add that this extension does not collect any personal data / この拡張機能が個人情報を収集しないことを追加
36 | - 2022/01/09 add use of [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/) / [`chrome.storage.sync`](https://developer.chrome.com/docs/extensions/reference/storage/) を使うようになったので追加
37 | - 2020/02/03 create this document / この文書を作成
38 |
--------------------------------------------------------------------------------
/renovate.json5:
--------------------------------------------------------------------------------
1 | {
2 | $schema: "https://docs.renovatebot.com/renovate-schema.json",
3 | extends: ["config:recommended", ":automergeMinor"],
4 | labels: ["renovate"],
5 | packageRules: [
6 | {
7 | matchUpdateTypes: ["minor", "pin", "digest"],
8 | automerge: true,
9 | postUpdateOptions: ["pnpmDedupe"],
10 | },
11 | {
12 | matchUpdateTypes: ["patch"],
13 | enabled: false,
14 | },
15 | {
16 | groupName: "@types/react, @types/react-dom",
17 | matchPackageNames: ["/^@types/react(|-dom)$/"],
18 | },
19 | {
20 | prCreation: "immediate",
21 | matchPackageNames: ["/^node$/"],
22 | },
23 | ],
24 | }
25 |
--------------------------------------------------------------------------------
/scripts/make_user_script.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | SCRIPT_DIRNAME='scripts'
6 | TMP_DIRNAME="${SCRIPT_DIRNAME}/tmp"
7 | HEADER_FILENAME="${TMP_DIRNAME}/header.js"
8 | MAIN_TS_FILENAME="${TMP_DIRNAME}/main_edited.ts"
9 | MAIN_JS_FILENAME="${TMP_DIRNAME}/main_compiled.js"
10 | TARGET_FILENAME="tooi-forGreaseTamperMonkey.user.js"
11 |
12 | if [ ! -f "${SCRIPT_DIRNAME}/$(basename ${0})" ]; then
13 | echo "run this script at the top directory." 1>&2
14 | exit 1
15 | fi
16 |
17 | mkdir -p "${TMP_DIRNAME}"
18 |
19 | VERSION="$(cat dist/manifest.json | jq -r .version)"
20 |
21 | cat > "${HEADER_FILENAME}" <<__EOS__
22 | // ==UserScript==
23 | // @author hogashi
24 | // @name twitterOpenOriginalImage
25 | // @namespace https://hogashi.hatenablog.com/
26 | // @description TwitterページでOriginalボタンを押すと原寸の画像が開きます(https://hogashi.hatenablog.com)
27 | // @include https://twitter.com*
28 | // @include https://mobile.twitter.com*
29 | // @include https://tweetdeck.twitter.com*
30 | // @include https://pbs.twimg.com/media*
31 | // @version ${VERSION}
32 | // ==/UserScript==
33 |
34 | __EOS__
35 |
36 | cat src/main.ts | perl -pe 's/^export //g' > "${MAIN_TS_FILENAME}"
37 |
38 | echo "tsc-ing..." 1>&2
39 | node_modules/.bin/tsc --outFile "${MAIN_JS_FILENAME}" "${MAIN_TS_FILENAME}"
40 | cat "${HEADER_FILENAME}" "${MAIN_JS_FILENAME}" > "${TARGET_FILENAME}"
41 | echo "burned: ${TARGET_FILENAME}" 1>&2
42 |
--------------------------------------------------------------------------------
/src/ButtonSetter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
3 | type OptionsBool,
4 | SHOW_ON_TIMELINE,
5 | SHOW_ON_TWEETDECK_TIMELINE,
6 | SHOW_ON_TWEET_DETAIL,
7 | isTweetdeck,
8 | isTwitter,
9 | } from './constants';
10 | import { onOriginalButtonClick, printException, setStyle } from './utils';
11 |
12 | export interface ButtonSetterType {
13 | setButtonOnTimeline: (currentOptions: OptionsBool) => void;
14 | setButtonOnTweetDetail: (currentOptions: OptionsBool) => void;
15 | }
16 |
17 | /**
18 | * twitter.comでボタンを設置するクラス
19 | */
20 | export class ButtonSetter implements ButtonSetterType {
21 | // タイムラインにボタン表示
22 | public setButtonOnTimeline(currentOptions: OptionsBool): void {
23 | // 昔のビューの処理はしばらく残す
24 | // ref: https://github.com/hogashi/twitterOpenOriginalImage/issues/32#issuecomment-578510155
25 | if (document.querySelector('#react-root')) {
26 | this._setButtonOnReactLayoutTimeline(currentOptions);
27 | return;
28 | }
29 | this._setButtonOnTimeline(currentOptions);
30 | }
31 |
32 | // ツイート詳細にボタン表示
33 | public setButtonOnTweetDetail(currentOptions: OptionsBool): void {
34 | // 昔のビューの処理はしばらく残す
35 | // - 公式の新しいhtmlでは使えないが, 古いhtmlで閲覧している人がいるので残してある
36 | this._setButtonOnTweetDetail(currentOptions);
37 | }
38 |
39 | private setButton({
40 | className,
41 | getImgSrcs,
42 | target,
43 | text,
44 | }: {
45 | className: string;
46 | getImgSrcs: () => string[];
47 | target: HTMLElement;
48 | text: string;
49 | }): void {
50 | const style = {
51 | width: '70px',
52 | 'font-size': '13px',
53 | color: this.getActionButtonColor(),
54 | };
55 |
56 | /* つくるDOMは以下 */
57 | /*
58 |
59 | {
65 | onOriginalButtonClick(e, imgSrcs);
66 | }}
67 | />
68 |
69 | */
70 |
71 | const button = document.createElement('input');
72 | button.className = 'tooi-button';
73 | setStyle(button, style);
74 | button.type = 'button';
75 | button.value = text;
76 | button.addEventListener('click', (e) => {
77 | onOriginalButtonClick(e, getImgSrcs());
78 | });
79 |
80 | const container = document.createElement('div');
81 | container.classList.add('ProfileTweet-action', className);
82 |
83 | target.appendChild(container);
84 | container.appendChild(button);
85 | }
86 |
87 | private setReactLayoutButton({
88 | className,
89 | getImgSrcs,
90 | target,
91 | text,
92 | }: {
93 | className: string;
94 | getImgSrcs: () => string[];
95 | target: HTMLElement;
96 | text: string;
97 | }): void {
98 | const button = document.createElement('input');
99 |
100 | button.type = 'button';
101 | button.value = text;
102 | const color = this.getReactLayoutActionButtonColor();
103 | setStyle(button, {
104 | 'font-size': '13px',
105 | padding: '4px 8px',
106 | color,
107 | 'background-color': 'rgba(0, 0, 0, 0)',
108 | border: `1px solid ${color}`,
109 | 'border-radius': '3px',
110 | cursor: 'pointer',
111 | });
112 | button.addEventListener('click', (e) => {
113 | onOriginalButtonClick(e, getImgSrcs());
114 | });
115 |
116 | const container = document.createElement('div');
117 | // container.id = '' + tweet.id
118 | container.classList.add(className);
119 | setStyle(container, {
120 | display: 'flex',
121 | 'flex-flow': 'column',
122 | 'justify-content': 'center',
123 | });
124 |
125 | // 新しいTweetDeckで, カラムの幅によってはボタンがはみ出るので,
126 | // 折り返してボタンを表示する
127 | setStyle(target, { 'flex-wrap': 'wrap' });
128 |
129 | target.appendChild(container);
130 | container.appendChild(button);
131 | }
132 |
133 | private _setButtonOnTimeline(currentOptions: OptionsBool): void {
134 | // タイムラインにボタン表示する設定がされているときだけ実行する
135 | if (!currentOptions[SHOW_ON_TIMELINE]) {
136 | return;
137 | }
138 | const tweets = document.getElementsByClassName('js-stream-tweet');
139 | if (!tweets.length) {
140 | return;
141 | }
142 | const className = 'tooi-button-container-timeline';
143 | // 各ツイートに対して
144 | Array.from(tweets).forEach((tweet) => {
145 | // 画像ツイートかつまだ処理を行っていないときのみ行う
146 | if (
147 | !(
148 | tweet.getElementsByClassName('AdaptiveMedia-container').length !== 0 &&
149 | tweet.getElementsByClassName('AdaptiveMedia-container')[0].getElementsByTagName('img').length !== 0
150 | ) ||
151 | tweet.getElementsByClassName(className).length !== 0
152 | ) {
153 | return;
154 | }
155 | // 操作ボタンの外側は様式にあわせる
156 | const actionList = tweet.querySelector('.ProfileTweet-actionList');
157 | if (!actionList) {
158 | printException('no target');
159 | return;
160 | }
161 |
162 | // 画像の親が取得できたら
163 | const mediaContainer = tweet.getElementsByClassName('AdaptiveMedia-container')[0] as HTMLElement;
164 | const getImgSrcs = (): string[] =>
165 | Array.from(mediaContainer.getElementsByClassName('AdaptiveMedia-photoContainer')).map(
166 | (element) => element.getElementsByTagName('img')[0].src,
167 | );
168 | this.setButton({
169 | className,
170 | getImgSrcs,
171 | target: actionList,
172 | text: currentOptions[ORIGINAL_BUTTON_TEXT_OPTION_KEY],
173 | });
174 | });
175 | }
176 |
177 | private _setButtonOnTweetDetail(currentOptions: OptionsBool): void {
178 | // ツイート詳細にボタン表示する設定がされているときだけ実行する
179 | if (!currentOptions[SHOW_ON_TWEET_DETAIL]) {
180 | return;
181 | }
182 | const className = 'tooi-button-container-detail';
183 | if (
184 | !document
185 | .getElementsByClassName('permalink-tweet-container')[0]
186 | ?.getElementsByClassName('AdaptiveMedia-photoContainer')[0] ||
187 | document.getElementsByClassName(className).length !== 0
188 | ) {
189 | // ツイート詳細ページでない、または、メインツイートが画像ツイートでないとき
190 | // または、すでに処理を行ってあるとき
191 | // 何もしない
192 | return;
193 | }
194 | // Originalボタンの親の親となる枠
195 | const actionList = document.querySelector('.permalink-tweet-container .ProfileTweet-actionList');
196 | if (!actionList) {
197 | printException('no target');
198 | return;
199 | }
200 |
201 | // .AdaptiveMedia-photoContainer: 画像のエレメントからURLを取得する
202 | const getImgSrcs = (): string[] =>
203 | Array.from(
204 | document
205 | .getElementsByClassName('permalink-tweet-container')[0]
206 | .getElementsByClassName('AdaptiveMedia-photoContainer'),
207 | ).map((element) => element.getElementsByTagName('img')[0].src);
208 | this.setButton({
209 | className,
210 | getImgSrcs,
211 | target: actionList,
212 | text: currentOptions[ORIGINAL_BUTTON_TEXT_OPTION_KEY],
213 | });
214 | }
215 |
216 | private _setButtonOnReactLayoutTimeline(currentOptions: OptionsBool): void {
217 | // タイムラインにボタン表示する設定がされているときだけ実行する
218 | // 公式Webと, 新しいTweetDeckで呼ばれる
219 | if (
220 | (isTwitter() && !currentOptions[SHOW_ON_TIMELINE]) ||
221 | (isTweetdeck() && !currentOptions[SHOW_ON_TWEETDECK_TIMELINE])
222 | ) {
223 | return;
224 | }
225 | const className = 'tooi-button-container-react-timeline';
226 | const tweets = Array.from(document.querySelectorAll('#react-root main section article'));
227 | if (!tweets.length) {
228 | return;
229 | }
230 | // 各ツイートに対して
231 | tweets.forEach((tweet) => {
232 | // 画像ツイート かつ 画像が1枚でもある かつ まだ処理を行っていないときのみ実行
233 | const tweetATags = Array.from(tweet.querySelectorAll('a')).filter((aTag) =>
234 | /\/status\/[0-9]+\/photo\//.test(aTag.href),
235 | );
236 | if (
237 | tweetATags.length === 0 ||
238 | tweetATags.every((aTag) => !aTag.querySelector('img')) ||
239 | tweet.getElementsByClassName(className)[0]
240 | ) {
241 | return;
242 | }
243 | // ボタンを設置
244 | // 操作ボタンの外側は様式にあわせる
245 | const target = tweet.querySelector('div[role="group"]');
246 | if (!target) {
247 | printException('no target');
248 | return;
249 | }
250 |
251 | const getImgSrcs = (): string[] => {
252 | const tweetImgs = tweetATags.map((aTag) => aTag.querySelector('img'));
253 | return tweetImgs
254 | .map((img) =>
255 | // filter で string[] にするためにここで string[] にする……
256 | img ? img.src : '',
257 | )
258 | .filter((src) => src !== '');
259 | };
260 |
261 | this.setReactLayoutButton({
262 | className,
263 | getImgSrcs,
264 | target,
265 | text: currentOptions[ORIGINAL_BUTTON_TEXT_OPTION_KEY],
266 | });
267 | });
268 | }
269 |
270 | private getActionButtonColor(): string {
271 | // コントラスト比4.5(chromeの推奨する最低ライン)の色
272 | const contrastLimitColor = '#697b8c';
273 |
274 | const actionButton = document.querySelector('.ProfileTweet-actionButton') as HTMLElement;
275 | if (!actionButton?.style) {
276 | return contrastLimitColor;
277 | }
278 |
279 | const buttonColor = window.getComputedStyle(actionButton).color;
280 | if (buttonColor && buttonColor.length > 0) {
281 | return buttonColor;
282 | }
283 | return contrastLimitColor;
284 | }
285 |
286 | private getReactLayoutActionButtonColor(): string {
287 | // 文字色
288 | // 初期値: コントラスト比4.5(chromeの推奨する最低ライン)の色
289 | let color = '#697b8c';
290 | // ツイートアクション(返信とか)のボタンのクラス(夜間モードか否かでクラス名が違う)
291 | const actionButtonSvg = document.querySelector('div[role="group"] div[role="button"] svg');
292 | if (actionButtonSvg) {
293 | const buttonColor = window.getComputedStyle(actionButtonSvg).color;
294 | if (buttonColor && buttonColor.length > 0) {
295 | color = buttonColor;
296 | }
297 | }
298 |
299 | return color;
300 | }
301 | }
302 |
--------------------------------------------------------------------------------
/src/ButtonSetterTweetDeck.ts:
--------------------------------------------------------------------------------
1 | import type { ButtonSetterType } from './ButtonSetter';
2 | import {
3 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
4 | type OptionsBool,
5 | SHOW_ON_TWEETDECK_TIMELINE,
6 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
7 | } from './constants';
8 | import { onOriginalButtonClick, printException, setStyle } from './utils';
9 |
10 | /**
11 | * tweetdeck.twitter.comでボタンを設置するクラス
12 | */
13 | export class ButtonSetterTweetDeck implements ButtonSetterType {
14 | // タイムラインにボタン表示
15 | public setButtonOnTimeline(currentOptions: OptionsBool): void {
16 | // タイムラインにボタン表示する設定がされているときだけ実行する
17 | if (!currentOptions[SHOW_ON_TWEETDECK_TIMELINE]) {
18 | return;
19 | }
20 | // if タイムラインのツイートを取得できたら
21 | // is-actionable: タイムラインのみ
22 | const tweets = document.getElementsByClassName('js-stream-item is-actionable') as HTMLCollectionOf;
23 | if (!tweets.length) {
24 | return;
25 | }
26 | const className = 'tooi-button-container-tweetdeck-timeline';
27 | // 各ツイートに対して
28 | Array.from(tweets).forEach((tweet) => {
29 | if (
30 | !tweet.getElementsByClassName('js-media-image-link').length ||
31 | tweet.getElementsByClassName('is-video').length ||
32 | tweet.getElementsByClassName('is-gif').length ||
33 | tweet.getElementsByClassName(className).length
34 | ) {
35 | // メディアツイートでない
36 | // または メディアが画像でない(動画/GIF)
37 | // または すでにボタンをおいてあるとき
38 | // 何もしない
39 | return;
40 | }
41 |
42 | const target = tweet.querySelector('footer');
43 | if (!target) {
44 | // ボタンを置く場所がないとき
45 | // 何もしない
46 | printException('no target');
47 | return;
48 | }
49 |
50 | const getImgSrcs = (): string[] => {
51 | return Array.from(tweet.getElementsByClassName('js-media-image-link'))
52 | .map((element) => {
53 | const urlstr = this.getBackgroundImageUrl(element as HTMLElement);
54 | // filter で string[] にするためにここで string[] にする……
55 | return urlstr ? urlstr : '';
56 | })
57 | .filter((urlstr) => urlstr !== '');
58 | };
59 | this.setButton({
60 | className,
61 | getImgSrcs,
62 | target,
63 | text: currentOptions[ORIGINAL_BUTTON_TEXT_OPTION_KEY],
64 | });
65 | });
66 | }
67 |
68 | // ツイート詳細にボタン表示
69 | // - 公式の新しいhtmlでは使えないが, 古いhtmlで閲覧している人がいるので残してある
70 | public setButtonOnTweetDetail(currentOptions: OptionsBool): void {
71 | // ツイート詳細にボタン表示する設定がされているときだけ実行する
72 | if (!currentOptions[SHOW_ON_TWEETDECK_TWEET_DETAIL]) {
73 | return;
74 | }
75 | // if ツイート詳細を取得できたら
76 | const tweets = document.getElementsByClassName('js-tweet-detail') as HTMLCollectionOf;
77 | if (!tweets.length) {
78 | return;
79 | }
80 | const className = 'tooi-button-container-tweetdeck-detail';
81 | // 各ツイートに対して
82 | Array.from(tweets).forEach((tweet) => {
83 | if (
84 | !(tweet.getElementsByClassName('media-img').length || tweet.getElementsByClassName('media-image').length) ||
85 | tweet.getElementsByClassName(className).length
86 | ) {
87 | // メディアツイートでない (画像のタグが取得できない)
88 | // または すでにボタンをおいてあるとき
89 | // 何もしない
90 | return;
91 | }
92 | const target = tweet.querySelector('footer');
93 | if (!target) {
94 | // ボタンを置く場所がないとき
95 | // 何もしない
96 | printException('no target');
97 | return;
98 | }
99 |
100 | const getImgSrcs = (): string[] => {
101 | if (tweet.getElementsByClassName('media-img').length !== 0) {
102 | return [(tweet.getElementsByClassName('media-img')[0] as HTMLImageElement).src];
103 | }
104 | return Array.from(tweet.getElementsByClassName('media-image'))
105 | .map((element) => {
106 | const urlstr = this.getBackgroundImageUrl(element as HTMLElement);
107 | // filter で string[] にするためにここで string[] にする……
108 | return urlstr ? urlstr : '';
109 | })
110 | .filter((urlstr) => urlstr !== '');
111 | };
112 |
113 | this.setButton({
114 | className,
115 | getImgSrcs,
116 | target,
117 | text: currentOptions[ORIGINAL_BUTTON_TEXT_OPTION_KEY],
118 | });
119 | });
120 | }
121 |
122 | private setButton({
123 | className,
124 | getImgSrcs,
125 | target,
126 | text,
127 | }: {
128 | className: string;
129 | getImgSrcs: () => string[];
130 | target: HTMLElement;
131 | text: string;
132 | }): void {
133 | // 枠線の色は'Original'と同じく'.txt-mute'の色を使うので取得する
134 | const txtMute = document.querySelector('.txt-mute');
135 | const borderColor = txtMute ? window.getComputedStyle(txtMute).color : '#697b8c';
136 | // ボタンのスタイル設定
137 | const style = {
138 | border: `1px solid ${borderColor}`,
139 | 'border-radius': '2px',
140 | display: 'inline-block',
141 | 'font-size': '0.75em',
142 | 'margin-top': '5px',
143 | padding: '1px 1px 0',
144 | 'line-height': '1.5em',
145 | cursor: 'pointer',
146 | };
147 |
148 | /* つくるDOMは以下 */
149 | /*
150 | {
154 | onOriginalButtonClick(e, imgSrcs);
155 | }}
156 | >
157 | Original
158 |
159 | */
160 |
161 | // tweetdeckのツイート右上の時刻などに使われているclassを使う
162 | // 設置の有無の判別用にclassNameを付与する
163 | const button = document.createElement('a');
164 | button.className = `pull-left margin-txs txt-mute ${className}`;
165 | setStyle(button, style);
166 | button.addEventListener('click', (e) => {
167 | onOriginalButtonClick(e, getImgSrcs());
168 | });
169 | button.insertAdjacentHTML('beforeend', text);
170 |
171 | target.appendChild(button);
172 | }
173 |
174 | private getBackgroundImageUrl(element: HTMLElement): string | null {
175 | if (element.style.backgroundImage) {
176 | return element.style.backgroundImage.replace(/url\("?([^"]*)"?\)/, '$1');
177 | }
178 | return null;
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 設定項目
3 | */
4 |
5 | // 設定取得メッセージ
6 | export const OPTION_UPDATED = 'OPTION_UPDATED';
7 | export const GET_LOCAL_STORAGE = 'GET_LOCAL_STORAGE';
8 |
9 | // 公式Web
10 | export const HOST_TWITTER_COM = 'twitter.com';
11 | export const HOST_MOBILE_TWITTER_COM = 'mobile.twitter.com';
12 | export const HOST_X_COM = 'x.com';
13 | export const HOST_MOBILE_X_COM = 'mobile.x.com';
14 | export const SHOW_ON_TIMELINE = 'SHOW_ON_TIMELINE';
15 | export const SHOW_ON_TWEET_DETAIL = 'SHOW_ON_TWEET_DETAIL';
16 | // TweetDeck
17 | export const HOST_TWEETDECK_TWITTER_COM = 'tweetdeck.twitter.com';
18 | export const HOST_PRO_TWITTER_COM = 'pro.twitter.com';
19 | export const HOST_PRO_X_COM = 'pro.x.com';
20 | export const SHOW_ON_TWEETDECK_TIMELINE = 'SHOW_ON_TWEETDECK_TIMELINE';
21 | export const SHOW_ON_TWEETDECK_TWEET_DETAIL = 'SHOW_ON_TWEETDECK_TWEET_DETAIL';
22 |
23 | // Originalボタンのテキスト
24 | export const ORIGINAL_BUTTON_TEXT_OPTION_KEY = 'ORIGINAL_BUTTON_TEXT_OPTION_KEY';
25 | export const INITIAL_ORIGINAL_BUTTON_TEXT = 'Original';
26 |
27 | export interface OptionsBool {
28 | // 公式Web
29 | SHOW_ON_TIMELINE: boolean;
30 | SHOW_ON_TWEET_DETAIL: boolean;
31 | // TweetDeck
32 | SHOW_ON_TWEETDECK_TIMELINE: boolean;
33 | SHOW_ON_TWEETDECK_TWEET_DETAIL: boolean;
34 | // Originalボタンのテキスト
35 | ORIGINAL_BUTTON_TEXT_OPTION_KEY: string;
36 | }
37 |
38 | // インストールした直後の初期値
39 | export const initialOptionsBool: OptionsBool = {
40 | // 公式Web
41 | SHOW_ON_TIMELINE: true,
42 | SHOW_ON_TWEET_DETAIL: true,
43 | // TweetDeck
44 | SHOW_ON_TWEETDECK_TIMELINE: true,
45 | SHOW_ON_TWEETDECK_TWEET_DETAIL: true,
46 | // Originalボタンのテキスト
47 | ORIGINAL_BUTTON_TEXT_OPTION_KEY: INITIAL_ORIGINAL_BUTTON_TEXT,
48 | };
49 |
50 | export const OPTION_KEYS = [
51 | SHOW_ON_TIMELINE,
52 | SHOW_ON_TWEET_DETAIL,
53 | SHOW_ON_TWEETDECK_TIMELINE,
54 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
55 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
56 | ] as const;
57 | export const OPTIONS_TEXT: { [key in keyof OptionsBool]: string } = {
58 | // 公式Web
59 | SHOW_ON_TIMELINE: 'タイムライン',
60 | SHOW_ON_TWEET_DETAIL: '(旧表示で)ツイート詳細',
61 | // TweetDeck
62 | SHOW_ON_TWEETDECK_TIMELINE: 'タイムライン',
63 | SHOW_ON_TWEETDECK_TWEET_DETAIL: '(旧表示で)ツイート詳細',
64 | // Originalボタンのテキスト
65 | ORIGINAL_BUTTON_TEXT_OPTION_KEY: 'ボタンのテキスト',
66 | };
67 |
68 | /** 公式Webかどうか */
69 | export const isTwitter = (): boolean =>
70 | window.location.hostname === HOST_TWITTER_COM ||
71 | window.location.hostname === HOST_MOBILE_TWITTER_COM ||
72 | window.location.hostname === HOST_X_COM ||
73 | window.location.hostname === HOST_MOBILE_X_COM;
74 | /** Tweetdeckかどうか */
75 | export const isTweetdeck = (): boolean =>
76 | window.location.hostname === HOST_TWEETDECK_TWITTER_COM ||
77 | window.location.hostname === HOST_PRO_TWITTER_COM ||
78 | window.location.hostname === HOST_PRO_X_COM;
79 |
80 | /** Reactビューかどうか */
81 | export const isReactView = (): boolean => !!document.getElementById('react-root');
82 |
83 | /** これ自体がChrome拡張機能かどうか */
84 | export const isNativeChromeExtension = (): boolean => chrome?.runtime?.id !== undefined;
85 |
86 | // chrome.storateへの移行が済んだかどうかのキー
87 | export const MIGRATED_TO_CHROME_STORAGE = 'MIGRATED_TO_CHROME_STORAGE';
88 |
--------------------------------------------------------------------------------
/src/extension-contexts/background.ts:
--------------------------------------------------------------------------------
1 | import { GET_LOCAL_STORAGE } from '../constants';
2 | import type { MessageRequest, MessageResponseBool } from '../utils';
3 | import { getOptions } from './options';
4 |
5 | // バックグラウンドで実行される
6 |
7 | chrome.runtime.onMessage.addListener((request: MessageRequest, _, sendResponse: (res: MessageResponseBool) => void) => {
8 | // console.log(chrome.runtime.lastError);
9 | if (request.method === GET_LOCAL_STORAGE) {
10 | getOptions().then((options) => {
11 | sendResponse({ data: options });
12 | });
13 | } else {
14 | sendResponse({ data: null });
15 | }
16 | return true;
17 | });
18 |
--------------------------------------------------------------------------------
/src/extension-contexts/options.ts:
--------------------------------------------------------------------------------
1 | import { OPTION_KEYS, type OptionsBool, initialOptionsBool } from '../constants';
2 |
3 | export const setOptions = (options: OptionsBool, callback?: () => void): void => {
4 | chrome.storage.sync.set(options, () => {
5 | console.log('options set');
6 | if (callback) {
7 | callback();
8 | }
9 | });
10 | };
11 |
12 | export const getOptions = (): Promise => {
13 | return new Promise((resolve) => {
14 | chrome.storage.sync.get(OPTION_KEYS, (got) => {
15 | // 初期値をフォールバックとしておく
16 | resolve({ ...initialOptionsBool, ...got });
17 | });
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/src/extension-contexts/popup.tsx:
--------------------------------------------------------------------------------
1 | import React, { type ChangeEvent, useCallback, useState } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import {
4 | HOST_MOBILE_TWITTER_COM,
5 | HOST_MOBILE_X_COM,
6 | HOST_PRO_TWITTER_COM,
7 | HOST_PRO_X_COM,
8 | HOST_TWEETDECK_TWITTER_COM,
9 | HOST_TWITTER_COM,
10 | HOST_X_COM,
11 | OPTIONS_TEXT,
12 | OPTION_KEYS,
13 | OPTION_UPDATED,
14 | ORIGINAL_BUTTON_TEXT_OPTION_KEY,
15 | type OptionsBool,
16 | SHOW_ON_TIMELINE,
17 | SHOW_ON_TWEETDECK_TIMELINE,
18 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
19 | SHOW_ON_TWEET_DETAIL,
20 | } from '../constants';
21 | import { printException } from '../utils';
22 | import { getOptions, setOptions } from './options';
23 |
24 | /* popup.js */
25 | // ツールバー右に表示される拡張機能のボタンをクリック、または
26 | // [設定]->[拡張機能]の[オプション]から出る設定画面
27 |
28 | interface Props {
29 | optionsText: { [key: string]: string };
30 | optionKeys: typeof OPTION_KEYS;
31 | optionsEnabled: OptionsBool;
32 | }
33 |
34 | export const Popup = (props: Props): JSX.Element => {
35 | const { optionsText, optionKeys, optionsEnabled } = props;
36 | const [enabled, setEnabled] = useState(optionsEnabled);
37 | const [saveButtonText, setSaveButtonText] = useState('設定を保存');
38 |
39 | const onOriginalButtonTextInputChange = useCallback(
40 | (event: ChangeEvent) => {
41 | setEnabled({ ...enabled, [ORIGINAL_BUTTON_TEXT_OPTION_KEY]: event.target.value });
42 | },
43 | [enabled],
44 | );
45 |
46 | const onSave = useCallback(() => {
47 | setOptions(enabled, () => {
48 | setSaveButtonText('しました');
49 | setTimeout(() => {
50 | setSaveButtonText('設定を保存');
51 | }, 500);
52 | });
53 | chrome.tabs.query({}, (result) =>
54 | result.forEach((tab) => {
55 | // console.log(tab);
56 | if (!(tab.url && tab.id)) {
57 | return;
58 | }
59 | const tabUrl = new URL(tab.url).hostname;
60 | if (
61 | ![
62 | HOST_TWITTER_COM,
63 | HOST_MOBILE_TWITTER_COM,
64 | HOST_TWEETDECK_TWITTER_COM,
65 | HOST_PRO_TWITTER_COM,
66 | HOST_X_COM,
67 | HOST_MOBILE_X_COM,
68 | HOST_PRO_X_COM,
69 | ].some((url) => url === tabUrl)
70 | ) {
71 | // 送り先タブが拡張機能が動作する対象ではないならメッセージを送らない
72 | return;
73 | }
74 | chrome.tabs.sendMessage(tab.id, { method: OPTION_UPDATED }, (response) => {
75 | // eslint-disable-next-line no-console
76 | console.log('res:', response);
77 | });
78 | }),
79 | );
80 | }, [enabled]);
81 |
82 | const renderCheckboxItem = (key: keyof Omit) => (
83 |
84 |
85 | {
92 | setEnabled(Object.assign({ ...enabled }, { [key]: !enabled[key] }));
93 | }}
94 | />
95 |
96 |
97 |
100 |
101 |
102 | );
103 |
104 | return (
105 |
111 |
112 | Options - 設定
113 |
114 |
115 |
120 |
125 |
126 |
129 |
130 |
138 |
139 |
140 |
141 |
148 |
149 |
150 | );
151 | };
152 |
153 | getOptions().then((optionsEnabled) => {
154 | const props = {
155 | optionsText: OPTIONS_TEXT,
156 | optionKeys: OPTION_KEYS,
157 | optionsEnabled,
158 | };
159 |
160 | let root = document.getElementById('root');
161 | if (!root) {
162 | root = document.createElement('div');
163 | root.id = 'root';
164 | const body = document.querySelector('body');
165 | if (body) {
166 | body.appendChild(root);
167 | } else {
168 | printException('cant find body');
169 | }
170 | }
171 | ReactDOM.render(, document.getElementById('root'));
172 | });
173 |
--------------------------------------------------------------------------------
/src/input.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { isTweetdeck, isTwitter } from './constants';
2 | import { setOriginalButton, updateOptions } from './utils';
3 |
4 | updateOptions().then((options) => {
5 | if (isTwitter() || isTweetdeck()) {
6 | /** 公式Web/TweetDeck */
7 | setOriginalButton(options);
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { ButtonSetter, type ButtonSetterType } from './ButtonSetter';
2 | import { ButtonSetterTweetDeck } from './ButtonSetterTweetDeck';
3 | import {
4 | GET_LOCAL_STORAGE,
5 | OPTION_UPDATED,
6 | type OptionsBool,
7 | initialOptionsBool,
8 | isNativeChromeExtension,
9 | isReactView,
10 | isTweetdeck,
11 | } from './constants';
12 |
13 | /** chrome.runtime.sendMessage で送るメッセージ */
14 | export interface MessageRequest {
15 | method: string;
16 | }
17 | /** chrome.runtime.sendMessage で返るメッセージ(真偽値版) */
18 | export interface MessageResponseBool {
19 | data: OptionsBool | null;
20 | }
21 | /** chrome.runtime.sendMessage で返るメッセージ */
22 | export interface MessageResponse {
23 | data: { [key: string]: string } | null;
24 | }
25 |
26 | /** エラーメッセージの表示(予期せぬ状況の確認) */
27 | export const printException = (tooiException: string): void => {
28 | try {
29 | throw new Error(`tooi: ${tooiException} at: ${window.location.href}`);
30 | } catch (err) {
31 | // eslint-disable-next-line no-console
32 | console.log(err);
33 | }
34 | };
35 |
36 | /** 画像urlの要素を集める */
37 | export const collectUrlParams = (
38 | rawUrl: string,
39 | ): {
40 | protocol: string;
41 | host: string;
42 | pathname: string;
43 | format: string;
44 | name: string | null;
45 | } | null => {
46 | if (!/https:\/\/pbs\.twimg\.com\/media/.test(rawUrl)) {
47 | // twitterの画像URLでないときnull
48 | return null;
49 | }
50 |
51 | const url = new URL(rawUrl);
52 | const searchSet: {
53 | format: string;
54 | name: string | null;
55 | } = {
56 | format: 'jpg', // 拡張子が無い場合はjpgにフォールバック
57 | name: null, // 大きさ指定がない場合はnull
58 | };
59 | url.search
60 | .slice(1)
61 | .split('&')
62 | .forEach((set) => {
63 | const [key, value] = set.split('=');
64 | if (key === 'format' || key === 'name') {
65 | searchSet[key] = value;
66 | }
67 | });
68 |
69 | // 空文字でもどんな文字列でもマッチする正規表現なのでnon-null
70 | const matched = url.pathname.match(/^(.*?)(?:|\.([^.:]+))(?:|:([a-z]+))$/)!;
71 | // どんな文字列でも空文字は最低入るのでnon-null
72 | const pathnameWithoutExtension = matched[1]!;
73 | // 拡張子はないかもしれないのでundefinedも示しておく
74 | const extension = matched[2] as string | undefined;
75 | // コロンを使う大きさ指定はないかもしれないのでなかったらnull
76 | const legacyName = matched[3] || null;
77 |
78 | return {
79 | protocol: url.protocol,
80 | host: url.host,
81 | pathname: pathnameWithoutExtension,
82 | // 2.1.11時点ではクエリパラメータを使うのはTweetDeckのみ
83 | // TweetDeckのURLでは拡張子を優先する
84 | // ref: https://hogashi.hatenablog.com/entry/2018/08/15/042044
85 | format: extension || searchSet.format,
86 | name: searchSet.name || legacyName,
87 | };
88 | };
89 |
90 | /** 画像URLを https~?format=〜&name=orig に揃える */
91 | export const formatUrl = (imgUrl: string): string | null => {
92 | if (imgUrl.length === 0) {
93 | return null;
94 | }
95 |
96 | const params = collectUrlParams(imgUrl);
97 | if (!params) {
98 | // twitterの画像URLでないときそのまま返す
99 | return imgUrl;
100 | }
101 |
102 | const { protocol, host, pathname, format } = params;
103 | // webpの場合name=origが見られないので, 見られるname=4096x4096にする
104 | // TODO: 対処を整理する
105 | if (format === 'webp') {
106 | return `${protocol}//${host}${pathname}?format=${format}&name=4096x4096`;
107 | }
108 | return `${protocol}//${host}${pathname}?format=${format}&name=orig`;
109 | };
110 |
111 | /** 画像を開く */
112 | export const openImages = (imgSrcs: string[]): void => {
113 | if (imgSrcs.length === 0) {
114 | printException('zero image urls');
115 | return;
116 | }
117 | Array.from(imgSrcs)
118 | .reverse() // 逆順に開くことで右側のタブから読める
119 | .forEach((imgSrc) => {
120 | // if 画像URLが取得できたなら
121 | const url = formatUrl(imgSrc);
122 | if (url) {
123 | window.open(url);
124 | } else {
125 | printException('no image url');
126 | }
127 | });
128 | };
129 |
130 | /**
131 | * エレメントにスタイル当てる
132 | * @param {HTMLElement} element スタイル当てる対象エレメント
133 | * @param {Object} propertySet プロパティ名('font-size')と値('10px')のオブジェクト
134 | */
135 | export const setStyle = (element: HTMLElement, propertySet: { [key: string]: string }): void => {
136 | Object.entries(propertySet).forEach(([key, value]) => element.style.setProperty(key, value));
137 | };
138 |
139 | export const onOriginalButtonClick = (e: MouseEvent, imgSrcs: string[]): void => {
140 | // イベント(MouseEvent)による既定の動作をキャンセル
141 | e.preventDefault();
142 | // イベント(MouseEvent)の親要素への伝播を停止
143 | e.stopPropagation();
144 |
145 | openImages(imgSrcs);
146 | };
147 |
148 | export const getImageFilenameByUrl = (imgUrl: string): string | null => {
149 | const params = collectUrlParams(imgUrl);
150 | if (!params) {
151 | return null;
152 | }
153 |
154 | const { pathname, format, name } = params;
155 | const basename = pathname.match(/([^/.]*)$/)![1];
156 |
157 | return `${basename}${name ? `-${name}` : ''}.${format}`;
158 | };
159 |
160 | export const downloadImage = (e: KeyboardEvent): void => {
161 | // if 押されたキーがC-s の状態なら
162 | // かつ 開いているURLが画像URLの定形なら(pbs.twimg.comを使うものは他にも存在するので)
163 | if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
164 | const imageSrc = document.querySelector('img')!.src;
165 | const filename = getImageFilenameByUrl(imageSrc);
166 | if (!filename) {
167 | return;
168 | }
169 | // もとの挙動(ブラウザが行う保存)をしないよう中止
170 | e.preventDefault();
171 |
172 | // download属性に正しい拡張子の画像名を入れたaタグをつくってクリックする
173 | const a = document.createElement('a');
174 | a.href = window.location.href;
175 | a.setAttribute('download', filename);
176 | a.dispatchEvent(new MouseEvent('click'));
177 | }
178 | };
179 |
180 | export const getButtonSetter = (): ButtonSetterType => {
181 | // 新しいTweetDeckは公式Webと同じReactビュー
182 | // 古いTweetDeckのときだけ専用クラスを使う
183 | if (isTweetdeck() && !isReactView()) {
184 | return new ButtonSetterTweetDeck();
185 | }
186 | return new ButtonSetter();
187 | };
188 |
189 | /**
190 | * 設定項目更新
191 | * background script に問い合わせて返ってきた値で options (真偽値) をつくって返す
192 | */
193 | export const updateOptions = (): Promise => {
194 | // これ自体はChrome拡張機能でない(UserScriptとして読み込まれている)とき
195 | // 設定は変わりようがないので何もしない
196 | if (!isNativeChromeExtension()) {
197 | return Promise.resolve(initialOptionsBool);
198 | }
199 | return new Promise((resolve) => {
200 | const request: MessageRequest = {
201 | method: GET_LOCAL_STORAGE,
202 | };
203 | const callback = (response: MessageResponseBool): void => {
204 | // 何かおかしくて設定内容取ってこれなかったらデフォルトということにする
205 | resolve(response?.data ? response.data : initialOptionsBool);
206 | };
207 | chrome.runtime.sendMessage(request, callback);
208 | });
209 | };
210 |
211 | /** Originalボタンおく */
212 | export const setOriginalButton = (options: OptionsBool): void => {
213 | // 実行の間隔(ms)
214 | const INTERVAL = 300;
215 |
216 | // ボタン設置クラス
217 | const buttonSetter = getButtonSetter();
218 |
219 | // ボタンを設置
220 | const setButton = (currentOptions: OptionsBool): void => {
221 | // console.log('setButton: ' + currentOptions['SHOW_ON_TIMELINE'] + ' ' + currentOptions['SHOW_ON_TWEET_DETAIL']) // debug
222 | buttonSetter.setButtonOnTimeline(currentOptions);
223 | buttonSetter.setButtonOnTweetDetail(currentOptions);
224 | };
225 |
226 | let isInterval = false;
227 | let deferred = false;
228 | const setButtonWithInterval = (currentOptions: OptionsBool): void => {
229 | // 短時間に何回も実行しないようインターバルを設ける
230 | if (isInterval) {
231 | deferred = true;
232 | return;
233 | }
234 | isInterval = true;
235 | setTimeout(() => {
236 | isInterval = false;
237 | if (deferred) {
238 | setButton(currentOptions);
239 | deferred = false;
240 | }
241 | }, INTERVAL);
242 |
243 | setButton(currentOptions);
244 | };
245 |
246 | // ボタンを(再)設置
247 | setButtonWithInterval(options);
248 |
249 | // ページ全体でDOMの変更を検知し都度ボタン設置
250 | const observer = new MutationObserver(() => setButtonWithInterval(options));
251 | const target = document.querySelector('body')!;
252 | const config = { childList: true, subtree: true };
253 | observer.observe(target, config);
254 |
255 | // 設定反映のためのリスナー設置
256 | // これ自体がChrome拡張機能のときだけ設置する
257 | // (Chrome拡張機能でないときは設定反映できる機構ないので)
258 | if (isNativeChromeExtension()) {
259 | chrome.runtime.onMessage.addListener((request, _, sendResponse) => {
260 | // Unchecked runtime.lastError みたいなエラーが出ることがあるので,
261 | // ひとまず console.log で出すようにしてみている
262 | if (chrome.runtime.lastError !== undefined) {
263 | // eslint-disable-next-line no-console
264 | console.log(chrome.runtime.lastError);
265 | }
266 | if (request.method === OPTION_UPDATED) {
267 | updateOptions().then((options) => {
268 | // ボタンを(再)設置
269 | setButtonWithInterval(options);
270 | sendResponse({ data: 'done' });
271 | });
272 | return true;
273 | }
274 | sendResponse({ data: 'yet' });
275 | return true;
276 | });
277 | }
278 | };
279 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.tsx"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/tooi-forGreaseTamperMonkey.user.js:
--------------------------------------------------------------------------------
1 | // ==UserScript==
2 | // @author hogashi
3 | // @name twitterOpenOriginalImage
4 | // @namespace https://hogashi.hatenablog.com/
5 | // @description TwitterページでOriginalボタンを押すと原寸の画像が開きます(https://hogashi.hatenablog.com)
6 | // @include https://twitter.com*
7 | // @include https://mobile.twitter.com*
8 | // @include https://tweetdeck.twitter.com*
9 | // @include https://pbs.twimg.com/media*
10 | // @version 3.2.3
11 | // ==/UserScript==
12 |
13 | /**
14 | * userjs 用の設定項目
15 | * 'isfalse' とすると、その設定がオフになる
16 | */
17 | var userjsOptions = {
18 | // 公式Web
19 | SHOW_ON_TIMELINE: 'istrue',
20 | SHOW_ON_TWEET_DETAIL: 'istrue',
21 | // TweetDeck
22 | SHOW_ON_TWEETDECK_TIMELINE: 'istrue',
23 | SHOW_ON_TWEETDECK_TWEET_DETAIL: 'istrue',
24 | // 画像ページ
25 | STRIP_IMAGE_SUFFIX: 'istrue'
26 | };
27 | /**
28 | * Constants
29 | */
30 | // 定数
31 | // 設定取得メッセージ
32 | var OPTION_UPDATED = 'OPTION_UPDATED';
33 | var GET_LOCAL_STORAGE = 'GET_LOCAL_STORAGE';
34 | // 公式Web
35 | var HOST_TWITTER_COM = 'twitter.com';
36 | var HOST_MOBILE_TWITTER_COM = 'mobile.twitter.com';
37 | var SHOW_ON_TIMELINE = 'SHOW_ON_TIMELINE';
38 | var SHOW_ON_TWEET_DETAIL = 'SHOW_ON_TWEET_DETAIL';
39 | // TweetDeck
40 | var HOST_TWEETDECK_TWITTER_COM = 'tweetdeck.twitter.com';
41 | var SHOW_ON_TWEETDECK_TIMELINE = 'SHOW_ON_TWEETDECK_TIMELINE';
42 | var SHOW_ON_TWEETDECK_TWEET_DETAIL = 'SHOW_ON_TWEETDECK_TWEET_DETAIL';
43 | // 画像ページ
44 | var HOST_PBS_TWIMG_COM = 'pbs.twimg.com';
45 | var STRIP_IMAGE_SUFFIX = 'STRIP_IMAGE_SUFFIX';
46 | /** 公式Webかどうか */
47 | var isTwitter = function () {
48 | return window.location.hostname === HOST_TWITTER_COM ||
49 | window.location.hostname === HOST_MOBILE_TWITTER_COM;
50 | };
51 | /** Tweetdeckかどうか */
52 | var isTweetdeck = function () {
53 | return window.location.hostname === HOST_TWEETDECK_TWITTER_COM;
54 | };
55 | /** 画像ページかどうか */
56 | var isImageTab = function () {
57 | return window.location.hostname === HOST_PBS_TWIMG_COM;
58 | };
59 | /** これ自体がChrome拡張機能かどうか */
60 | var isNativeChromeExtension = function () {
61 | return window.chrome !== undefined &&
62 | window.chrome.runtime !== undefined &&
63 | window.chrome.runtime.id !== undefined;
64 | };
65 | // 設定
66 | // 設定に使う真偽値
67 | var isTrue = 'istrue';
68 | var isFalse = 'isfalse';
69 | var OPTION_KEYS = [
70 | SHOW_ON_TIMELINE,
71 | SHOW_ON_TWEET_DETAIL,
72 | SHOW_ON_TWEETDECK_TIMELINE,
73 | SHOW_ON_TWEETDECK_TWEET_DETAIL,
74 | STRIP_IMAGE_SUFFIX,
75 | ];
76 | var OPTIONS_TEXT = {
77 | // 公式Web
78 | SHOW_ON_TIMELINE: 'タイムラインにボタンを表示',
79 | SHOW_ON_TWEET_DETAIL: 'ツイート詳細にボタンを表示',
80 | // TweetDeck
81 | SHOW_ON_TWEETDECK_TIMELINE: 'タイムラインにボタンを表示',
82 | SHOW_ON_TWEETDECK_TWEET_DETAIL: 'ツイート詳細にボタンを表示',
83 | // 画像ページ
84 | STRIP_IMAGE_SUFFIX: '[Ctrl]+[s]で拡張子を校正'
85 | };
86 | /** エラーメッセージの表示(予期せぬ状況の確認) */
87 | var printException = function (tooiException) {
88 | try {
89 | throw new Error('tooi: ' + tooiException + ' at: ' + window.location.href);
90 | }
91 | catch (err) {
92 | // eslint-disable-next-line no-console
93 | console.log(err);
94 | }
95 | };
96 | /** 画像urlの要素を集める */
97 | var collectUrlParams = function (rawUrl) {
98 | if (!/https:\/\/pbs\.twimg\.com\/media/.test(rawUrl)) {
99 | // twitterの画像URLでないときnull
100 | return null;
101 | }
102 | var url = new URL(rawUrl);
103 | var searchSet = {
104 | format: 'jpg',
105 | name: null
106 | };
107 | url.search
108 | .slice(1)
109 | .split('&')
110 | .forEach(function (set) {
111 | var _a = set.split('='), key = _a[0], value = _a[1];
112 | if (key === 'format' || key === 'name') {
113 | searchSet[key] = value;
114 | }
115 | });
116 | // 空文字でもどんな文字列でもマッチする正規表現なのでnon-null
117 | var matched = url.pathname.match(/^(.*?)(?:|\.([^.:]+))(?:|:([a-z]+))$/);
118 | // どんな文字列でも空文字は最低入るのでnon-null
119 | var pathnameWithoutExtension = matched[1];
120 | // 拡張子はないかもしれないのでundefinedも示しておく
121 | var extension = matched[2];
122 | // コロンを使う大きさ指定はないかもしれないのでなかったらnull
123 | var legacyName = matched[3] || null;
124 | return {
125 | protocol: url.protocol,
126 | host: url.host,
127 | pathname: pathnameWithoutExtension,
128 | // 2.1.11時点ではクエリパラメータを使うのはTweetDeckのみ
129 | // TweetDeckのURLでは拡張子を優先する
130 | // ref: https://hogashi.hatenablog.com/entry/2018/08/15/042044
131 | format: extension || searchSet.format,
132 | name: searchSet.name || legacyName
133 | };
134 | };
135 | /** 画像URLを https~?format=〜&name=orig に揃える */
136 | var formatUrl = function (imgUrl) {
137 | if (imgUrl.length === 0) {
138 | return null;
139 | }
140 | var params = collectUrlParams(imgUrl);
141 | if (!params) {
142 | // twitterの画像URLでないときそのまま返す
143 | return imgUrl;
144 | }
145 | var protocol = params.protocol, host = params.host, pathname = params.pathname, format = params.format;
146 | return "".concat(protocol, "//").concat(host).concat(pathname, "?format=").concat(format, "&name=orig");
147 | };
148 | /** 画像を開く */
149 | var openImages = function (imgSrcs) {
150 | if (imgSrcs.length === 0) {
151 | printException('zero image urls');
152 | return;
153 | }
154 | Array.from(imgSrcs)
155 | .reverse() // 逆順に開くことで右側のタブから読める
156 | .forEach(function (imgSrc) {
157 | // if 画像URLが取得できたなら
158 | var url = formatUrl(imgSrc);
159 | if (url) {
160 | window.open(url);
161 | }
162 | else {
163 | printException('no image url');
164 | }
165 | });
166 | };
167 | /**
168 | * エレメントにスタイル当てる
169 | * @param {HTMLElement} element スタイル当てる対象エレメント
170 | * @param {Object} propertySet プロパティ名('font-size')と値('10px')のオブジェクト
171 | */
172 | var setStyle = function (element, propertySet) {
173 | Object.entries(propertySet).forEach(function (_a) {
174 | var key = _a[0], value = _a[1];
175 | return element.style.setProperty(key, value);
176 | });
177 | };
178 | var onOriginalButtonClick = function (e, imgSrcs) {
179 | // イベント(MouseEvent)による既定の動作をキャンセル
180 | e.preventDefault();
181 | // イベント(MouseEvent)の親要素への伝播を停止
182 | e.stopPropagation();
183 | openImages(imgSrcs);
184 | };
185 | var getImageFilenameByUrl = function (imgUrl) {
186 | var params = collectUrlParams(imgUrl);
187 | if (!params) {
188 | return null;
189 | }
190 | var pathname = params.pathname, format = params.format, name = params.name;
191 | var basename = pathname.match(/([^/.]*)$/)[1];
192 | return "".concat(basename).concat(name ? "-".concat(name) : '', ".").concat(format);
193 | };
194 | var downloadImage = function (e) {
195 | // if 押されたキーがC-s の状態なら
196 | // かつ 開いているURLが画像URLの定形なら(pbs.twimg.comを使うものは他にも存在するので)
197 | if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
198 | var imageSrc = document.querySelector('img').src;
199 | var filename = getImageFilenameByUrl(imageSrc);
200 | if (!filename) {
201 | return;
202 | }
203 | // もとの挙動(ブラウザが行う保存)をしないよう中止
204 | e.preventDefault();
205 | // download属性に正しい拡張子の画像名を入れたaタグをつくってクリックする
206 | var a = document.createElement('a');
207 | a.href = window.location.href;
208 | a.setAttribute('download', filename);
209 | a.dispatchEvent(new MouseEvent('click'));
210 | }
211 | };
212 | /**
213 | * twitter.comでボタンを設置するクラス
214 | */
215 | var ButtonSetter = /** @class */ (function () {
216 | function ButtonSetter() {
217 | }
218 | // タイムラインにボタン表示
219 | ButtonSetter.prototype.setButtonOnTimeline = function (currentOptions) {
220 | // 昔のビューの処理はしばらく残す
221 | // ref: https://github.com/hogashi/twitterOpenOriginalImage/issues/32#issuecomment-578510155
222 | if (document.querySelector('#react-root')) {
223 | this._setButtonOnReactLayoutTimeline(currentOptions);
224 | return;
225 | }
226 | this._setButtonOnTimeline(currentOptions);
227 | };
228 | // ツイート詳細にボタン表示
229 | ButtonSetter.prototype.setButtonOnTweetDetail = function (currentOptions) {
230 | // 昔のビューの処理はしばらく残す
231 | // TODO: Reactレイアウトでも実装する必要がある?
232 | // ref: https://github.com/hogashi/twitterOpenOriginalImage/issues/32#issuecomment-578510155
233 | this._setButtonOnTweetDetail(currentOptions);
234 | };
235 | ButtonSetter.prototype.setButton = function (_a) {
236 | var className = _a.className, getImgSrcs = _a.getImgSrcs, target = _a.target;
237 | var style = {
238 | width: '70px',
239 | 'font-size': '13px',
240 | color: this.getActionButtonColor()
241 | };
242 | /* つくるDOMは以下 */
243 | /*
244 |
245 | {
251 | onOriginalButtonClick(e, imgSrcs);
252 | }}
253 | />
254 |
255 | */
256 | var button = document.createElement('input');
257 | button.className = 'tooi-button';
258 | setStyle(button, style);
259 | button.type = 'button';
260 | button.value = 'Original';
261 | button.addEventListener('click', function (e) {
262 | onOriginalButtonClick(e, getImgSrcs());
263 | });
264 | var container = document.createElement('div');
265 | container.classList.add('ProfileTweet-action', className);
266 | target.appendChild(container);
267 | container.appendChild(button);
268 | };
269 | ButtonSetter.prototype.setReactLayoutButton = function (_a) {
270 | var className = _a.className, getImgSrcs = _a.getImgSrcs, target = _a.target;
271 | var button = document.createElement('input');
272 | button.type = 'button';
273 | button.value = 'Original';
274 | var color = this.getReactLayoutActionButtonColor();
275 | setStyle(button, {
276 | 'font-size': '13px',
277 | padding: '4px 8px',
278 | color: color,
279 | 'background-color': 'rgba(0, 0, 0, 0)',
280 | border: "1px solid ".concat(color),
281 | 'border-radius': '3px',
282 | cursor: 'pointer'
283 | });
284 | button.addEventListener('click', function (e) {
285 | onOriginalButtonClick(e, getImgSrcs());
286 | });
287 | var container = document.createElement('div');
288 | // container.id = '' + tweet.id
289 | container.classList.add(className);
290 | setStyle(container, {
291 | display: 'flex',
292 | 'margin-left': '20px',
293 | 'flex-flow': 'column',
294 | 'justify-content': 'center'
295 | });
296 | target.appendChild(container);
297 | container.appendChild(button);
298 | };
299 | ButtonSetter.prototype._setButtonOnTimeline = function (currentOptions) {
300 | var _this = this;
301 | // タイムラインにボタン表示する設定がされているときだけ実行する
302 | // - isTrue か 設定なし のとき ON
303 | // - isFalse のとき OFF
304 | if (!(currentOptions[SHOW_ON_TIMELINE] !== isFalse)) {
305 | return;
306 | }
307 | var tweets = document.getElementsByClassName('js-stream-tweet');
308 | if (!tweets.length) {
309 | return;
310 | }
311 | var className = 'tooi-button-container-timeline';
312 | // 各ツイートに対して
313 | Array.from(tweets).forEach(function (tweet) {
314 | // 画像ツイートかつまだ処理を行っていないときのみ行う
315 | if (!(tweet.getElementsByClassName('AdaptiveMedia-container').length !==
316 | 0 &&
317 | tweet
318 | .getElementsByClassName('AdaptiveMedia-container')[0]
319 | .getElementsByTagName('img').length !== 0) ||
320 | tweet.getElementsByClassName(className).length !== 0) {
321 | return;
322 | }
323 | // 操作ボタンの外側は様式にあわせる
324 | var actionList = tweet.querySelector('.ProfileTweet-actionList');
325 | if (!actionList) {
326 | printException('no target');
327 | return;
328 | }
329 | // 画像の親が取得できたら
330 | var mediaContainer = tweet.getElementsByClassName('AdaptiveMedia-container')[0];
331 | var getImgSrcs = function () {
332 | return Array.from(mediaContainer.getElementsByClassName('AdaptiveMedia-photoContainer')).map(function (element) { return element.getElementsByTagName('img')[0].src; });
333 | };
334 | _this.setButton({
335 | className: className,
336 | getImgSrcs: getImgSrcs,
337 | target: actionList
338 | });
339 | });
340 | };
341 | ButtonSetter.prototype._setButtonOnTweetDetail = function (currentOptions) {
342 | // ツイート詳細にボタン表示する設定がされているときだけ実行する
343 | // - isTrue か 設定なし のとき ON
344 | // - isFalse のとき OFF
345 | if (!(currentOptions[SHOW_ON_TWEET_DETAIL] !== isFalse)) {
346 | return;
347 | }
348 | var className = 'tooi-button-container-detail';
349 | if (!document.getElementsByClassName('permalink-tweet-container')[0] ||
350 | !document
351 | .getElementsByClassName('permalink-tweet-container')[0]
352 | .getElementsByClassName('AdaptiveMedia-photoContainer')[0] ||
353 | document.getElementsByClassName(className).length !== 0) {
354 | // ツイート詳細ページでない、または、メインツイートが画像ツイートでないとき
355 | // または、すでに処理を行ってあるとき
356 | // 何もしない
357 | return;
358 | }
359 | // Originalボタンの親の親となる枠
360 | var actionList = document.querySelector('.permalink-tweet-container .ProfileTweet-actionList');
361 | if (!actionList) {
362 | printException('no target');
363 | return;
364 | }
365 | // .AdaptiveMedia-photoContainer: 画像のエレメントからURLを取得する
366 | var getImgSrcs = function () {
367 | return Array.from(document
368 | .getElementsByClassName('permalink-tweet-container')[0]
369 | .getElementsByClassName('AdaptiveMedia-photoContainer')).map(function (element) { return element.getElementsByTagName('img')[0].src; });
370 | };
371 | this.setButton({
372 | className: className,
373 | getImgSrcs: getImgSrcs,
374 | target: actionList
375 | });
376 | };
377 | ButtonSetter.prototype._setButtonOnReactLayoutTimeline = function (currentOptions) {
378 | var _this = this;
379 | // ツイート詳細にボタン表示する設定がされているときだけ実行する
380 | // - isTrue か 設定なし のとき ON
381 | // - isFalse のとき OFF
382 | if (!(currentOptions[SHOW_ON_TIMELINE] !== isFalse)) {
383 | return;
384 | }
385 | var className = 'tooi-button-container-react-timeline';
386 | var tweets = Array.from(document.querySelectorAll('#react-root main section article'));
387 | if (!tweets.length) {
388 | return;
389 | }
390 | // 各ツイートに対して
391 | tweets.forEach(function (tweet) {
392 | // 画像ツイート かつ 画像が1枚でもある かつ まだ処理を行っていないときのみ実行
393 | var tweetATags = Array.from(tweet.querySelectorAll('a')).filter(function (aTag) {
394 | return /\/status\/[0-9]+\/photo\//.test(aTag.href);
395 | });
396 | if (tweetATags.length === 0 ||
397 | tweetATags.every(function (aTag) { return !aTag.querySelector('img'); }) ||
398 | tweet.getElementsByClassName(className)[0]) {
399 | return;
400 | }
401 | // ボタンを設置
402 | // 操作ボタンの外側は様式にあわせる
403 | var target = tweet.querySelector('div[role="group"]');
404 | if (!target) {
405 | printException('no target');
406 | return;
407 | }
408 | var getImgSrcs = function () {
409 | var tweetImgs = tweetATags.map(function (aTag) { return aTag.querySelector('img'); });
410 | if (tweetImgs.length === 4) {
411 | // 4枚のとき2枚目と3枚目のDOMの順序が前後するので直す
412 | var tweetimgTmp = tweetImgs[1];
413 | tweetImgs[1] = tweetImgs[2];
414 | tweetImgs[2] = tweetimgTmp;
415 | }
416 | return tweetImgs
417 | .map(function (img) {
418 | // filter で string[] にするためにここで string[] にする……
419 | return img ? img.src : '';
420 | })
421 | .filter(function (src) { return src != ''; });
422 | };
423 | _this.setReactLayoutButton({
424 | className: className,
425 | getImgSrcs: getImgSrcs,
426 | target: target
427 | });
428 | });
429 | };
430 | ButtonSetter.prototype.getActionButtonColor = function () {
431 | // コントラスト比4.5(chromeの推奨する最低ライン)の色
432 | var contrastLimitColor = '#697b8c';
433 | var actionButton = document.querySelector('.ProfileTweet-actionButton');
434 | if (!(actionButton && actionButton.style)) {
435 | return contrastLimitColor;
436 | }
437 | var buttonColor = window.getComputedStyle(actionButton).color;
438 | if (buttonColor && buttonColor.length > 0) {
439 | return buttonColor;
440 | }
441 | return contrastLimitColor;
442 | };
443 | ButtonSetter.prototype.getReactLayoutActionButtonColor = function () {
444 | // 文字色
445 | // 初期値: コントラスト比4.5(chromeの推奨する最低ライン)の色
446 | var color = '#697b8c';
447 | // ツイートアクション(返信とか)のボタンのクラス(夜間モードか否かでクラス名が違う)
448 | var actionButton = document.querySelector('div[role="group"] div[role="button"]');
449 | if (actionButton &&
450 | actionButton.children[0] &&
451 | actionButton.children[0].style) {
452 | var buttonColor = window.getComputedStyle(actionButton.children[0]).color;
453 | if (buttonColor && buttonColor.length > 0) {
454 | color = buttonColor;
455 | }
456 | }
457 | return color;
458 | };
459 | return ButtonSetter;
460 | }());
461 | /**
462 | * tweetdeck.twitter.comでボタンを設置するクラス
463 | */
464 | var ButtonSetterTweetDeck = /** @class */ (function () {
465 | function ButtonSetterTweetDeck() {
466 | }
467 | // タイムラインにボタン表示
468 | ButtonSetterTweetDeck.prototype.setButtonOnTimeline = function (currentOptions) {
469 | var _this = this;
470 | // タイムラインにボタン表示する設定がされているときだけ実行する
471 | // - isTrue か 設定なし のとき ON
472 | // - isFalse のとき OFF
473 | if (!(currentOptions[SHOW_ON_TWEETDECK_TIMELINE] !== isFalse)) {
474 | return;
475 | }
476 | // if タイムラインのツイートを取得できたら
477 | // is-actionable: タイムラインのみ
478 | var tweets = document.getElementsByClassName('js-stream-item is-actionable');
479 | if (!tweets.length) {
480 | return;
481 | }
482 | var className = 'tooi-button-container-tweetdeck-timeline';
483 | // 各ツイートに対して
484 | Array.from(tweets).forEach(function (tweet) {
485 | if (!tweet.getElementsByClassName('js-media-image-link').length ||
486 | tweet.getElementsByClassName('is-video').length ||
487 | tweet.getElementsByClassName('is-gif').length ||
488 | tweet.getElementsByClassName(className).length) {
489 | // メディアツイートでない
490 | // または メディアが画像でない(動画/GIF)
491 | // または すでにボタンをおいてあるとき
492 | // 何もしない
493 | return;
494 | }
495 | var target = tweet.querySelector('footer');
496 | if (!target) {
497 | // ボタンを置く場所がないとき
498 | // 何もしない
499 | printException('no target');
500 | return;
501 | }
502 | var getImgSrcs = function () {
503 | return Array.from(tweet.getElementsByClassName('js-media-image-link'))
504 | .map(function (element) {
505 | var urlstr = _this.getBackgroundImageUrl(element);
506 | // filter で string[] にするためにここで string[] にする……
507 | return urlstr ? urlstr : '';
508 | })
509 | .filter(function (urlstr) { return urlstr != ''; });
510 | };
511 | _this.setButton({
512 | className: className,
513 | getImgSrcs: getImgSrcs,
514 | target: target
515 | });
516 | });
517 | };
518 | // ツイート詳細にボタン表示
519 | ButtonSetterTweetDeck.prototype.setButtonOnTweetDetail = function (currentOptions) {
520 | var _this = this;
521 | // ツイート詳細にボタン表示する設定がされているときだけ実行する
522 | // - isTrue か 設定なし のとき ON
523 | // - isFalse のとき OFF
524 | if (!(currentOptions[SHOW_ON_TWEETDECK_TWEET_DETAIL] !== isFalse)) {
525 | return;
526 | }
527 | // if ツイート詳細を取得できたら
528 | var tweets = document.getElementsByClassName('js-tweet-detail');
529 | if (!tweets.length) {
530 | return;
531 | }
532 | var className = 'tooi-button-container-tweetdeck-detail';
533 | // 各ツイートに対して
534 | Array.from(tweets).forEach(function (tweet) {
535 | if ((!tweet.getElementsByClassName('media-img').length &&
536 | !tweet.getElementsByClassName('media-image').length) ||
537 | tweet.getElementsByClassName(className).length) {
538 | // メディアツイートでない (画像のタグが取得できない)
539 | // または すでにボタンをおいてあるとき
540 | // 何もしない
541 | return;
542 | }
543 | var target = tweet.querySelector('footer');
544 | if (!target) {
545 | // ボタンを置く場所がないとき
546 | // 何もしない
547 | printException('no target');
548 | return;
549 | }
550 | var getImgSrcs = function () {
551 | if (tweet.getElementsByClassName('media-img').length !== 0) {
552 | return [
553 | tweet.getElementsByClassName('media-img')[0]
554 | .src,
555 | ];
556 | }
557 | else {
558 | return Array.from(tweet.getElementsByClassName('media-image'))
559 | .map(function (element) {
560 | var urlstr = _this.getBackgroundImageUrl(element);
561 | // filter で string[] にするためにここで string[] にする……
562 | return urlstr ? urlstr : '';
563 | })
564 | .filter(function (urlstr) { return urlstr != ''; });
565 | }
566 | };
567 | _this.setButton({
568 | className: className,
569 | getImgSrcs: getImgSrcs,
570 | target: target
571 | });
572 | });
573 | };
574 | ButtonSetterTweetDeck.prototype.setButton = function (_a) {
575 | var className = _a.className, getImgSrcs = _a.getImgSrcs, target = _a.target;
576 | // 枠線の色は'Original'と同じく'.txt-mute'の色を使うので取得する
577 | var txtMute = document.querySelector('.txt-mute');
578 | var borderColor = txtMute
579 | ? window.getComputedStyle(txtMute).color
580 | : '#697b8c';
581 | // ボタンのスタイル設定
582 | var style = {
583 | border: "1px solid ".concat(borderColor),
584 | 'border-radius': '2px',
585 | display: 'inline-block',
586 | 'font-size': '0.75em',
587 | 'margin-top': '5px',
588 | padding: '1px 1px 0',
589 | 'line-height': '1.5em',
590 | cursor: 'pointer'
591 | };
592 | /* つくるDOMは以下 */
593 | /*
594 | {
598 | onOriginalButtonClick(e, imgSrcs);
599 | }}
600 | >
601 | Original
602 |
603 | */
604 | // tweetdeckのツイート右上の時刻などに使われているclassを使う
605 | // 設置の有無の判別用にclassNameを付与する
606 | var button = document.createElement('a');
607 | button.className = "pull-left margin-txs txt-mute ".concat(className);
608 | setStyle(button, style);
609 | button.addEventListener('click', function (e) {
610 | onOriginalButtonClick(e, getImgSrcs());
611 | });
612 | button.insertAdjacentHTML('beforeend', 'Original');
613 | target.appendChild(button);
614 | };
615 | ButtonSetterTweetDeck.prototype.getBackgroundImageUrl = function (element) {
616 | if (element.style.backgroundImage) {
617 | return element.style.backgroundImage.replace(/url\("?([^"]*)"?\)/, '$1');
618 | }
619 | return null;
620 | };
621 | return ButtonSetterTweetDeck;
622 | }());
623 | var getButtonSetter = function () {
624 | return isTweetdeck() ? new ButtonSetterTweetDeck() : new ButtonSetter();
625 | };
626 | /**
627 | * 設定項目更新
628 | * background script に問い合わせて返ってきた値で options をつくって返す
629 | */
630 | var updateOptions = function () {
631 | // これ自体はChrome拡張機能でない(UserScriptとして読み込まれている)とき
632 | // 設定は変わりようがないので何もしない
633 | if (!isNativeChromeExtension()) {
634 | return Promise.resolve(userjsOptions);
635 | }
636 | return new Promise(function (resolve) {
637 | var request = {
638 | method: GET_LOCAL_STORAGE
639 | };
640 | var callback = function (response) {
641 | // 何かおかしくて設定内容取ってこれなかったらデフォルトということにする
642 | resolve(response && response.data ? response.data : {});
643 | };
644 | window.chrome.runtime.sendMessage(request, callback);
645 | }).then(function (data) {
646 | var newOptions = {};
647 | // ここで全部埋めるので newOptions は Options になる
648 | OPTION_KEYS.forEach(function (key) {
649 | newOptions[key] = data[key] || isTrue;
650 | });
651 | // console.log('get options (then): ', newOptions); // debug
652 | return newOptions;
653 | });
654 | };
655 | /** Originalボタンおく */
656 | var setOriginalButton = function (options) {
657 | // 実行の間隔(ms)
658 | var INTERVAL = 300;
659 | // ボタン設置クラス
660 | var buttonSetter = getButtonSetter();
661 | // ボタンを設置
662 | var setButton = function (currentOptions) {
663 | // console.log('setButton: ' + currentOptions['SHOW_ON_TIMELINE'] + ' ' + currentOptions['SHOW_ON_TWEET_DETAIL']) // debug
664 | buttonSetter.setButtonOnTimeline(currentOptions);
665 | buttonSetter.setButtonOnTweetDetail(currentOptions);
666 | };
667 | var isInterval = false;
668 | var deferred = false;
669 | var setButtonWithInterval = function (currentOptions) {
670 | // 短時間に何回も実行しないようインターバルを設ける
671 | if (isInterval) {
672 | deferred = true;
673 | return;
674 | }
675 | isInterval = true;
676 | setTimeout(function () {
677 | isInterval = false;
678 | if (deferred) {
679 | setButton(currentOptions);
680 | deferred = false;
681 | }
682 | }, INTERVAL);
683 | setButton(currentOptions);
684 | };
685 | // ボタンを(再)設置
686 | setButtonWithInterval(options);
687 | // ページ全体でDOMの変更を検知し都度ボタン設置
688 | var observer = new MutationObserver(function () { return setButtonWithInterval(options); });
689 | var target = document.querySelector('body');
690 | var config = { childList: true, subtree: true };
691 | observer.observe(target, config);
692 | // 設定反映のためのリスナー設置
693 | // これ自体がChrome拡張機能のときだけ設置する
694 | // (Chrome拡張機能でないときは設定反映できる機構ないので)
695 | if (isNativeChromeExtension()) {
696 | window.chrome.runtime.onMessage.addListener(function (request, _, sendResponse) {
697 | // Unchecked runtime.lastError みたいなエラーが出ることがあるので,
698 | // ひとまず console.log で出すようにしてみている
699 | if (window.chrome.runtime.lastError !== undefined) {
700 | // eslint-disable-next-line no-console
701 | console.log(window.chrome.runtime.lastError);
702 | }
703 | if (request.method === OPTION_UPDATED) {
704 | updateOptions().then(function (options) {
705 | // ボタンを(再)設置
706 | setButtonWithInterval(options);
707 | sendResponse({ data: 'done' });
708 | });
709 | return true;
710 | }
711 | sendResponse({ data: 'yet' });
712 | return true;
713 | });
714 | }
715 | };
716 | /**
717 | * twitterの画像を表示したときのC-sを拡張
718 | * 画像のファイル名を「~.jpg-orig」「~.png-orig」ではなく「~-orig.jpg」「~-orig.png」にする
719 | */
720 | var fixFileNameOnSaveCommand = function (options) {
721 | // キーを押したとき
722 | document.addEventListener('keydown', function (e) {
723 | // 設定が有効なら
724 | if (options[STRIP_IMAGE_SUFFIX] !== 'isfalse') {
725 | downloadImage(e);
726 | }
727 | });
728 | };
729 | /**
730 | * メインの処理
731 | * 設定を取得できたらそれに沿ってやっていく
732 | */
733 | updateOptions().then(function (options) {
734 | if (isTwitter() || isTweetdeck()) {
735 | /** 公式Web/TweetDeck */
736 | setOriginalButton(options);
737 | }
738 | else if (isImageTab()) {
739 | /** 画像ページ(https://pbs.twimg.com/*) */
740 | fixFileNameOnSaveCommand(options);
741 | }
742 | });
743 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/recommended/tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist/js/",
5 | // add `dom` over tsconfig/bases
6 | "lib": ["es2020", "dom"],
7 | "jsx": "react"
8 | },
9 | "include": ["./src/", "./__tests__/"]
10 | }
11 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | entry: {
3 | main: './src/main.ts',
4 | background: './src/extension-contexts/background.ts',
5 | popup: './src/extension-contexts/popup.tsx',
6 | },
7 | output: {
8 | filename: '[name].bundle.js',
9 | path: __dirname + '/dist/js',
10 | },
11 |
12 | resolve: {
13 | // Add '.ts' and '.tsx' as resolvable extensions.
14 | extensions: ['.ts', '.tsx', '.js', '.json'],
15 | },
16 |
17 | module: {
18 | rules: [
19 | {
20 | test: /\.tsx?$/,
21 | use: [{ loader: 'babel-loader' }],
22 | exclude: /node_modules/,
23 | },
24 | ],
25 | },
26 |
27 | optimization: {
28 | // no minimize for chrome extension
29 | minimize: false,
30 | },
31 |
32 | // When importing a module whose path matches one of the following, just
33 | // assume a corresponding global variable exists and use that instead.
34 | // This is important because it allows us to avoid bundling all of our
35 | // dependencies, which allows browsers to cache those libraries between builds.
36 | // externals: {
37 | // "react": "React",
38 | // "react-dom": "ReactDOM"
39 | // }
40 | };
41 |
--------------------------------------------------------------------------------