Compare commits
	
		
			87 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | bb6ede2b8f | ||
|   | 822400a1ba | ||
|   | e3e08843f1 | ||
|   | ce0d4f77fa | ||
|   | 94fdb4e974 | ||
|   | 4d425fc8a4 | ||
|   | c6cdfa2f5a | ||
|   | 0fff2e4f16 | ||
|   | 80a2172715 | ||
|   | 5a0a297634 | ||
|   | 948a133b7b | ||
|   | 2ee826c958 | ||
|   | 539409faf8 | ||
|   | 606e46e4d7 | ||
|   | a179cfd69a | ||
|   | d8379253d4 | ||
|   | c3344fbd68 | ||
|   | 4cebd6e84a | ||
|   | 90fbf9dbb0 | ||
|   | d365b9f634 | ||
|   | a2f06acaa4 | ||
|   | 8c90cbcbfb | ||
|   | a4a47772dc | ||
|   | 5dde1f4602 | ||
|   | 9dc0909eeb | ||
|   | 0ed2592e41 | ||
|   | 76cff98220 | ||
|   | 60604b6f51 | ||
|   | f410b7aecb | ||
|   | 1a61f2cee9 | ||
|   | 78a8293520 | ||
|   | 03cfb4fc8d | ||
|   | 144345a359 | ||
|   | fd2c01515e | ||
|   | 219570e08b | ||
|   | 69df556ff5 | ||
|   | 5f4a52574f | ||
|   | 5a1f6c5839 | ||
|   | 91d0342fe8 | ||
|   | 8cc236daf8 | ||
|   | d283ec69f7 | ||
|   | d1aea7596c | ||
|   | c934987b14 | ||
|   | 00c9f4a2e5 | ||
|   | 6605c1d07f | ||
|   | 7325d66c52 | ||
|   | a485061e22 | ||
|   | 1f63f50343 | ||
|   | cd3170dabd | ||
|   | 841cedc5f8 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7f4882734d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e7d647d412 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 913d14a58a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 909272ec3d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7af40ffbbe | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9df79a3ec9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4f2eee06aa | ||
|   | 1b9cf76008 | ||
|   | d035a43ed6 | ||
|   | 95ee9a6e09 | ||
|   | 02a63cdcb3 | ||
|   | f02125dd47 | ||
|   | c11e813146 | ||
|   | a365849048 | ||
|   | a493c9f769 | ||
|   | a13f522b2a | ||
|   | 1ed70b2e2c | ||
|   | 86d5a599b7 | ||
|   | c226fc8d63 | ||
|   | bbf4e1c413 | ||
|   | a24a20a83d | ||
|   | 725600da8f | ||
|   | f74a32ed9b | ||
|   | e08e72dd10 | ||
|   | ce02e1e528 | ||
|   | 0b27d8a717 | ||
|   | 2782e7d26f | ||
|   | 2c83a05e80 | ||
|   | 467f68502a | ||
|   | d95b0dee6b | ||
|   | a1f3323fa5 | ||
|   | 494796a7f0 | ||
|   | 94f2c20d35 | ||
|   | c1deb9438d | ||
|   | ea86527c66 | ||
|   | d1a18fe266 | ||
|   | 737064da82 | 
							
								
								
									
										14
									
								
								.github/ISSUE_TEMPLATE/01_bug-report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/ISSUE_TEMPLATE/01_bug-report.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,30 +1,30 @@ | ||||
| --- | ||||
| name: Bug Report | ||||
| name: 🐛 Bug Report | ||||
| about: Create a report to help us improve | ||||
| title: '' | ||||
| labels: bug | ||||
| labels: ⚠️bug? | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the bug is --> | ||||
|  | ||||
| # Expected Behavior | ||||
| ## Expected Behavior | ||||
|  | ||||
| <!--- Tell us what should happen --> | ||||
|  | ||||
| # Actual Behavior | ||||
| ## Actual Behavior | ||||
|  | ||||
| <!--- Tell us what happens instead of the expected behavior --> | ||||
|  | ||||
| # Steps to Reproduce | ||||
| ## Steps to Reproduce | ||||
|  | ||||
| 1. | ||||
| 2. | ||||
| 3. | ||||
|  | ||||
| # Environment | ||||
| ## Environment | ||||
|  | ||||
| <!-- Tell us where on the platform it happens --> | ||||
|   | ||||
| @@ -1,31 +1,31 @@ | ||||
| --- | ||||
| name: Client-side Bug Report | ||||
| name: 🐛 Bug Report (🖥️Client specific) | ||||
| about: Create a report to help us improve | ||||
| title: '' | ||||
| labels: bug, client-side | ||||
| labels: ⚠️bug?, 🖥️Client | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the bug is --> | ||||
|  | ||||
| # Expected Behavior | ||||
| ## Expected Behavior | ||||
|  | ||||
| <!--- Tell us what should happen --> | ||||
|  | ||||
| # Actual Behavior | ||||
| ## Actual Behavior | ||||
|  | ||||
| <!--- Tell us what happens instead of the expected behavior --> | ||||
|  | ||||
| # Steps to Reproduce | ||||
| ## Steps to Reproduce | ||||
|  | ||||
| 1. | ||||
| 2. | ||||
| 3. | ||||
|  | ||||
| # Environment | ||||
| ## Environment | ||||
|  | ||||
| <!-- Tell us where on the platform it happens --> | ||||
| <!-- e.g. desktop or mobile version, your browser, your OS --> | ||||
|   | ||||
| @@ -1,31 +1,31 @@ | ||||
| --- | ||||
| name: Server-side Bug Report | ||||
| name: 🐛 Bug Report (⚙️Server specific) | ||||
| about: Create a report to help us improve | ||||
| title: '' | ||||
| labels: bug, server-side | ||||
| labels: ⚠️bug?, ⚙️Server | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the bug is --> | ||||
|  | ||||
| # Expected Behavior | ||||
| ## Expected Behavior | ||||
|  | ||||
| <!--- Tell us what should happen --> | ||||
|  | ||||
| # Actual Behavior | ||||
| ## Actual Behavior | ||||
|  | ||||
| <!--- Tell us what happens instead of the expected behavior --> | ||||
|  | ||||
| # Steps to Reproduce | ||||
| ## Steps to Reproduce | ||||
|  | ||||
| 1. | ||||
| 2. | ||||
| 3. | ||||
|  | ||||
| # Environment | ||||
| ## Environment | ||||
|  | ||||
| <!-- Tell us where on the platform it happens --> | ||||
| <!-- e.g. your Node.js version, your OS --> | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/ISSUE_TEMPLATE/11_feature-request.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/ISSUE_TEMPLATE/11_feature-request.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| --- | ||||
| name: Feature Request | ||||
| name: ✨ Feature Request | ||||
| about: Suggest an idea for this project | ||||
| title: '' | ||||
| labels: feature | ||||
| labels: ✨Feature | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the suggestion is --> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| --- | ||||
| name: Client-side Feature Request | ||||
| name: ✨ Feature Request (🖥️Client specific) | ||||
| about: Suggest an idea for this project | ||||
| title: '' | ||||
| labels: client-side, feature | ||||
| labels: ✨Feature, 🖥️Client | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the suggestion is --> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| --- | ||||
| name: Server-side Feature Request | ||||
| name: ✨ Feature Request (⚙️Server specific) | ||||
| about: Suggest an idea for this project | ||||
| title: '' | ||||
| labels: feature, server-side | ||||
| labels: ✨Feature, ⚙️Server | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- Tell us what the suggestion is --> | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +1,4 @@ | ||||
| # Summary | ||||
| ## Summary | ||||
|  | ||||
| <!-- | ||||
|   - | ||||
|   | ||||
							
								
								
									
										38
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,6 +1,44 @@ | ||||
| ChangeLog | ||||
| ========= | ||||
|  | ||||
| 10.92.4 | ||||
| ---------- | ||||
| * リストのエクスポートをできるように | ||||
| * ジョブキューウィジェットを追加 | ||||
| * URLプレビューのサムネイルが表示されないことがある問題を修正 | ||||
|  | ||||
| 10.92.3 | ||||
| ---------- | ||||
| * 管理画面の各種ジョブ数がおかしい問題を修正 | ||||
| * ジョブキューの動作を調整 | ||||
|  | ||||
| 10.92.2 | ||||
| ---------- | ||||
| * 管理画面で各種ジョブ数を一覧できるように | ||||
| * ジョブキューの動作を修正 | ||||
| * notes/children が遅い問題を修正 | ||||
|  | ||||
| 10.92.1 | ||||
| ---------- | ||||
| * アンケートの結果をリモートと同期するように | ||||
| * ジョブキューを有効に | ||||
| * 投稿の返信一覧に引用Renoteも含めるように | ||||
| * robots.txt追加 | ||||
| * デザインの調整 | ||||
|  | ||||
| 10.92.0 | ||||
| ---------- | ||||
| * Mastodonのアンケートに対応 | ||||
| * 複数回答できるアンケートを作成できるように | ||||
| * アンケートに期限を設定できるように | ||||
| * 絵文字ピッカーを改良 | ||||
| * ハッシュタグの判定を改善 | ||||
| * デッキのタグTLで別のタグをクリックしてもTLが変わらない問題を修正 | ||||
| * ユーザーサジェストで表示名が変わらない問題を修正 | ||||
| * UIのバグ修正 | ||||
| * デザインの調整 | ||||
| * など | ||||
|  | ||||
| 10.91.2 | ||||
| ---------- | ||||
| * 10.91.1 で追加した依存関係にXSS脆弱性があったので他のパッケージに差し替え | ||||
|   | ||||
							
								
								
									
										12
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | ||||
| <img src="https://github.com/syuilo/misskey/blob/develop/assets/ai-orig.png?raw=true" align="right" height="320px"/> | ||||
| <a href="https://ai.misskey.xyz/"><img src="https://github.com/syuilo/misskey/blob/develop/assets/ai-orig.png?raw=true" align="right" height="320px"/></a> | ||||
|  | ||||
| [](https://misskey.xyz/) | ||||
| ================================================================ | ||||
| @@ -103,7 +103,7 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). | ||||
| <table><tr> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12190916/fb7fa7983c14425f890369535b1506a4/1?token-time=2145916800&token-hash=WeuDzzz24cRXJogyIkU-mxARqkdyms-rcZKbO-GpGjw%3D" alt="weep" width="100"></td> | ||||
| <td><img src="https://c8.patreon.com/2/200/12059069" alt="naga_rus" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/3?token-time=2145916800&token-hash=c8HeVqLtmdgH-gSBJg8i10gmOcwllM87MDHeznl3el0%3D" alt="Melilot" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12913507/f7181eacafe8469a93033d85f5969c29/4?token-time=2145916800&token-hash=vZdDTTF-ahiKBjjgppS2ev4rkD8H7TTKkXXoxsucs6Y%3D" alt="Melilot" width="100"></td> | ||||
| <td><img src="https://c8.patreon.com/2/200/16869916" alt="見当かなみ" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12999811/5f349fafcce44dd1824a8b1ebbec4564/3?token-time=2145916800&token-hash=LtV2lRi3L2jOWMLwccr9qWYfPrFlzIo2jYZHKzHEb6k%3D" alt="Xeltica" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/12021162/963128bb8d14476dbd8407943db8f31a/1?token-time=2145916800&token-hash=1FlxS9MEgmNGH_RHUVHbO5hIXB5I1z0lvA33CTvYvjA%3D" alt="gutfuckllc" width="100"></td> | ||||
| @@ -124,6 +124,7 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). | ||||
| <td><img src="https://c8.patreon.com/2/200/17463605" alt="Sampot" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/17880724/311738c8a48f4a6b9443c2445a75adde/1?token-time=2145916800&token-hash=95p8VdGX45E8BitZR_eOcDlqCjumjzNLBPQJrJdeCpI%3D" alt="takimura" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/17195955/be45e5e14c3e48b2bee0456c84e19df4/4?token-time=2145916800&token-hash=SbdZeN5SmsuT9stD6v0jN1z0hftg0FmRiCTxysU0Ihw%3D" alt="Damillora" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/16900731/935a10339daa4ede8e555903a0707060/1?token-time=2145916800&token-hash=3CrpqH-XtKs_NoIlSsTyVs8wCzP1WFCsG2xwps1IJq0%3D" alt="Atsuko Tominaga" width="100"></td> | ||||
| </tr><tr> | ||||
| <td><a href="https://www.patreon.com/mydarkstar">mydarkstar</a></td> | ||||
| <td><a href="https://www.patreon.com/user?u=12718187">Peter G.</a></td> | ||||
| @@ -133,10 +134,12 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). | ||||
| <td><a href="https://www.patreon.com/user?u=17463605">Sampot</a></td> | ||||
| <td><a href="https://www.patreon.com/takimura">takimura</a></td> | ||||
| <td><a href="https://www.patreon.com/damillora">Damillora</a></td> | ||||
| <td><a href="https://www.patreon.com/user?u=16900731">Atsuko Tominaga</a></td> | ||||
| </tr></table> | ||||
| <table><tr> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4389829/9f709180ac714651a70f74a82f3ffdb9/2?token-time=2145916800&token-hash=zcwFxb2zopzWwksKVU1YpfAEjsl4yKT02aQ6yiAFRiQ%3D" alt="natalie" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4389829/9f709180ac714651a70f74a82f3ffdb9/3?token-time=2145916800&token-hash=-iJszBqgYBhsM5qMdA1knf9wvprhEfESzKfR2oh7mIA%3D" alt="natalie" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/13034746/c711c7f58e204ecfbc2fd646bc8a4eee/1?token-time=2145916800&token-hash=5T8XcaAf9Zyzfg3QubR06s_kJZkArVEM2dwObrBVAU4%3D" alt="Hiratake" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/18072312/98e894d960314fa7bc236a72a39488fe/1?token-time=2145916800&token-hash=D6QK3fPyqiYKJfOzc-QqaSSairUrWdjld-ewp2waj6s%3D" alt="@Hekovic@gyutte.site" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/4503830/ccf2cc867ea64de0b524bb2e24b9a1cb/1?token-time=2145916800&token-hash=Ksk_2l3gjPDbnzMUOCSW1E-hdPJsNs2tSR4_RAakRK8%3D" alt="dansup" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/619786/32cf01444db24e578cd1982c197f6fc6/1?token-time=2145916800&token-hash=CXe9AqlZy9AsYfiWd3OBYVOzvODoN47Litz0Tu4BFpU%3D" alt="Gargron" width="100"></td> | ||||
| <td><img src="https://c10.patreonusercontent.com/3/eyJ3IjoyMDB9/patreon-media/p/user/5731881/4b6038e6cda34c04b83a5fcce3806a93/1?token-time=2145916800&token-hash=xhR1n6NAAyEb-IUXLD6_dshkFa3mefU5ZZuk1L8qKTs%3D" alt="Nokotaro Takeda" width="100"></td> | ||||
| @@ -144,13 +147,14 @@ Please see the [Contribution Guide](./CONTRIBUTING.md). | ||||
| </tr><tr> | ||||
| <td><a href="https://www.patreon.com/user?u=4389829">natalie</a></td> | ||||
| <td><a href="https://www.patreon.com/hiratake">Hiratake</a></td> | ||||
| <td><a href="https://www.patreon.com/user?u=18072312">@Hekovic@gyutte.site</a></td> | ||||
| <td><a href="https://www.patreon.com/dansup">dansup</a></td> | ||||
| <td><a href="https://www.patreon.com/mastodon">Gargron</a></td> | ||||
| <td><a href="https://www.patreon.com/takenoko">Nokotaro Takeda</a></td> | ||||
| <td><a href="https://www.patreon.com/user?u=12531784">Takashi Shibuya</a></td> | ||||
| </tr></table> | ||||
|  | ||||
| **Last updated:** Fri, 01 Mar 2019 23:59:07 UTC | ||||
| **Last updated:** Sun, 10 Mar 2019 22:17:05 UTC | ||||
| <!-- PATREON_END --> | ||||
|  | ||||
| :four_leaf_clover: Copyright | ||||
|   | ||||
							
								
								
									
										4
									
								
								assets/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								assets/robots.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| user-agent: * | ||||
| allow: / | ||||
|  | ||||
| # todo: sitemap | ||||
| @@ -5,7 +5,6 @@ | ||||
| import * as gulp from 'gulp'; | ||||
| import * as gutil from 'gulp-util'; | ||||
| import * as ts from 'gulp-typescript'; | ||||
| const yaml = require('gulp-yaml'); | ||||
| const sourcemaps = require('gulp-sourcemaps'); | ||||
| import tslint from 'gulp-tslint'; | ||||
| const cssnano = require('gulp-cssnano'); | ||||
| @@ -126,12 +125,6 @@ gulp.task('copy:client', () => | ||||
| 			.pipe(gulp.dest('./built/client/assets/')) | ||||
| ); | ||||
|  | ||||
| gulp.task('locales', () => | ||||
| 	gulp.src('./locales/*.yml') | ||||
| 		.pipe(yaml({ schema: 'DEFAULT_SAFE_SCHEMA' })) | ||||
| 		.pipe(gulp.dest('./built/client/assets/locales/')) | ||||
| ); | ||||
|  | ||||
| gulp.task('doc', () => | ||||
| 	gulp.src('./src/docs/**/*.styl') | ||||
| 		.pipe(stylus()) | ||||
| @@ -149,7 +142,6 @@ gulp.task('build', gulp.parallel( | ||||
| 	'build:ts', | ||||
| 	'build:copy', | ||||
| 	'build:client', | ||||
| 	'locales', | ||||
| 	'doc' | ||||
| )); | ||||
|  | ||||
|   | ||||
| @@ -93,7 +93,7 @@ common: | ||||
|     love: "Super" | ||||
|     laugh: "Smích" | ||||
|     hmm: "Hmm...?" | ||||
|     surprise: "Páni" | ||||
|     surprise: "Překvapení" | ||||
|     congrats: "Gratuluji!" | ||||
|     angry: "Naštvaný" | ||||
|     confused: "Zmatený" | ||||
| @@ -101,14 +101,14 @@ common: | ||||
|     pudding: "Pudink" | ||||
|   note-visibility: | ||||
|     public: "Veřejná" | ||||
|     home: "Domovský" | ||||
|     home: "Domovská" | ||||
|     home-desc: "Poslat pouze na domovskou časovou osu" | ||||
|     followers: "Pro sledující" | ||||
|     followers-desc: "Poslat pouze sledujícím" | ||||
|     specified: "Přímý" | ||||
|     specified: "Přímá" | ||||
|     specified-desc: "Poslat pouze zmíněným uživatelům" | ||||
|     local-public: "Veřejný (pouze místní)" | ||||
|     local-home: "Domovský (pouze místní)" | ||||
|     local-public: "Veřejná (pouze místní)" | ||||
|     local-home: "Domovská (pouze místní)" | ||||
|     local-followers: "Pro sledující (pouze místní)" | ||||
|   note-placeholders: | ||||
|     a: "Co právě děláte?" | ||||
| @@ -122,7 +122,8 @@ common: | ||||
|     profile: "Profil" | ||||
|     notification: "Oznámení" | ||||
|     apps: "Aplikace" | ||||
|     tags: "Tagy" | ||||
|     tags: "Hashtagy" | ||||
|     mute-and-block: "Ztlumit/blokovat" | ||||
|     blocking: "Blokování" | ||||
|     security: "Zabezpečení" | ||||
|     signin: "Historie přihlášení" | ||||
| @@ -131,97 +132,182 @@ common: | ||||
|     appearance: "Vzhled" | ||||
|     behavior: "Chování" | ||||
|     fetch-on-scroll: "Nekonečné rolování" | ||||
|     note-visibility: "Viditelnost statusu" | ||||
|     default-note-visibility: "Výchozí viditelnost statusu" | ||||
|     fetch-on-scroll-desc: "Pokud budete rolovat dolů po stránce, automaticky bude načten další obsah." | ||||
|     note-visibility: "Viditelnost příspěvku" | ||||
|     default-note-visibility: "Výchozí viditelnost příspěvku" | ||||
|     remember-note-visibility: "Zapamatovat viditelnost příspěvků" | ||||
|     web-search-engine: "Webové vyhledávače" | ||||
|     web-search-engine-desc: "Například: https://www.google.com/?#q={{query}}" | ||||
|     keep-cw: "Zachovat varování o obsahu" | ||||
|     keep-cw-desc: "Při odpovědi na příspěvek bude varování o obsahu nastaveno stejně jako původní příspěvek." | ||||
|     i-like-sushi: "Mam radši sushi (než puding)" | ||||
|     show-reversi-board-labels: "Zobrazit označení řad a sloupců v Reversi" | ||||
|     use-avatar-reversi-stones: "Použít avatar jako figurku v Reversi" | ||||
|     disable-animated-mfm: "Vypnout pohyblivé texty v příspěvku" | ||||
|     disable-showing-animated-images: "Nepřehrávat animované obrázky" | ||||
|     suggest-recent-hashtags: "Navrhovat nedávné hashtagy v rámci psacího pole" | ||||
|     always-show-nsfw: "Vždycky ukázat NSFW obsah" | ||||
|     always-mark-nsfw: "Označovat všechny příspěvky za delikátní" | ||||
|     show-full-acct: "Zaradit hostovací server jako součast přezdívky" | ||||
|     show-via: "zobrazit přes" | ||||
|     reduce-motion: "Snížit pohyb v rozhraní" | ||||
|     this-setting-is-this-device-only: "Pouze pro toto zařízení" | ||||
|     use-os-default-emojis: "Použít výchozí emoji systému" | ||||
|     line-width: "Hrubka línie" | ||||
|     line-width-thin: "Úzka" | ||||
|     line-width-normal: "Běžná" | ||||
|     line-width-thick: "Tlustá" | ||||
|     font-size: "Velikost písma" | ||||
|     font-size-x-small: "Malé" | ||||
|     font-size-small: "Dost malé" | ||||
|     font-size-medium: "Průměrné" | ||||
|     font-size-large: "Dost velké" | ||||
|     font-size-x-large: "Velké" | ||||
|     deck-column-align: "Zarovnání sloupců v Decku" | ||||
|     deck-column-align-center: "Na střed" | ||||
|     deck-column-align-left: "Vlevo" | ||||
|     deck-column-width-wide: "široké" | ||||
|     wallpaper: "Pozadí" | ||||
|     deck-column-align-flexible: "Flexibilní" | ||||
|     deck-column-width: "Šířka sloupců v Decku" | ||||
|     deck-column-width-narrow: "Úzké" | ||||
|     deck-column-width-narrower: "Poněkud úzké" | ||||
|     deck-column-width-normal: "Normální" | ||||
|     deck-column-width-wider: "Poněkud široké" | ||||
|     deck-column-width-wide: "Široké" | ||||
|     use-shadow: "Používat v rozhraní stíny" | ||||
|     rounded-corners: "Zakulatit rohy v rozhraní" | ||||
|     circle-icons: "Používat kulaté ikony" | ||||
|     contrasted-acct: "Přidat uživatelskému účtu kontrast" | ||||
|     wallpaper: "Obrázek na pozadí" | ||||
|     choose-wallpaper: "Zvolit pozadí" | ||||
|     delete-wallpaper: "Odstranit pozadí" | ||||
|     post-form-on-timeline: "Zobrazit formulář pro nové příspěvky nad časovou osou" | ||||
|     show-clock-on-header: "Zobrazit hodiny v pravém horním rohu" | ||||
|     show-reply-target: "Zobrazit cíl odpovědi" | ||||
|     timeline: "Časová osa" | ||||
|     show-my-renotes: "Zobrazit moje renoty v časové ose" | ||||
|     show-renoted-my-notes: "Zobrazit renoty vašich vlastních příspěvků v časové ose" | ||||
|     show-local-renotes: "Zobrazit renoty místních příspěvků v časové ose" | ||||
|     remain-deleted-note: "I nadále zobrazovat odstraněné příspěvky" | ||||
|     sound: "Zvuk" | ||||
|     enable-sounds: "Povolit zvuk" | ||||
|     update: "Misskey aktualizace" | ||||
|     enable-sounds-desc: "Přehrát zvuk, například při odeslání nebo přijetí příspěvku, či zprávy. Toto nastavení je uloženo v prohlížeči." | ||||
|     volume: "Hlasitost" | ||||
|     test: "Test" | ||||
|     update: "Aktualizace Misskey" | ||||
|     version: "Verze:" | ||||
|     latest-version: "Nejnovější verze:" | ||||
|     advanced-settings: "Pokročilé nastavení" | ||||
|     navbar-position: "Pozice navigace" | ||||
|     update-checking: "Kontroluji aktualizace" | ||||
|     do-update: "Zkontrolovat aktualizace" | ||||
|     update-settings: "Pokročilá nastavení" | ||||
|     no-updates: "Nejsou dostupné žádné aktualizace" | ||||
|     no-updates-desc: "Váš server Misskey je aktuální." | ||||
|     update-available: "Je dostupná nová verze" | ||||
|     update-available-desc: "Aktualizace budou aplikovány po znovunačtení stránky." | ||||
|     advanced-settings: "Pokročilá nastavení" | ||||
|     debug-mode: "Povolit režim ladění" | ||||
|     debug-mode-desc: "Toto nastavení je uloženo v prohlížeči." | ||||
|     navbar-position: "Poloha navigačního panelu" | ||||
|     navbar-position-top: "Nahoře" | ||||
|     navbar-position-left: "Vlevo" | ||||
|     navbar-position-right: "Vpravo" | ||||
|     i-am-under-limited-internet: "Mam omezený (pomalý) internet" | ||||
|     post-style: "Styl zobrazení poznámek" | ||||
|     post-style-standard: "Standardní" | ||||
|     post-style-smart: "Chytrý" | ||||
|     notification-position: "Poloha oznámení" | ||||
|     notification-position-bottom: "Dole" | ||||
|     notification-position-top: "Nahoře" | ||||
|     load-raw-images: "Zobrazit obrázky v originální kvalitě" | ||||
|   search: "Vyhledávání" | ||||
|     disable-via-mobile: "Neoznačovat příspěvky jako „z mobilu“" | ||||
|     load-raw-images: "Zobrazovat obrázky v původní kvalitě" | ||||
|     load-remote-media: "Zobrazovat média ze vzdáleného serveru" | ||||
|   search: "Hledání" | ||||
|   delete: "Odstranit" | ||||
|   loading: "Nahrávám..." | ||||
|   loading: "Načítám..." | ||||
|   ok: "OK" | ||||
|   cancel: "Zrušit" | ||||
|   update-available-title: "Aktualizace k dispozici" | ||||
|   update-available: "Nová verze Misskey je k dispozici({newer}, Vaše verze je {current}). Načtěte znovu stránku pro aktivování nové verze." | ||||
|   update-available: "Je k dispozici nová verze Misskey ({newer},vaše verze je {current}). Pro aplikování nové verze znovunačtěte stránku." | ||||
|   verified-user: "Ověřené účty" | ||||
|   hide-password: "Skrýt heslo" | ||||
|   show-password: "Zobrazit heslo" | ||||
|   do-not-use-in-production: "Tohle je vývojářský build. Nepoužívejte v produkci." | ||||
|   user-suspended: "Tomuto uživateli byl pozastaven účet." | ||||
|   is-remote-user: "Informace o tomto uživateli nemusí být kompletní." | ||||
|   is-remote-post: "Obsah tohoto příspěvku je zrcadlen." | ||||
|   view-on-remote: "Pro kompletnost jej zobrazte vzdáleně." | ||||
|   renoted-by: "{user} renotoval/a" | ||||
|   no-notes: "Bez poznámek" | ||||
|   turn-on-darkmode: "Přepnout na tmavý režim" | ||||
|   turn-off-darkmode: "Světlý režim" | ||||
|   error: | ||||
|     title: "Něco se stalo :(" | ||||
|     retry: "Zkusit znovu" | ||||
|   reversi: | ||||
|     drawn: "Remíza" | ||||
|     my-turn: "Váš tah" | ||||
|     opponent-turn: "Je řada na protivníkovi" | ||||
|     turn-of: "{name} je na tahu" | ||||
|     won: "{name} vyhrál" | ||||
|     past-turn-of: "{name} byl/a na tahu" | ||||
|     won: "{name} vyhrál/a" | ||||
|     black: "Černá" | ||||
|     white: "Bílá" | ||||
|     total: "Celkem" | ||||
|     this-turn: "{count}. kolo" | ||||
|   widgets: | ||||
|     analog-clock: "Analogové hodiny" | ||||
|     profile: "Profil" | ||||
|     calendar: "Kalendář" | ||||
|     timemachine: "Kalendář (Stroj času)" | ||||
|     activity: "Aktivita" | ||||
|     rss: "RSS čtečka" | ||||
|     memo: "Poznámky" | ||||
|     memo: "Rychlé poznámky" | ||||
|     trends: "Trendy" | ||||
|     photo-stream: "Proud fotek" | ||||
|     posts-monitor: "Grafy příspěvků" | ||||
|     slideshow: "Prezentace" | ||||
|     version: "Verze" | ||||
|     broadcast: "Rozhlas" | ||||
|     notifications: "Oznámení" | ||||
|     users: "Doporučení uživatelé" | ||||
|     polls: "Ankety" | ||||
|     post-form: "Formulář pro psaní" | ||||
|     server: "Informace o serveru" | ||||
|     nav: "Navigace" | ||||
|     tips: "Tipy" | ||||
|     hashtags: "Hashtagy" | ||||
|   dev: "Nepodařilo se vytvořit aplikace. Prosím zkuste to znovu." | ||||
|   ai-chan-kawaii: "Ai-chan kawaii!" | ||||
|   you: "Vy" | ||||
| auth/views/form.vue: | ||||
|   share-access: "Chcete dovolit <i>{name}</i> přístup k Vašemu účtu?" | ||||
|   share-access: "Chcete dovolit aplikaci <i>{name}</i> přístup k vašemu účtu?" | ||||
|   permission-ask: "Tato aplikace vyžaduje následující oprávnění:" | ||||
|   account-read: "Zobrazit informace účtu" | ||||
|   note-write: "Odeslat." | ||||
|   following-write: "Sledovat a přestat sledovat" | ||||
|   drive-read: "Přečíst váš Disk" | ||||
|   notification-read: "Sledovat oznámení." | ||||
|   notification-write: "Zpravovat notifikace." | ||||
|   cancel: "Zrušit" | ||||
|   accept: "Povolit přístup" | ||||
| auth/views/index.vue: | ||||
|   loading: "Nahrávám..." | ||||
|   loading: "Načítám..." | ||||
|   already-authorized: "Tato aplikace byla již autorizována." | ||||
|   error: "Taková relace neexistuje." | ||||
|   sign-in: "Prosím přihlaste se." | ||||
| common/views/pages/explore.vue: | ||||
|   verified-users: "Ověřené účty" | ||||
|   popular-users: "Populární uživatelé" | ||||
|   recently-updated-users: "Nedávno aktívni uživatelé" | ||||
|   recently-registered-users: "Nedávno registrovaní uživatelé" | ||||
|   popular-tags: "Populární tagy" | ||||
|   federated: "Z fediversu" | ||||
|   federated: "Z fediverse" | ||||
| common/views/components/url-preview.vue: | ||||
|   enable-player: "Otevřít v přehrávači" | ||||
| common/views/components/user-list.vue: | ||||
|   no-users: "Žádní uživatelé" | ||||
| common/views/components/games/reversi/reversi.vue: | ||||
|   matching: | ||||
|     waiting-for: "Čeká se na {}" | ||||
|     cancel: "Zrušit" | ||||
| common/views/components/games/reversi/reversi.game.vue: | ||||
|   surrender: "Vzdát se" | ||||
| @@ -235,14 +321,21 @@ common/views/components/games/reversi/reversi.index.vue: | ||||
|   my-games: "Moje hra" | ||||
|   all-games: "Všechny hry" | ||||
|   enter-username: "Zadejte své uživatelské jméno" | ||||
|   game-state: | ||||
|     ended: "Ukončené" | ||||
|     playing: "Probíhají" | ||||
| common/views/components/games/reversi/reversi.room.vue: | ||||
|   settings-of-the-game: "Nastavení hry" | ||||
|   choose-map: "Vybrat mapu" | ||||
|   random: "Náhodně" | ||||
|   black-or-white: "Černé/bílé" | ||||
|   black-is: "Černá je {}" | ||||
|   rules: "Pravidla" | ||||
|   looped-map: "Zacyklená mapa" | ||||
|   settings-of-the-bot: "Nastavení Botu" | ||||
|   this-game-is-started-soon: "Hra začne za pár vteřin" | ||||
|   waiting-for-other: "Čeká se na protivníka" | ||||
|   waiting-for-me: "Čeká se na Vás" | ||||
|   waiting-for-both: "Připravuji" | ||||
|   cancel: "Zrušit" | ||||
|   ready: "Připraveno" | ||||
| @@ -255,7 +348,22 @@ common/views/components/connect-failed.troubleshooter.vue: | ||||
|   checking-network: "Prověřit síťové připojení" | ||||
|   internet: "Připojení k internetu" | ||||
|   checking-internet: "Ověřuji připojení k internetu." | ||||
|   server: "Připojení k serveru" | ||||
|   no-network-desc: "Ujistěte se že jste připojeni k Internetu." | ||||
|   no-internet: "Nejste připojeni k internetu" | ||||
|   no-internet-desc: "Jste připojen k síti, ale zdá se že stále chybí připojení k Internetu. Prosím zkontrolujte Vaše připojení k Internetu." | ||||
| common/views/components/media-banner.vue: | ||||
|   click-to-show: "Klikněte pro zobrazení" | ||||
| common/views/components/theme.vue: | ||||
|   light-theme: "Šablona pro použití ve světlém vzhledu" | ||||
|   dark-theme: "Šablona pro použití v tmavém vzhledu" | ||||
|   light-themes: "Světlý vzhled" | ||||
|   dark-themes: "Tmavý vzhled" | ||||
|   install-a-theme: "Nainstalovat šablonu" | ||||
|   theme-code: "Kód šablony" | ||||
|   install: "Nainstalovat" | ||||
|   installed: "\"{}\" byl nainstalován" | ||||
|   create-a-theme: "Vytvořit motiv" | ||||
|   base-theme: "Základní vzhled" | ||||
|   find-more-theme: "Najít další vzhledy" | ||||
|   theme-name: "Jméno vzhledu" | ||||
| @@ -289,6 +397,7 @@ common/views/components/messaging-room.vue: | ||||
|   only-one-file-attached: "Jenom JEDEN soubor může být přiložen ke zprávě." | ||||
| common/views/components/messaging-room.form.vue: | ||||
|   send: "Odeslat" | ||||
|   attach-from-local: "Přiložit soubory z Vašeho zařízení" | ||||
|   only-one-file-attached: "Jenom JEDEN soubor může být přiložen ke zprávě." | ||||
| common/views/components/messaging-room.message.vue: | ||||
|   is-read: "Přečtené" | ||||
| @@ -301,13 +410,41 @@ common/views/components/nav.vue: | ||||
|   donors: "Dárci" | ||||
|   repository: "Úložiště" | ||||
|   develop: "Vývojáři" | ||||
|   feedback: "Zpětná vazba" | ||||
| common/views/components/note-menu.vue: | ||||
|   mention: "Zmínění" | ||||
|   copy-content: "Zkopírovat obsah" | ||||
|   copy-link: "Zkopírovat odkaz" | ||||
|   favorite: "Přidat do oblíbených" | ||||
|   unfavorite: "Odebrat z oblízených" | ||||
|   watch: "Sledovat" | ||||
|   unwatch: "Přestat sledovat" | ||||
|   delete: "Odstranit" | ||||
|   delete-confirm: "Opravdu chcete smazat tento příspěvek?" | ||||
|   remote: "Ukázat originální poznámku" | ||||
| common/views/components/user-menu.vue: | ||||
|   mention: "Zmínění" | ||||
|   mute: "Umlčet" | ||||
|   block: "Blokováno" | ||||
|   unmute: "Zrušit umlčení" | ||||
|   block: "Blokován" | ||||
|   unblock: "Odblokovat" | ||||
|   push-to-list: "Přidat do seznamu" | ||||
|   select-list: "Vyberte seznam" | ||||
|   report-abuse-reported: "Problém byl nahlášen administrátorovi. Děkujeme za Vaší kooperaci." | ||||
| common/views/components/poll.vue: | ||||
|   vote-count: "{} hlasů" | ||||
|   vote: "Hlasovat" | ||||
|   show-result: "Podívat se na výsledky" | ||||
|   voted: "Už jste hlasovaly" | ||||
|   remaining-days: "zbývá {d} dnů, {h} hodin" | ||||
|   remaining-hours: "zbývá {h} hodin, a {m} minut" | ||||
|   remaining-minutes: "zbývá {m} minut, a {s} sekund" | ||||
|   remaining-seconds: "zbývá {s} sekund" | ||||
| common/views/components/poll-editor.vue: | ||||
|   no-only-one-choice: "Musíte vybrat alespoň dvě možnosti" | ||||
|   day: "Ne" | ||||
| common/views/components/emoji-picker.vue: | ||||
|   custom-emoji: "Emoji" | ||||
|   people: "Lidé" | ||||
|   animals-and-nature: "Zvířata a příroda" | ||||
|   food-and-drink: "Jídlo a pití" | ||||
| @@ -360,20 +497,53 @@ common/views/components/notification-settings.vue: | ||||
|   mark-as-read-all-notifications: "Označit všechna oznámení za přečtená" | ||||
|   mark-as-read-all-unread-notes: "Označit všechny příspěvky za přečtené" | ||||
|   mark-as-read-all-talk-messages: "Označit všechny zprávy za přečtené" | ||||
| common/views/components/integration-settings.vue: | ||||
|   connect: "Připojit" | ||||
|   disconnect: "Odpojit" | ||||
| common/views/components/github-setting.vue: | ||||
|   description: "Jakmile spojíte Váš GitHub účet s Vaším Misskey účtem, uvidíte informace o Vašem GitHub účtu na Vašem profilu a budete se moci přihlásit skrze GitHub." | ||||
|   connected-to: "Je připojen k tomuto GitHub účtu" | ||||
|   detail: "Více…" | ||||
|   reconnect: "Znovu připojit" | ||||
|   connect: "Připojit Váš GitHub účet" | ||||
|   disconnect: "Odpojit" | ||||
| common/views/components/discord-setting.vue: | ||||
|   description: "Jakmile spojíte Váš Discord účet s Vaším Misskey účtem, uvidíte informace o Vašem Discord účtu na Vašem profilu a budete se moci přihlásit skrze Discord." | ||||
|   connected-to: "Je připojen k tomuto Discord účtu" | ||||
|   detail: "Více…" | ||||
|   reconnect: "Znovu připojit" | ||||
|   connect: "Připojit Váš Discord účet" | ||||
|   disconnect: "Odpojit" | ||||
| common/views/components/uploader.vue: | ||||
|   waiting: "Čekáme" | ||||
| common/views/components/visibility-chooser.vue: | ||||
|   local-public: "Veřejný (pouze místní)" | ||||
|   local-home: "Domovský (pouze místní)" | ||||
|   public: "Veřejné" | ||||
|   home: "Domů" | ||||
|   specified-desc: "Poslat pouze zmíněným uživatelům" | ||||
|   local-public: "Veřejná (pouze místní)" | ||||
|   local-home: "Domovská (pouze místní)" | ||||
|   local-followers: "Pro sledující (pouze místní)" | ||||
| common/views/components/trends.vue: | ||||
|   count: "{} zmíněných uživatelů" | ||||
|   empty: "Žádný trend" | ||||
| common/views/components/language-settings.vue: | ||||
|   title: "Zobrazit jazyky" | ||||
|   pick-language: "Zvolte jazyk" | ||||
|   recommended: "Doporučené" | ||||
|   info: "Pro aktivování změn musíte znovu načíst stránky." | ||||
| common/views/components/profile-editor.vue: | ||||
|   title: "Profil" | ||||
|   name: "Jméno" | ||||
|   account: "Účet" | ||||
|   location: "Lokace" | ||||
|   description: "O mně" | ||||
|   you-can-include-hashtags: "V popisku o Vás můžete použít i hastagy." | ||||
|   language: "Jazyk" | ||||
|   birthday: "Datum narození" | ||||
|   avatar: "Avatar" | ||||
|   banner: "Baner" | ||||
|   is-cat: "Tento účet je kočka" | ||||
|   is-bot: "Tento účet je Bot" | ||||
|   advanced: "Ostatní" | ||||
|   privacy: "Osobní údaje" | ||||
|   save: "Uložit" | ||||
| @@ -386,9 +556,10 @@ common/views/components/profile-editor.vue: | ||||
|   email-not-verified: "Váš email není potvrzen. Prosím zkontrolujte si svou schránku." | ||||
|   export: "Exportovat" | ||||
|   export-targets: | ||||
|     following-list: "Seznam následovníků" | ||||
|     following-list: "Seznam sledujících" | ||||
|     mute-list: "Seznam ztlumených uživatelů" | ||||
|     blocking-list: "Seznam blokovaných uživatelů" | ||||
|     user-lists: "Seznamy" | ||||
|   enter-password: "Prosím, zadejte Vaše heslo" | ||||
|   danger-zone: "Nebezpečná zóna" | ||||
|   delete-account: "Smazat účet" | ||||
| @@ -401,6 +572,7 @@ common/views/components/user-list-editor.vue: | ||||
|   delete-are-you-sure: "Smazat seznam \"$1\"?" | ||||
|   deleted: "Smazáno" | ||||
| common/views/widgets/broadcast.vue: | ||||
|   fetching: "Načítám" | ||||
|   next: "Další" | ||||
| common/views/widgets/calendar.vue: | ||||
|   year: "Rok {}" | ||||
| @@ -413,10 +585,12 @@ common/views/widgets/photo-stream.vue: | ||||
|   no-photos: "Žádné obrázky" | ||||
| common/views/widgets/posts-monitor.vue: | ||||
|   title: "Grafy příspěvků" | ||||
|   toggle: "Přepnout zobrazení" | ||||
| common/views/widgets/hashtags.vue: | ||||
|   title: "Hashtagy" | ||||
| common/views/widgets/server.vue: | ||||
|   title: "Informace o serveru" | ||||
|   toggle: "Přepnout zobrazení" | ||||
| common/views/widgets/memo.vue: | ||||
|   title: "Poznámky" | ||||
|   memo: "Pište sem!" | ||||
| @@ -425,12 +599,19 @@ common/views/widgets/slideshow.vue: | ||||
|   no-image: "V této složce nebyly nalezeny žádné fotky." | ||||
| desktop: | ||||
|   banner: "Baner" | ||||
|   avatar-crop-title: "Vyberte část, která se zobrazí jako avatar" | ||||
|   avatar: "Avatar" | ||||
|   uploading-avatar: "Nahrál nový avatar" | ||||
|   avatar-updated: "Vaše avatar byl aktualizován" | ||||
|   invalid-filetype: "Tento formát souboru není podporován" | ||||
| desktop/views/components/activity.chart.vue: | ||||
|   total: "Černá ... Celkem" | ||||
|   notes: "Modrá ... Poznámky" | ||||
|   replies: "Červená ... Odpovědi" | ||||
|   renotes: "Zelená ... Renoty" | ||||
| desktop/views/components/activity.vue: | ||||
|   title: "Aktivita" | ||||
|   toggle: "Přepnout zobrazení" | ||||
| desktop/views/components/calendar.vue: | ||||
|   title: "{month}. {year}" | ||||
|   prev: "Předchozí měsíc" | ||||
| @@ -448,6 +629,8 @@ desktop/views/components/choose-folder-from-drive-window.vue: | ||||
| desktop/views/components/crop-window.vue: | ||||
|   cancel: "Zrušit" | ||||
|   ok: "OK" | ||||
| desktop/views/components/drive-window.vue: | ||||
|   used: "využito" | ||||
| desktop/views/components/drive.file.vue: | ||||
|   avatar: "Avatar" | ||||
|   banner: "Baner" | ||||
| @@ -479,10 +662,41 @@ desktop/views/components/drive.vue: | ||||
|   empty-folder: "Tato složka je prázdná" | ||||
|   unable-to-process: "Operace nemohla být dokončena." | ||||
|   unhandled-error: "Neznámá chyba" | ||||
|   url-upload: "Nahrát z URL adresy" | ||||
|   url-of-file: "URL adresa souboru, který chcete nahrát" | ||||
|   may-take-time: "Může trvat nějakou dobu, dokud nebude dokončeno nahrávání." | ||||
|   create-folder: "Vytvořit složku" | ||||
|   folder-name: "Název složky" | ||||
|   contextmenu: | ||||
|     create-folder: "Vytvořit složku" | ||||
|     upload: "Nahrát soubor" | ||||
|     url-upload: "Nahrát z URL" | ||||
| desktop/views/components/media-video.vue: | ||||
|   click-to-show: "Klikněte pro zobrazení" | ||||
| desktop/views/components/game-window.vue: | ||||
|   game: "Reversi" | ||||
| desktop/views/components/home.vue: | ||||
|   done: "Hotovo" | ||||
|   add: "Přidat" | ||||
| desktop/views/input-dialog.vue: | ||||
|   cancel: "Zrušit" | ||||
|   ok: "OK" | ||||
| desktop/views/components/messaging-room-window.vue: | ||||
|   title: "Zprávy:" | ||||
| desktop/views/components/messaging-window.vue: | ||||
|   title: "Zprávy" | ||||
| desktop/views/components/note-detail.vue: | ||||
|   private: "Tento příspěvek je soukromý" | ||||
|   deleted: "Tento příspěvek byl odstraněn" | ||||
|   renote: "Renotovat" | ||||
|   add-reaction: "Přidat reakci" | ||||
|   undo-reaction: "Odebrat reakci" | ||||
| desktop/views/components/note.vue: | ||||
|   reply: "Odpovědět" | ||||
|   renote: "Renote" | ||||
|   add-reaction: "Přidat reakci" | ||||
|   undo-reaction: "Odebrat reakci" | ||||
|   private: "Tento příspěvek je soukromý" | ||||
|   deleted: "Tento příspěvek byl odstraněn" | ||||
| desktop/views/components/notes.vue: | ||||
|   error: "Načítání selhalo." | ||||
| @@ -493,37 +707,62 @@ desktop/views/components/post-form.vue: | ||||
|   hide-contents: "Schovat obsah" | ||||
|   reply-placeholder: "Odpovědět na tento příspěvěk" | ||||
|   quote-placeholder: "Citovat tento příspěvek" | ||||
|   submit: "Příspěvek" | ||||
|   reply: "Odpovědět" | ||||
|   renote: "Renotovat" | ||||
|   posted: "Odesláno!" | ||||
|   replied: "Odpověděno!" | ||||
|   reposted: "Renotováno!" | ||||
|   note-failed: "Nepodařilo se přidat příspěvek" | ||||
|   renote-failed: "Renotování neuspělo" | ||||
|   insert-a-kao: "v('ω')v" | ||||
|   create-poll: "Vytvořit anketu" | ||||
|   text-remain: "{0} znaků zbývá" | ||||
|   recent-tags: "Nejnovější" | ||||
|   visibility: "Viditelnost" | ||||
|   geolocation-alert: "Vaše zařízení nepodporuje lokační službu" | ||||
|   error: "Chyba" | ||||
|   enter-username: "Zadejte své uživatelské jméno..." | ||||
| desktop/views/components/post-form-window.vue: | ||||
|   note: "Nový příspěvek" | ||||
|   reply: "Odpovědět" | ||||
| desktop/views/components/progress-dialog.vue: | ||||
|   waiting: "Čekáme" | ||||
| desktop/views/components/renote-form.vue: | ||||
|   quote: "Citovat..." | ||||
|   cancel: "Zrušit" | ||||
|   renote: "Renotovat" | ||||
|   renote-home: "Renote (domů)" | ||||
|   reposting: "Renotuji..." | ||||
|   success: "Renotováno!" | ||||
|   failure: "Renotování neuspělo" | ||||
| desktop/views/components/renote-form-window.vue: | ||||
|   title: "Chcete tohle renotovat?" | ||||
| desktop/views/components/settings.2fa.vue: | ||||
|   detail: "Více…" | ||||
|   url: "https://www.google.cz/landing/2step/" | ||||
| common/views/components/media-image.vue: | ||||
|   click-to-show: "Klikněte pro zobrazení" | ||||
| common/views/components/api-settings.vue: | ||||
|   token: "Token:" | ||||
|   enter-password: "Prosím zadejte heslo" | ||||
|   console: | ||||
|     title: "API konzole" | ||||
|     endpoint: "Endpoint" | ||||
|     parameter: "Parametry" | ||||
|     send: "Odeslat" | ||||
|     sending: "Odesílám" | ||||
|     response: "Výsledek" | ||||
| desktop/views/components/settings.apps.vue: | ||||
|   no-apps: "Žádné připojené aplikace" | ||||
| common/views/components/drive-settings.vue: | ||||
|   max: "Velikost úložiště" | ||||
|   in-use: "využito" | ||||
|   stats: "Statistiky" | ||||
| common/views/components/mute-and-block.vue: | ||||
|   mute-and-block: "Umlčet / Blokovat" | ||||
|   mute-and-block: "Umlčet/blokovat" | ||||
|   mute: "Umlčet" | ||||
|   block: "Blokováno" | ||||
|   block: "Blokován" | ||||
|   no-muted-users: "Žádný uživatel nebyl umlčen" | ||||
|   no-blocked-users: "Žádný uživatel není blokován" | ||||
|   save: "Uložit" | ||||
| @@ -546,21 +785,55 @@ desktop/views/components/settings.tags.vue: | ||||
| desktop/views/components/taskmanager.vue: | ||||
|   title: "Správce úloh" | ||||
| desktop/views/components/timeline.vue: | ||||
|   home: "Domů" | ||||
|   local: "Lokální" | ||||
|   global: "Globální" | ||||
|   mentions: "Zmínění" | ||||
|   messages: "Zprávy" | ||||
|   list: "Seznamy" | ||||
|   hashtag: "Hashtag" | ||||
|   add-list: "Přidat do seznamu" | ||||
|   list-name: "Název seznamu" | ||||
| desktop/views/components/ui.header.vue: | ||||
|   welcome-back: "Vítejte zpátky," | ||||
|   adjective: "Pán" | ||||
| desktop/views/components/ui.header.account.vue: | ||||
|   profile: "Váš profil" | ||||
|   lists: "Seznamy" | ||||
|   admin: "Administrace" | ||||
| desktop/views/components/ui.header.nav.vue: | ||||
|   game: "Hry" | ||||
| desktop/views/components/ui.header.notifications.vue: | ||||
|   title: "Oznámení" | ||||
| desktop/views/components/ui.header.post.vue: | ||||
|   post: "Nový příspěvek" | ||||
| desktop/views/components/ui.header.search.vue: | ||||
|   placeholder: "Vyhledávání" | ||||
| desktop/views/components/received-follow-requests-window.vue: | ||||
|   accept: "Přijmout" | ||||
|   reject: "Odmítnout" | ||||
| desktop/views/components/user-lists-window.vue: | ||||
|   title: "Seznamy uživatelů" | ||||
|   create-list: "Vytvořit seznam" | ||||
|   list-name: "Název seznamu" | ||||
| desktop/views/components/user-preview.vue: | ||||
|   notes: "Příspěvky" | ||||
| desktop/views/components/users-list.vue: | ||||
|   all: "Všechny" | ||||
|   iknow: "Znáte" | ||||
|   fetching: "Načítám…" | ||||
| desktop/views/components/window.vue: | ||||
|   close: "Zavřít" | ||||
| admin/views/index.vue: | ||||
|   instance: "Instance" | ||||
|   emoji: "Emoji" | ||||
|   moderators: "Moderátoři" | ||||
|   users: "Uživatelé" | ||||
|   federation: "Z fediversu" | ||||
|   announcements: "Oznámení" | ||||
|   hashtags: "Hashtagy" | ||||
|   queue: "Fronta úloh" | ||||
|   logs: "Logy" | ||||
|   back-to-misskey: "Zpět na Misskey" | ||||
| admin/views/dashboard.vue: | ||||
|   accounts: "Účty" | ||||
| @@ -611,9 +884,19 @@ admin/views/instance.vue: | ||||
|   saved: "Uloženo" | ||||
|   user-recommendation-config: "Doporučení uživatelé" | ||||
|   email: "Emailová adresa" | ||||
|   smtp-port: "SMTP Port" | ||||
|   smtp-auth: "Provést SMTP autentikaci" | ||||
|   smtp-user: "SMTP uživatel" | ||||
|   smtp-pass: "SMTP heslo" | ||||
|   serviceworker-config: "ServiceWorker" | ||||
|   enable-serviceworker: "Povolit ServiceWorker" | ||||
|   vapid-publickey: "VAPID veřejný klíč" | ||||
|   vapid-privatekey: "VAPID osobní klíč" | ||||
| admin/views/charts.vue: | ||||
|   title: "Graf" | ||||
|   per-day: "za den" | ||||
|   per-hour: "za hodinu" | ||||
|   federation: "Federace" | ||||
|   notes: "Příspěvky" | ||||
|   users: "Uživatelé" | ||||
|   drive: "Disk" | ||||
| @@ -621,11 +904,20 @@ admin/views/charts.vue: | ||||
|   charts: | ||||
|     federation-instances: "Počet instancí: zvýšení/snížení" | ||||
|     federation-instances-total: "Celkový počet instancí" | ||||
|     notes-total: "Celkem příspěvků" | ||||
|     users-total: "Celkem uživatelů" | ||||
|     active-users: "Aktivní uživatelé" | ||||
|     network-requests: "Požadavek" | ||||
|     network-time: "Doba odezvy" | ||||
|     network-usage: "Síťový provoz" | ||||
| admin/views/drive.vue: | ||||
|   operation: "Operace" | ||||
|   fileid-or-url: "ID nebo URL souboru" | ||||
|   file-not-found: "Soubor nebyl nalezen" | ||||
|   sort: | ||||
|     title: "Seřadit" | ||||
|     createdAtAsc: "Věk - od nejstaršího" | ||||
|     createdAtDesc: "Věk - od nejmladšího" | ||||
|     sizeAsc: "Velikost - od nejmenších" | ||||
|     sizeDesc: "Velikost – od největších" | ||||
|   origin: | ||||
| @@ -642,8 +934,17 @@ admin/views/users.vue: | ||||
|   reset-password: "Resetovat heslo" | ||||
|   reset-password-confirm: "Opravdu chcete resetovat Vaše heslo?" | ||||
|   password-updated: "Heslo je nyní \"{password}\"" | ||||
|   verify: "Ověřit účet" | ||||
|   verify-confirm: "Chcete aby toto byl ověřený účet?" | ||||
|   verified: "Účet se nyní ověřuje" | ||||
|   unverify: "Zrušit ověření účtu" | ||||
|   unverify-confirm: "Opravdu chcete zrušit designaci \"ověřený účet\"?" | ||||
|   unverified: "Ruší se potvrzení účtu" | ||||
|   update-remote-user: "Aktualizovat informace o vzdáleném účtu" | ||||
|   users: | ||||
|     title: "Uživatel" | ||||
|     state: | ||||
|       all: "Všechny" | ||||
|       moderator: "Moderátor" | ||||
|       adminOrModerator: "Admin/Moderátor" | ||||
|       verified: "Ověřený účet" | ||||
| @@ -695,61 +996,182 @@ admin/views/federation.vue: | ||||
|   users: "Uživatelé" | ||||
|   status: "Status" | ||||
|   latest-request-received-at: "Poslední požadavek přijat" | ||||
|   block: "Blokováno" | ||||
|   block: "Blokován" | ||||
|   instances: "Instance" | ||||
|   states: | ||||
|     blocked: "Blokováno" | ||||
|     all: "Všechny" | ||||
|     blocked: "Blokován" | ||||
|     not-responding: "Bez odpovědi" | ||||
|     marked-as-closed: "Označeno jako uzavřené" | ||||
|   charts: "Graf" | ||||
|   chart-srcs: | ||||
|     requests: "Požadavek" | ||||
|     users-total: "Celkem uživatelů" | ||||
|     notes-total: "Celkem příspěvků" | ||||
|   chart-spans: | ||||
|     hour: "za hodinu" | ||||
|     day: "za den" | ||||
| desktop/views/pages/welcome.vue: | ||||
|   about: "O Misskey" | ||||
|   timeline: "Časová osa" | ||||
|   announcements: "Oznámení" | ||||
|   photos: "Nedávné obrázky" | ||||
|   powered-by-misskey: "Běží na <b>Misskey</b>." | ||||
|   info: "Informace" | ||||
| desktop/views/pages/drive.vue: | ||||
|   title: "Misskey Disk" | ||||
| desktop/views/pages/note.vue: | ||||
|   prev: "Předchozí příspěvěk" | ||||
|   next: "Následující příspěvek" | ||||
| desktop/views/pages/selectdrive.vue: | ||||
|   title: "Vyberte soubor(y)" | ||||
|   ok: "OK" | ||||
|   cancel: "Zrušit" | ||||
|   upload: "Nahrajte soubory z vašeho zařízení" | ||||
| desktop/views/pages/search.vue: | ||||
|   not-available: "Vyhledávání je vypnuté pro tuto instanci." | ||||
|   not-found: "Pro '{q}' nebyly nalezeny žádné příspěvky." | ||||
| desktop/views/pages/share.vue: | ||||
|   share-with: "Sdílet na {name}" | ||||
| desktop/views/pages/tag.vue: | ||||
|   no-posts-found: "Nebyly nalezeny žádné příspěvky s \"{q}\"." | ||||
| desktop/views/pages/user-list.users.vue: | ||||
|   users: "Uživatel" | ||||
|   add-user: "Přidat uživatele" | ||||
|   username: "Přezdívka" | ||||
| desktop/views/pages/user/user.followers-you-know.vue: | ||||
|   loading: "Nahrávám..." | ||||
|   loading: "Načítám..." | ||||
| desktop/views/pages/user/user.friends.vue: | ||||
|   loading: "Nahrávám..." | ||||
|   title: "Častá zmínění" | ||||
|   loading: "Načítám..." | ||||
|   no-users: "Žádná častá zmínění" | ||||
| desktop/views/pages/user/user.photos.vue: | ||||
|   loading: "Nahrávám..." | ||||
|   title: "Fotky" | ||||
|   loading: "Načítám..." | ||||
|   no-photos: "Žádné obrázky" | ||||
| desktop/views/pages/user/user.header.vue: | ||||
|   posts: "Poznámky" | ||||
|   month: "Po" | ||||
|   day: "Ne" | ||||
| desktop/views/widgets/messaging.vue: | ||||
|   title: "Zprávy" | ||||
| desktop/views/widgets/notifications.vue: | ||||
|   title: "Oznámení" | ||||
| desktop/views/widgets/polls.vue: | ||||
|   title: "Ankety" | ||||
| desktop/views/widgets/users.vue: | ||||
|   title: "Doporučení uživatelé" | ||||
| mobile/views/components/drive.vue: | ||||
|   used: "využito" | ||||
|   file-count: "Soubor(ů)" | ||||
|   folder-is-empty: "Tato složka je prázdná" | ||||
|   deletion-alert: "Omlouváme se, ale mazání složek ještě nebylo implementováno." | ||||
|   folder-name: "Název složky" | ||||
|   url-prompt: "URL adresa souboru, který chcete nahrát" | ||||
|   uploading: "Byl zahájen upload. Může chvilku trvat než bude dokončen." | ||||
| mobile/views/components/drive-file-chooser.vue: | ||||
|   select-file: "Vybrat soubory" | ||||
| mobile/views/components/drive-folder-chooser.vue: | ||||
|   select-folder: "Vyberte složku" | ||||
| mobile/views/components/drive.file-detail.vue: | ||||
|   download: "Stáhnout" | ||||
|   rename: "Přejmenovat" | ||||
|   move: "Přesunout" | ||||
|   hash: "Hash (md5)" | ||||
|   exif: "EXIF" | ||||
| mobile/views/components/media-video.vue: | ||||
|   click-to-show: "Klikněte pro zobrazení" | ||||
| common/views/components/follow-button.vue: | ||||
|   follow-processing: "Zpracovávám" | ||||
| mobile/views/components/note.vue: | ||||
|   private: "Tento příspěvek je soukromý" | ||||
|   deleted: "Tento příspěvek byl odstraněn" | ||||
|   location: "Lokace" | ||||
| mobile/views/components/note-detail.vue: | ||||
|   reply: "Odpovědět" | ||||
|   reaction: "Reakce" | ||||
|   private: "Tento příspěvek je soukromý" | ||||
|   deleted: "Tento příspěvek byl odstraněn" | ||||
|   location: "Lokace" | ||||
| mobile/views/components/note-preview.vue: | ||||
|   admin: "admin" | ||||
|   bot: "bot" | ||||
|   cat: "kočka" | ||||
| mobile/views/components/note-sub.vue: | ||||
|   admin: "admin" | ||||
|   bot: "bot" | ||||
|   cat: "kočka" | ||||
| mobile/views/components/post-form.vue: | ||||
|   add-visible-user: "Přidat uživatele" | ||||
|   submit: "Příspěvek" | ||||
|   reply: "Odpovědět" | ||||
|   renote: "Renotovat" | ||||
|   reply-placeholder: "Odpovědět na tento příspěvěk" | ||||
|   location-alert: "Vaše zařízení nepodporuje lokační službu" | ||||
|   error: "Chyba" | ||||
|   username-prompt: "Zadejte uživatelské jméno" | ||||
| mobile/views/components/sub-note-content.vue: | ||||
|   private: "Tento příspěvek je soukromý" | ||||
|   deleted: "Tento příspěvek byl odstraněn" | ||||
|   poll: "Ankety" | ||||
| mobile/views/components/ui.header.vue: | ||||
|   welcome-back: "Vítejte zpátky," | ||||
|   adjective: "Pán" | ||||
| mobile/views/components/ui.nav.vue: | ||||
|   timeline: "Časová osa" | ||||
|   notifications: "Oznámení" | ||||
|   search: "Vyhledávání" | ||||
|   user-lists: "Seznamy" | ||||
|   widgets: "Widgety" | ||||
|   game: "Hry" | ||||
|   admin: "Administrace" | ||||
|   about: "O Misskey" | ||||
| mobile/views/pages/user-lists.vue: | ||||
|   title: "Seznamy" | ||||
| mobile/views/pages/signup.vue: | ||||
|   lets-start: "Váš účet je připraven! 📦" | ||||
| mobile/views/pages/home.vue: | ||||
|   home: "Domů" | ||||
|   local: "Lokální" | ||||
|   global: "Globální" | ||||
|   mentions: "Zmínění" | ||||
|   messages: "Zprávy" | ||||
| mobile/views/pages/tag.vue: | ||||
|   no-posts-found: "Nebyly nalezeny žádné příspěvky s \"{q}\"." | ||||
| mobile/views/pages/widgets.vue: | ||||
|   add-widget: "Přidat" | ||||
|   customization-tips: "Tipy pro přizpůsobení" | ||||
| mobile/views/pages/widgets/activity.vue: | ||||
|   activity: "Aktivita" | ||||
| mobile/views/pages/share.vue: | ||||
|   share-with: "Sdílet na {name}" | ||||
| mobile/views/pages/received-follow-requests.vue: | ||||
|   accept: "Přijmout" | ||||
|   reject: "Odmítnout" | ||||
| mobile/views/pages/note.vue: | ||||
|   prev: "Předchozí příspěvěk" | ||||
|   next: "Následující příspěvek" | ||||
| mobile/views/pages/games/reversi.vue: | ||||
|   reversi: "Reversi" | ||||
| mobile/views/pages/search.vue: | ||||
|   not-found: "Pro '{q}' nebyly nalezeny žádné příspěvky." | ||||
| mobile/views/pages/selectdrive.vue: | ||||
|   select-file: "Vybrat soubory" | ||||
| mobile/views/pages/user/home.vue: | ||||
|   activity: "Aktivita" | ||||
|   frequently-replied-users: "Častá zmínění" | ||||
| mobile/views/pages/user/home.photos.vue: | ||||
|   no-photos: "Žádné obrázky" | ||||
| deck: | ||||
|   widgets: "Widgety" | ||||
|   home: "Domů" | ||||
|   local: "Lokální" | ||||
|   hashtag: "Hashtagy" | ||||
|   global: "Globální" | ||||
|   mentions: "Zmínění" | ||||
|   notifications: "Oznámení" | ||||
|   list: "Seznamy" | ||||
|   select-list: "Vyberte seznam" | ||||
|   swap-left: "Posunout doleva" | ||||
|   swap-right: "Posunout doprava" | ||||
|   rename: "Přejmenovat" | ||||
| @@ -758,4 +1180,10 @@ deck/deck.user-column.vue: | ||||
| dev/views/new-app.vue: | ||||
|   app-name-desc: "Jméno vaší aplikace" | ||||
|   app-desc: "Stručný popis nebo představení vaší aplikace." | ||||
|   account-read: "Zobrazit informace účtu" | ||||
|   note-write: "Odeslat." | ||||
|   reaction-write: "Přidat nebo odebrat reakce." | ||||
|   following-write: "Sledovat a přestat sledovat" | ||||
|   drive-read: "Přečíst váš Disk" | ||||
|   notification-read: "Sledovat oznámení." | ||||
|   notification-write: "Zpravovat notifikace." | ||||
|   | ||||
| @@ -270,7 +270,6 @@ common/views/components/note-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "Stimme für '{}'" | ||||
|   vote-count: "{} Stimmen" | ||||
|   total-users: "{} Nutzer haben abgestimmt" | ||||
|   vote: "Abstimmen" | ||||
|   show-result: "Zeige Ergebnis" | ||||
|   voted: "Abgestimmt" | ||||
| @@ -280,6 +279,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "Diese Auswahl entfernen" | ||||
|   add: "+ Eine Auswahl hinzufügen" | ||||
|   destroy: "Diese Abstimmung löschen" | ||||
|   day: "So" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Wähle eine Reaktion aus" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -339,6 +339,8 @@ common/views/components/profile-editor.vue: | ||||
|   banner: "Banner" | ||||
|   save: "Speichern" | ||||
|   export: "Exportieren" | ||||
|   export-targets: | ||||
|     user-lists: "Listen" | ||||
|   enter-password: "Bitte Passwort eingeben" | ||||
| common/views/widgets/broadcast.vue: | ||||
|   fetching: "Laden" | ||||
|   | ||||
| @@ -24,8 +24,8 @@ common: | ||||
|   application-authorization: "Application authorizations" | ||||
|   close: "Close" | ||||
|   do-not-copy-paste: "Please do not enter or paste the code here. Account may be compromised." | ||||
|   load-more: "Load more" | ||||
|   enter-password: "Please enter the Password" | ||||
|   load-more: "Read more" | ||||
|   enter-password: "Enter your password" | ||||
|   2fa: "Two-factor authentication" | ||||
|   customize-home: "Customize home layout" | ||||
|   featured-notes: "Featured notes" | ||||
| @@ -114,7 +114,7 @@ common: | ||||
|     a: "What are you doing?" | ||||
|     b: "What's happening?" | ||||
|     c: "What’s on your mind?" | ||||
|     d: "Would you post any words?" | ||||
|     d: "What do you want to say?" | ||||
|     e: "Write here" | ||||
|     f: "Waiting for your writing." | ||||
|   settings: "Settings" | ||||
| @@ -223,8 +223,8 @@ common: | ||||
|   search: "Search" | ||||
|   delete: "Delete" | ||||
|   loading: "Loading" | ||||
|   ok: "It's OK" | ||||
|   cancel: "Quit" | ||||
|   ok: "Confirm" | ||||
|   cancel: "Exit" | ||||
|   update-available-title: "Update available" | ||||
|   update-available: "A new version of Misskey is now available({newer}, the current version is {current}). Reload the page to apply updates." | ||||
|   my-token-regenerated: "Your token has been regenerated, so you will be signed out." | ||||
| @@ -285,7 +285,7 @@ auth/views/form.vue: | ||||
|   account-read: "View account information." | ||||
|   account-write: "Modify account information." | ||||
|   note-write: "Post." | ||||
|   like-write: "React to posts." | ||||
|   like-write: "Express yourself about this post." | ||||
|   following-write: "Follow and unfollow." | ||||
|   drive-read: "Read your drive." | ||||
|   drive-write: "Upload/delete files in your drive." | ||||
| @@ -304,7 +304,7 @@ auth/views/index.vue: | ||||
|   error: "Session does not exist." | ||||
|   sign-in: "Please sign in." | ||||
| common/views/pages/explore.vue: | ||||
|   verified-users: "Verified accounts" | ||||
|   verified-users: "Official accounts" | ||||
|   popular-users: "Popular users" | ||||
|   recently-updated-users: "Recently active users" | ||||
|   recently-registered-users: "Users who joined recently" | ||||
| @@ -489,16 +489,35 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "Vote for '{}'" | ||||
|   vote-count: "{} votes" | ||||
|   total-users: "{} users voted" | ||||
|   total-votes: "{} votes in total" | ||||
|   vote: "Vote" | ||||
|   show-result: "Show results" | ||||
|   voted: "Voted" | ||||
|   closed: "Ended" | ||||
|   remaining-days: "{d} days, {h} hours remain" | ||||
|   remaining-hours: "{h} hours, and {m} minutes remain" | ||||
|   remaining-minutes: "{m} minutes, and {s} seconds remaining" | ||||
|   remaining-seconds: "{s} seconds remaining" | ||||
| common/views/components/poll-editor.vue: | ||||
|   no-only-one-choice: "At least two choices are required" | ||||
|   choice-n: "Choice {}" | ||||
|   remove: "Delete the choice" | ||||
|   add: "+ Add a choice" | ||||
|   destroy: "Discard the poll" | ||||
|   multiple: "More than one answer is allowed" | ||||
|   expiration: "Valid until" | ||||
|   infinite: "Indefinitely" | ||||
|   at: "Date and time pick" | ||||
|   after: "Progression specifics" | ||||
|   no-more: "You cannot add any more" | ||||
|   deadline-date: "Finish date" | ||||
|   deadline-time: "Time duration" | ||||
|   interval: "Duration" | ||||
|   unit: "Unit" | ||||
|   second: "Seconds" | ||||
|   minute: "Minutes" | ||||
|   hour: "Hours" | ||||
|   day: "S" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Send a reaction" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -520,7 +539,7 @@ common/views/components/signin.vue: | ||||
|   signin-with-twitter: "Log in with Twitter" | ||||
|   signin-with-github: "Sign in with GitHub" | ||||
|   signin-with-discord: "Sign in with Discord" | ||||
|   login-failed: "Log in failed. Make sure you have entered your correct username and password." | ||||
|   login-failed: "Logging in has failed. Make sure you have entered the correct username and password." | ||||
| common/views/components/signup.vue: | ||||
|   invitation-code: "Invitation code" | ||||
|   invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>." | ||||
| @@ -633,6 +652,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "List of followers" | ||||
|     mute-list: "List of muted accounts" | ||||
|     blocking-list: "List of blocked accounts" | ||||
|     user-lists: "Lists" | ||||
|   export-requested: "You have requested an export. This may take a while. After the export is complete, the resulting file will be added to the drive." | ||||
|   enter-password: "Please enter your password" | ||||
|   danger-zone: "Cautious options" | ||||
| @@ -1196,7 +1216,7 @@ admin/views/users.vue: | ||||
|       updatedAtAsc: "Last Updated (Ascending)" | ||||
|       updatedAtDesc: "Last Updated (Descending)" | ||||
|     state: | ||||
|       title: "Status" | ||||
|       title: "Sort" | ||||
|       all: "All" | ||||
|       admin: "Administrator" | ||||
|       moderator: "Moderator" | ||||
| @@ -1257,7 +1277,7 @@ admin/views/federation.vue: | ||||
|   users: "Users" | ||||
|   following: "Following" | ||||
|   followers: "Followers" | ||||
|   status: "Status" | ||||
|   status: "Statuses" | ||||
|   latest-request-sent-at: "Time of last request sent" | ||||
|   latest-request-received-at: "Last request received at" | ||||
|   remove-all-following: "Withold all followers" | ||||
| @@ -1273,19 +1293,19 @@ admin/views/federation.vue: | ||||
|     caughtAtDesc: "Date of discovery (Descending)" | ||||
|     lastCommunicatedAtAsc: "The date and time of the older interactions" | ||||
|     lastCommunicatedAtDesc: "The date and time of the newer interactions" | ||||
|     notesAsc: "Order by least Notes posted" | ||||
|     notesDesc: "Order by most Notes posted" | ||||
|     notesAsc: "Least Notes posted" | ||||
|     notesDesc: "Most Notes posted" | ||||
|     usersAsc: "Less followers" | ||||
|     usersDesc: "More followers" | ||||
|     followingAsc: "Least followed" | ||||
|     followingDesc: "Has more followers" | ||||
|     followersAsc: "Sort by having less followers" | ||||
|     followersDesc: "Sort by the larger number of followers" | ||||
|     followingDesc: "Most followed" | ||||
|     followersAsc: "Having less followers" | ||||
|     followersDesc: "The largest number of followers" | ||||
|     driveUsageAsc: "Least storage used" | ||||
|     driveUsageDesc: "Most storage used" | ||||
|     driveFilesAsc: "By the smallest number of files stored on Drive" | ||||
|     driveFilesDesc: "By the largest number of files stored on Drive" | ||||
|   state: "Status" | ||||
|     driveFilesAsc: "Least files stored on Drive" | ||||
|     driveFilesDesc: "The largest number of files stored on Drive" | ||||
|   state: "Sort" | ||||
|   states: | ||||
|     all: "All" | ||||
|     blocked: "Blocked" | ||||
| @@ -1353,7 +1373,7 @@ desktop/views/pages/user/user.header.vue: | ||||
|   following: "Following" | ||||
|   followers: "Followers" | ||||
|   is-bot: "This account is a Bot" | ||||
|   no-description: "The user has not written their profile introduction" | ||||
|   no-description: "This user has not written their profile introduction" | ||||
|   years-old: "{age} years old" | ||||
|   year: "/" | ||||
|   month: "/" | ||||
| @@ -1365,7 +1385,7 @@ desktop/views/pages/user/user.timeline.vue: | ||||
|   with-media: "Media" | ||||
|   my-posts: "My posts" | ||||
| desktop/views/widgets/messaging.vue: | ||||
|   title: "Message" | ||||
|   title: "Messaging" | ||||
| desktop/views/widgets/notifications.vue: | ||||
|   title: "Notifications" | ||||
| desktop/views/widgets/polls.vue: | ||||
|   | ||||
| @@ -103,6 +103,32 @@ common: | ||||
|     tags: "Etiquetas" | ||||
|     blocking: "Bloquear" | ||||
|     password: "Contraseña" | ||||
|     use-os-default-emojis: "Usar los emoticonos estándar del sistema operativo" | ||||
|     line-width: "Grosor de línea" | ||||
|     line-width-thick: "Grosor" | ||||
|     font-size: "Tamaño del texto" | ||||
|     font-size-x-small: "Muy pequeño" | ||||
|     font-size-small: "Pequeño" | ||||
|     font-size-medium: "Normal" | ||||
|     font-size-large: "Grande" | ||||
|     font-size-x-large: "Muy grande" | ||||
|     deck-column-align: "Alineamiento de las columnas" | ||||
|     deck-column-align-center: "Centrar" | ||||
|     deck-column-align-left: "Izquierda" | ||||
|     deck-column-align-flexible: "Flexible" | ||||
|     deck-column-width: "Ancho de las columnas" | ||||
|     deck-column-width-narrow: "Estrecho" | ||||
|     deck-column-width-narrower: "Un poco estrecho" | ||||
|     deck-column-width-normal: "Normal" | ||||
|     deck-column-width-wider: "Un poco ancho" | ||||
|     deck-column-width-wide: "Ancho" | ||||
|     use-shadow: "Usar sombras en la Interfaz de Usuario" | ||||
|     rounded-corners: "Esquinas redondeadas en la Interfaz de Usuario" | ||||
|     circle-icons: "Usar iconos circulares" | ||||
|     contrasted-acct: "Añadir contraste al nombre de usuario" | ||||
|     wallpaper: "Fondo de pantalla" | ||||
|     choose-wallpaper: "Escoge un fondo de pantalla" | ||||
|     navbar-position-left: "Izquierda" | ||||
|   search: "Buscar" | ||||
|   delete: "eliminar" | ||||
|   loading: "cargando" | ||||
| @@ -303,7 +329,6 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "'{}' para votar" | ||||
|   vote-count: "{} votos" | ||||
|   total-users: "{} usuario(s) que ha(n) votado" | ||||
|   vote: "Vota" | ||||
|   show-result: "Mostrar resultados" | ||||
|   voted: "Votado" | ||||
| @@ -313,6 +338,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "Borra la opción" | ||||
|   add: "+ Añade una opción" | ||||
|   destroy: "Cancelar la encuesta" | ||||
|   day: "domingo" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Escoge una reacción" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -398,6 +424,7 @@ common/views/components/profile-editor.vue: | ||||
|   export-targets: | ||||
|     mute-list: "Silenciar" | ||||
|     blocking-list: "Bloquear" | ||||
|     user-lists: "Listas" | ||||
|   enter-password: "Escribe una contraseña" | ||||
| common/views/components/user-list-editor.vue: | ||||
|   users: "Usuarios" | ||||
|   | ||||
| @@ -383,7 +383,6 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "Voter pour '{}'" | ||||
|   vote-count: "{} votes" | ||||
|   total-users: "{} utilisateur·rice·s ont voté" | ||||
|   vote: "Vote" | ||||
|   show-result: "Montrer les résultats" | ||||
|   voted: "Voté" | ||||
| @@ -393,6 +392,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "Supprimer ce choix" | ||||
|   add: "+ Ajouter un choix" | ||||
|   destroy: "Annuler ce sondage" | ||||
|   day: "D" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Choisissez votre réaction" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -527,6 +527,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "Liste des abonnements" | ||||
|     mute-list: "Liste des comptes mis en sourdine" | ||||
|     blocking-list: "Liste des comptes bloqués" | ||||
|     user-lists: "Listes" | ||||
|   export-requested: "Vous avez demandé une exportation. Cela peut prendre un certain temps. Une fois l'exportation terminée, le fichier résultant sera ajouté dans le Drive." | ||||
|   enter-password: "Veuillez saisir votre mot de passe" | ||||
|   danger-zone: "Zone de danger" | ||||
|   | ||||
| @@ -5,22 +5,46 @@ | ||||
| const fs = require('fs'); | ||||
| const yaml = require('js-yaml'); | ||||
|  | ||||
| const langs = ['de-DE', 'en-US', 'fr-FR', 'ja-JP', 'ja-KS', 'pl-PL', 'es-ES', 'nl-NL', 'zh-CN', 'ko-KR']; | ||||
| const merge = (...args) => args.reduce((a, c) => ({ | ||||
| 	...a, | ||||
| 	...c, | ||||
| 	...Object.entries(a) | ||||
| 		.filter(([k]) => c && typeof c[k] === 'object') | ||||
| 		.reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {}) | ||||
| }), {}); | ||||
|  | ||||
| const loadLocale = lang => yaml.safeLoad(fs.readFileSync(`${__dirname}/${lang}.yml`, 'utf-8')); | ||||
| const locales = langs | ||||
| 	.map(lang => [lang, loadLocale(lang)]) | ||||
| 	.map(([lang, locale], _, locales) => { | ||||
| 		switch (lang) { | ||||
| 			case 'ja-JP': return [lang, locale]; | ||||
| 			case 'en-US': return [lang, { ...locales['ja-JP'], ...locale }]; | ||||
| 			default: return [lang, { | ||||
| 				...(lang.startsWith('ja-') ? {} : locales['en-US']), | ||||
| 				...locales['ja-JP'], | ||||
| 				...locale | ||||
| 		}]; | ||||
| const languages = [ | ||||
| 	'de-DE', | ||||
| 	'en-US', | ||||
| 	'es-ES', | ||||
| 	'fr-FR', | ||||
| 	'ja-JP', | ||||
| 	'ja-KS', | ||||
| 	'ko-KR', | ||||
| 	'nl-NL', | ||||
| 	'pl-PL', | ||||
| 	'zh-CN', | ||||
| ]; | ||||
|  | ||||
| const primaries = { | ||||
| 	'ja': 'JP', | ||||
| 	'zh': 'CN', | ||||
| }; | ||||
|  | ||||
| const locales = languages.reduce((a, c) => (a[c] = yaml.safeLoad(fs.readFileSync(`${__dirname}/${c}.yml`, 'utf-8')) || {}, a), {}); | ||||
|  | ||||
| module.exports = Object.entries(locales) | ||||
| 	.reduce((a, [k ,v]) => (a[k] = (() => { | ||||
| 		const [lang] = k.split('-'); | ||||
| 		switch (k) { | ||||
| 			case 'ja-JP': return v; | ||||
| 			case 'ja-KS': | ||||
| 			case 'en-US': return merge(locales['ja-JP'], v); | ||||
| 			default: return merge( | ||||
| 				locales['ja-JP'], | ||||
| 				locales['en-US'], | ||||
| 				locales[`${lang}-${primaries[lang]}`] || {}, | ||||
| 				v | ||||
| 			); | ||||
| 		} | ||||
| 	}) | ||||
| 	.map(([lang, locale]) => ({ [lang]: loadLocale(lang) })); | ||||
|  | ||||
| module.exports = locales.reduce((a, b) => ({ ...a, ...b })); | ||||
| 	})(), a), {}); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| --- | ||||
| meta: | ||||
|   lang: "In Italiano" | ||||
|   lang: "Italiano" | ||||
| common: | ||||
|   misskey: "A ⭐ of the fediverse" | ||||
|   about-title: "A ⭐ of the fediverse." | ||||
|   | ||||
| @@ -527,10 +527,15 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "「{}」に投票する" | ||||
|   vote-count: "{}票" | ||||
|   total-users: "{}人が投票" | ||||
|   total-votes: "計{}票" | ||||
|   vote: "投票する" | ||||
|   show-result: "結果を見る" | ||||
|   voted: "投票済み" | ||||
|   closed: "終了済み" | ||||
|   remaining-days: "終了まであと{d}日{h}時間" | ||||
|   remaining-hours: "終了まであと{h}時間{m}分" | ||||
|   remaining-minutes: "終了まであと{m}分{s}秒" | ||||
|   remaining-seconds: "終了まであと{s}秒" | ||||
|  | ||||
| common/views/components/poll-editor.vue: | ||||
|   no-only-one-choice: "アンケートには、選択肢が最低2つ必要です" | ||||
| @@ -538,6 +543,20 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "この選択肢を削除" | ||||
|   add: "+選択肢を追加" | ||||
|   destroy: "アンケートを破棄" | ||||
|   multiple: "複数回答可" | ||||
|   expiration: "期限" | ||||
|   infinite: "無期限" | ||||
|   at: "日時指定" | ||||
|   after: "経過指定" | ||||
|   no-more: "これ以上追加できません" | ||||
|   deadline-date: "期日" | ||||
|   deadline-time: "時間" | ||||
|   interval: "期間" | ||||
|   unit: "単位" | ||||
|   second: "秒" | ||||
|   minute: "分" | ||||
|   hour: "時間" | ||||
|   day: "日" | ||||
|  | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "リアクションを選択" | ||||
| @@ -687,6 +706,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "フォロー" | ||||
|     mute-list: "ミュート" | ||||
|     blocking-list: "ブロック" | ||||
|     user-lists: "リスト" | ||||
|   export-requested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、ドライブにファイルが追加されます。" | ||||
|   enter-password: "パスワードを入力してください" | ||||
|   danger-zone: "危険な設定" | ||||
|   | ||||
| @@ -344,7 +344,6 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "「{}」に投票や!" | ||||
|   vote-count: "{}票" | ||||
|   total-users: "{}人が投票" | ||||
|   vote: "投票するで" | ||||
|   show-result: "結果を見よか" | ||||
|   voted: "投票済みや" | ||||
| @@ -354,6 +353,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "この選択肢を消すで" | ||||
|   add: "+選択肢を追加" | ||||
|   destroy: "アンケートをほかそ" | ||||
|   day: "日" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "リアクション、どれにするんや?" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -480,6 +480,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "フォロー" | ||||
|     mute-list: "ミュート" | ||||
|     blocking-list: "ブロック" | ||||
|     user-lists: "リスト" | ||||
|   enter-password: "パスワードを入れてや" | ||||
| common/views/components/user-list-editor.vue: | ||||
|   users: "ユーザー" | ||||
|   | ||||
| @@ -489,16 +489,35 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "\"{}\"에 투표하기" | ||||
|   vote-count: "{}표" | ||||
|   total-users: "{}명이 투표" | ||||
|   total-votes: "총 {}표" | ||||
|   vote: "투표하기" | ||||
|   show-result: "결과 보기" | ||||
|   voted: "투표함" | ||||
|   closed: "종료됨" | ||||
|   remaining-days: "종료까지 앞으로 {d}일 {h}시간" | ||||
|   remaining-hours: "종료까지 앞으로 {h}시간 {m}분" | ||||
|   remaining-minutes: "종료까지 앞으로 {m}분 {s}초" | ||||
|   remaining-seconds: "종료까지 앞으로 {s}초" | ||||
| common/views/components/poll-editor.vue: | ||||
|   no-only-one-choice: "투표에는 선택지가 최소한 두 개 필요합니다" | ||||
|   choice-n: "선택지 {}" | ||||
|   remove: "이 선택지를 제거" | ||||
|   add: "+선택지 추가" | ||||
|   destroy: "투표 제거" | ||||
|   multiple: "복수 응답 가능" | ||||
|   expiration: "기한" | ||||
|   infinite: "무기한" | ||||
|   at: "일시 지정" | ||||
|   after: "기간 지정" | ||||
|   no-more: "더 이상 추가할 수 없습니다" | ||||
|   deadline-date: "기한" | ||||
|   deadline-time: "시간" | ||||
|   interval: "기간" | ||||
|   unit: "단위" | ||||
|   second: "초" | ||||
|   minute: "분" | ||||
|   hour: "시간" | ||||
|   day: "일" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "반응 선택" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -633,6 +652,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "팔로잉" | ||||
|     mute-list: "뮤트" | ||||
|     blocking-list: "차단" | ||||
|     user-lists: "리스트" | ||||
|   export-requested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 드라이브에 파일이 추가됩니다." | ||||
|   enter-password: "비밀번호를 입력하여 주십시오" | ||||
|   danger-zone: "위험한 설정" | ||||
|   | ||||
| @@ -131,7 +131,6 @@ common/views/components/note-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "Stemmen op '{}'" | ||||
|   vote-count: "{} stemmen" | ||||
|   total-users: "{} gebruikers hebben gestemd" | ||||
|   vote: "Stemmen" | ||||
|   show-result: "Resultaten tonen" | ||||
|   voted: "Gestemd" | ||||
| @@ -141,6 +140,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "Deze keuze verwijderen" | ||||
|   add: "+ Keuze toevoegen" | ||||
|   destroy: "Deze peiling vernietigen" | ||||
|   day: "Z" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Kies een reactie" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -196,6 +196,7 @@ common/views/components/profile-editor.vue: | ||||
|   banner: "Omslagfoto" | ||||
|   export-targets: | ||||
|     following-list: "Volgend" | ||||
|     user-lists: "Lijsten" | ||||
|   enter-password: "Voer het wachtwoord in" | ||||
| common/views/components/user-list-editor.vue: | ||||
|   users: "Gebruiker" | ||||
|   | ||||
| @@ -151,6 +151,7 @@ common/views/components/poll.vue: | ||||
|   voted: "Stemt" | ||||
| common/views/components/poll-editor.vue: | ||||
|   choice-n: "Valg {}" | ||||
|   day: "S" | ||||
| common/views/components/signin.vue: | ||||
|   username: "Brukernavn" | ||||
|   password: "Passord" | ||||
| @@ -186,6 +187,7 @@ common/views/components/profile-editor.vue: | ||||
|   save: "Lagre" | ||||
|   export-targets: | ||||
|     following-list: "Følger" | ||||
|     user-lists: "Lister" | ||||
| common/views/components/user-list-editor.vue: | ||||
|   users: "Bruker" | ||||
| common/views/widgets/broadcast.vue: | ||||
|   | ||||
| @@ -26,6 +26,7 @@ common: | ||||
|   dark-mode: "Tryb ciemny" | ||||
|   signin: "Zaloguj się" | ||||
|   signup: "Rejestracja" | ||||
|   signout: "Wyloguj się" | ||||
|   got-it: "Rozumiem!" | ||||
|   customization-tips: | ||||
|     title: "Wskazówki o dostosowywaniu" | ||||
| @@ -120,7 +121,24 @@ common: | ||||
|     other: "Inne" | ||||
|     appearance: "Wygląd" | ||||
|     behavior: "Zachowanie" | ||||
|     note-visibility: "Widoczność wpisów" | ||||
|     line-width-thin: "Cienka" | ||||
|     line-width-normal: "Normalna" | ||||
|     line-width-thick: "Gruba" | ||||
|     font-size: "Rozmiar tekstu" | ||||
|     font-size-medium: "Normalna" | ||||
|     font-size-x-large: "Duży" | ||||
|     deck-column-align-center: "Po środku" | ||||
|     deck-column-align-left: "Z lewej" | ||||
|     deck-column-align-flexible: "Elastyczne" | ||||
|     deck-column-width: "Szerokość kolumn w talii" | ||||
|     deck-column-width-narrow: "Wąska" | ||||
|     deck-column-width-narrower: "Trochę wąska" | ||||
|     deck-column-width-normal: "Normalna" | ||||
|     deck-column-width-wider: "Trochę szerokie" | ||||
|     deck-column-width-wide: "Szeroka" | ||||
|     timeline: "Oś czasu" | ||||
|     navbar-position-left: "Z lewej" | ||||
|   search: "Szukaj" | ||||
|   delete: "Usuń" | ||||
|   loading: "Ładowanie" | ||||
| @@ -346,7 +364,6 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "Zagłosuj na '{}'" | ||||
|   vote-count: "{} głosów" | ||||
|   total-users: "{} głosujących" | ||||
|   vote: "Zagłosuj" | ||||
|   show-result: "Pokaż wyniki" | ||||
|   voted: "Zagłosowano" | ||||
| @@ -356,6 +373,7 @@ common/views/components/poll-editor.vue: | ||||
|   remove: "Usuń tą opcję" | ||||
|   add: "+ Dodaj opcję" | ||||
|   destroy: "Usuń tę ankietę" | ||||
|   day: "N" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "Wybierz reakcję" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -476,6 +494,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "Śledzeni" | ||||
|     mute-list: "Wycisz" | ||||
|     blocking-list: "Zablokuj" | ||||
|     user-lists: "Listy" | ||||
|   enter-password: "Wprowadź hasło" | ||||
| common/views/components/user-list-editor.vue: | ||||
|   users: "Użytkownicy" | ||||
|   | ||||
| @@ -158,6 +158,8 @@ common/views/components/messaging.vue: | ||||
|   you: "Você" | ||||
| common/views/components/note-menu.vue: | ||||
|   delete: "Apagar" | ||||
| common/views/components/poll-editor.vue: | ||||
|   day: "Dom" | ||||
| common/views/components/visibility-chooser.vue: | ||||
|   followers: "Seguidores" | ||||
| common/views/components/profile-editor.vue: | ||||
|   | ||||
| @@ -127,6 +127,8 @@ common/views/components/connect-failed.vue: | ||||
|   title: "Невозможно подключиться к серверу" | ||||
| common/views/components/cw-button.vue: | ||||
|   poll: "Голосования" | ||||
| common/views/components/poll-editor.vue: | ||||
|   day: "Вс" | ||||
| common/views/widgets/memo.vue: | ||||
|   title: "Заметка" | ||||
| desktop/views/components/sub-note-content.vue: | ||||
|   | ||||
| @@ -489,16 +489,35 @@ common/views/components/user-menu.vue: | ||||
| common/views/components/poll.vue: | ||||
|   vote-to: "为\"{}\"投票" | ||||
|   vote-count: "{}票" | ||||
|   total-users: "{} 人投票" | ||||
|   total-votes: "总票数{}" | ||||
|   vote: "投票" | ||||
|   show-result: "显示结果" | ||||
|   voted: "已投票" | ||||
|   closed: "已截止" | ||||
|   remaining-days: "{d}天{h}小时后截止" | ||||
|   remaining-hours: "{h}小时{m}分后截止" | ||||
|   remaining-minutes: "{m}分{s}秒后截止" | ||||
|   remaining-seconds: "{s}秒后截止" | ||||
| common/views/components/poll-editor.vue: | ||||
|   no-only-one-choice: "至少选择两个选项" | ||||
|   choice-n: "选择{}" | ||||
|   remove: "删除选项" | ||||
|   add: "+添加一个选项" | ||||
|   destroy: "放弃投票" | ||||
|   multiple: "允许多个投票" | ||||
|   expiration: "截止时间" | ||||
|   infinite: "永久" | ||||
|   at: "指定日期" | ||||
|   after: "指定时间" | ||||
|   no-more: "最多只能添加十个回答" | ||||
|   deadline-date: "日期" | ||||
|   deadline-time: "时间" | ||||
|   interval: "时长" | ||||
|   unit: "单位" | ||||
|   second: "秒" | ||||
|   minute: "分" | ||||
|   hour: "小时" | ||||
|   day: "日" | ||||
| common/views/components/reaction-picker.vue: | ||||
|   choose-reaction: "选择回应" | ||||
| common/views/components/emoji-picker.vue: | ||||
| @@ -633,6 +652,7 @@ common/views/components/profile-editor.vue: | ||||
|     following-list: "关注列表" | ||||
|     mute-list: "屏蔽列表" | ||||
|     blocking-list: "黑名单" | ||||
|     user-lists: "列表" | ||||
|   export-requested: "导出请求已提交。可能需要花一些时间。导出的文件将保存到网盘中。" | ||||
|   enter-password: "请输入您的密码" | ||||
|   danger-zone: "危险选项" | ||||
|   | ||||
							
								
								
									
										88
									
								
								locales/zh-TW.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								locales/zh-TW.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| --- | ||||
| meta: | ||||
|   lang: "中文(繁体)" | ||||
| common: | ||||
|   intro: | ||||
|     title: "什麽是 Misskey 呢?" | ||||
|     rich-contents: "發佈" | ||||
|     reaction: "回應" | ||||
|     drive: "雲端硬碟" | ||||
|   adblock: | ||||
|     detected: "請禁用廣告封鎖器" | ||||
|   close: "關閉" | ||||
|   enter-password: "請輸入密碼" | ||||
|   2fa: "雙重身份驗證" | ||||
|   dark-mode: "夜間模式" | ||||
|   signup: "註冊" | ||||
|   signout: "登出" | ||||
|   notification: | ||||
|     reversi-invited: "您已被邀請加入壹場遊戲" | ||||
|     reversi-invited-by: "來自{}的邀請" | ||||
|     notified-by: "來自{}的邀請" | ||||
|   time: | ||||
|     future: "未來" | ||||
|     just_now: "剛剛" | ||||
|   drive: "雲端硬碟" | ||||
|   weekday: | ||||
|     sunday: "週日" | ||||
|     monday: "週一" | ||||
|     tuesday: "週二" | ||||
|     wednesday: "週三" | ||||
|     thursday: "週四" | ||||
|     friday: "週五" | ||||
|     saturday: "週六" | ||||
|   reactions: | ||||
|     like: "贊" | ||||
|     love: "喜歡" | ||||
|     congrats: "恭喜" | ||||
|   _settings: | ||||
|     password: "密碼" | ||||
|     font-size: "字體大小" | ||||
|     font-size-x-small: "小" | ||||
|     font-size-small: "較小" | ||||
|     deck-column-width-wide: "寬" | ||||
|     timeline: "時間軸" | ||||
| common/views/components/connect-failed.troubleshooter.vue: | ||||
|   flush: "清除快取" | ||||
| common/views/components/theme.vue: | ||||
|   light-themes: "淺色主題" | ||||
|   dark-themes: "深色主題" | ||||
|   install-a-theme: "安裝主題" | ||||
|   save-created-theme: "保存主題" | ||||
| common/views/components/signin.vue: | ||||
|   signin-with-twitter: "用 Twitter 帳號登入" | ||||
|   signin-with-github: "用 GitHub 帳號登入" | ||||
|   signin-with-discord: "用 Discord 帳號登入" | ||||
|   login-failed: "登錄失敗。 請檢查用戶名和密碼。" | ||||
| common/views/components/signup.vue: | ||||
|   invitation-code: "邀請碼" | ||||
|   username: "用戶名" | ||||
|   available: "可用" | ||||
|   too-long: "請不要超過20個字元" | ||||
|   password: "密碼" | ||||
|   password-placeholder: "建議至少8個字元" | ||||
| common/views/components/stream-indicator.vue: | ||||
|   connecting: "正在連線" | ||||
|   reconnecting: "正在重新連線" | ||||
|   connected: "已建立連線" | ||||
| common/views/components/integration-settings.vue: | ||||
|   disconnect: "中斷連線" | ||||
| common/views/components/github-setting.vue: | ||||
|   reconnect: "重新連線" | ||||
|   disconnect: "中斷連線" | ||||
| common/views/components/discord-setting.vue: | ||||
|   reconnect: "重新連線" | ||||
|   disconnect: "中斷連線" | ||||
| common/views/components/language-settings.vue: | ||||
|   recommended: "推薦" | ||||
|   auto: "自動" | ||||
|   specify-language: "指定語言" | ||||
| common/views/components/profile-editor.vue: | ||||
|   title: "個人資料" | ||||
|   name: "名稱" | ||||
|   birthday: "生日:" | ||||
|   privacy: "隱私" | ||||
| admin/views/dashboard.vue: | ||||
|   drive: "雲端硬碟" | ||||
| admin/views/charts.vue: | ||||
|   drive: "雲端硬碟" | ||||
							
								
								
									
										21
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| { | ||||
| 	"name": "misskey", | ||||
| 	"author": "syuilo <i@syuilo.com>", | ||||
| 	"version": "10.91.2", | ||||
| 	"version": "10.92.4", | ||||
| 	"codename": "nighthike", | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
| @@ -32,6 +32,7 @@ | ||||
| 		"@prezzemolo/rap": "0.1.2", | ||||
| 		"@prezzemolo/zip": "0.0.3", | ||||
| 		"@types/bcryptjs": "2.4.2", | ||||
| 		"@types/bull": "3.5.8", | ||||
| 		"@types/chai-http": "3.0.5", | ||||
| 		"@types/dateformat": "3.0.0", | ||||
| 		"@types/deep-equal": "1.0.1", | ||||
| @@ -58,7 +59,7 @@ | ||||
| 		"@types/koa-logger": "3.1.1", | ||||
| 		"@types/koa-mount": "3.0.1", | ||||
| 		"@types/koa-multer": "1.0.0", | ||||
| 		"@types/koa-router": "7.0.39", | ||||
| 		"@types/koa-router": "7.0.40", | ||||
| 		"@types/koa-send": "4.1.1", | ||||
| 		"@types/koa-views": "2.0.3", | ||||
| 		"@types/koa__cors": "2.2.3", | ||||
| @@ -66,7 +67,7 @@ | ||||
| 		"@types/mkdirp": "0.5.2", | ||||
| 		"@types/mocha": "5.2.5", | ||||
| 		"@types/mongodb": "3.1.20", | ||||
| 		"@types/node": "10.12.24", | ||||
| 		"@types/node": "11.10.4", | ||||
| 		"@types/nodemailer": "4.6.6", | ||||
| 		"@types/nprogress": "0.0.29", | ||||
| 		"@types/oauth": "0.9.1", | ||||
| @@ -84,7 +85,7 @@ | ||||
| 		"@types/seedrandom": "2.4.27", | ||||
| 		"@types/sharp": "0.21.2", | ||||
| 		"@types/showdown": "1.9.2", | ||||
| 		"@types/speakeasy": "2.0.3", | ||||
| 		"@types/speakeasy": "2.0.4", | ||||
| 		"@types/systeminformation": "3.23.1", | ||||
| 		"@types/tinycolor2": "1.4.1", | ||||
| 		"@types/tmp": "0.0.33", | ||||
| @@ -100,8 +101,8 @@ | ||||
| 		"autosize": "4.0.2", | ||||
| 		"autwh": "0.1.0", | ||||
| 		"bcryptjs": "2.4.3", | ||||
| 		"bee-queue": "1.2.2", | ||||
| 		"bootstrap-vue": "2.0.0-rc.11", | ||||
| 		"bootstrap-vue": "2.0.0-rc.13", | ||||
| 		"bull": "3.7.0", | ||||
| 		"cafy": "15.1.0", | ||||
| 		"chai": "4.2.0", | ||||
| 		"chai-http": "4.2.1", | ||||
| @@ -118,11 +119,11 @@ | ||||
| 		"elasticsearch": "15.3.1", | ||||
| 		"emojilib": "2.4.0", | ||||
| 		"escape-regexp": "0.0.1", | ||||
| 		"eslint": "5.12.0", | ||||
| 		"eslint": "5.15.0", | ||||
| 		"eslint-plugin-vue": "5.2.2", | ||||
| 		"eventemitter3": "3.1.0", | ||||
| 		"feed": "2.0.2", | ||||
| 		"file-type": "10.7.1", | ||||
| 		"file-type": "10.9.0", | ||||
| 		"fuckadblock": "3.2.1", | ||||
| 		"gulp": "4.0.0", | ||||
| 		"gulp-cssnano": "2.1.3", | ||||
| @@ -136,7 +137,6 @@ | ||||
| 		"gulp-typescript": "5.0.0", | ||||
| 		"gulp-uglify": "3.0.1", | ||||
| 		"gulp-util": "3.0.8", | ||||
| 		"gulp-yaml": "2.0.3", | ||||
| 		"hard-source-webpack-plugin": "0.13.1", | ||||
| 		"html-minifier": "3.5.21", | ||||
| 		"http-signature": "1.2.0", | ||||
| @@ -176,7 +176,6 @@ | ||||
| 		"nodemailer": "5.1.1", | ||||
| 		"nprogress": "0.2.0", | ||||
| 		"object-assign-deep": "0.4.0", | ||||
| 		"on-build-webpack": "0.1.0", | ||||
| 		"os-utils": "0.0.14", | ||||
| 		"parse5": "5.1.0", | ||||
| 		"parsimmon": "1.12.0", | ||||
| @@ -251,7 +250,7 @@ | ||||
| 		"web-push": "3.3.3", | ||||
| 		"webfinger.js": "2.7.0", | ||||
| 		"webpack": "4.28.4", | ||||
| 		"webpack-cli": "3.2.1", | ||||
| 		"webpack-cli": "3.2.3", | ||||
| 		"websocket": "1.0.28", | ||||
| 		"ws": "6.1.4", | ||||
| 		"xev": "2.0.1" | ||||
|   | ||||
| @@ -5,8 +5,7 @@ program | ||||
| 	.version(pkg.version) | ||||
| 	.option('--no-daemons', 'Disable daemon processes (for debbuging)') | ||||
| 	.option('--disable-clustering', 'Disable clustering') | ||||
| 	.option('--disable-queue', 'Disable job queue processing') | ||||
| 	.option('--only-server', 'Run server only (without job queue)') | ||||
| 	.option('--only-server', 'Run server only (without job queue processing)') | ||||
| 	.option('--only-queue', 'Pocessing job queue only (without server)') | ||||
| 	.option('--quiet', 'Suppress all logs') | ||||
| 	.option('--verbose', 'Enable all logs') | ||||
| @@ -15,7 +14,6 @@ program | ||||
| 	.option('--color', 'This option is a dummy for some external program\'s (e.g. forever) issue.') | ||||
| 	.parse(process.argv); | ||||
|  | ||||
| /*if (process.env.MK_DISABLE_QUEUE)*/ program.disableQueue = true; | ||||
| if (process.env.MK_ONLY_QUEUE) program.onlyQueue = true; | ||||
|  | ||||
| export { program }; | ||||
|   | ||||
| @@ -85,11 +85,10 @@ export default Vue.extend({ | ||||
|  | ||||
| <style lang="stylus" scoped> | ||||
| .nqjzuvev | ||||
| 	white-space nowrap | ||||
| 	overflow auto | ||||
| 	padding 8px | ||||
| 	background #000 | ||||
| 	color #fff | ||||
| 	font-size 14px | ||||
|  | ||||
| 	> code | ||||
| 		display block | ||||
|   | ||||
| @@ -2,6 +2,34 @@ | ||||
| <div> | ||||
| 	<ui-card> | ||||
| 		<template #title>{{ $t('operation') }}</template> | ||||
| 		<section> | ||||
| 			<header>Deliver</header> | ||||
| 			<ui-horizon-group inputs v-if="stats"> | ||||
| 				<ui-input :value="stats.deliver.waiting | number" type="text" readonly> | ||||
| 					<span>Waiting</span> | ||||
| 				</ui-input> | ||||
| 				<ui-input :value="stats.deliver.delayed | number" type="text" readonly> | ||||
| 					<span>Delayed</span> | ||||
| 				</ui-input> | ||||
| 				<ui-input :value="stats.deliver.active | number" type="text" readonly> | ||||
| 					<span>Active</span> | ||||
| 				</ui-input> | ||||
| 			</ui-horizon-group> | ||||
| 		</section> | ||||
| 		<section> | ||||
| 			<header>Inbox</header> | ||||
| 			<ui-horizon-group inputs v-if="stats"> | ||||
| 				<ui-input :value="stats.inbox.waiting | number" type="text" readonly> | ||||
| 					<span>Waiting</span> | ||||
| 				</ui-input> | ||||
| 				<ui-input :value="stats.inbox.delayed | number" type="text" readonly> | ||||
| 					<span>Delayed</span> | ||||
| 				</ui-input> | ||||
| 				<ui-input :value="stats.inbox.active | number" type="text" readonly> | ||||
| 					<span>Active</span> | ||||
| 				</ui-input> | ||||
| 			</ui-horizon-group> | ||||
| 		</section> | ||||
| 		<section> | ||||
| 			<ui-button @click="removeAllJobs">{{ $t('remove-all-jobs') }}</ui-button> | ||||
| 		</section> | ||||
| @@ -18,9 +46,26 @@ export default Vue.extend({ | ||||
|  | ||||
| 	data() { | ||||
| 		return { | ||||
| 			stats: null | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	created() { | ||||
| 		const fetchStats = () => { | ||||
| 			this.$root.api('admin/queue/stats', {}, true).then(stats => { | ||||
| 				this.stats = stats; | ||||
| 			}); | ||||
| 		}; | ||||
|  | ||||
| 		fetchStats(); | ||||
|  | ||||
| 		const clock = setInterval(fetchStats, 1000); | ||||
|  | ||||
| 		this.$once('hook:beforeDestroy', () => { | ||||
| 			clearInterval(clock); | ||||
| 		}); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		async removeAllJobs() { | ||||
| 			const process = async () => { | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
| 		<li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> | ||||
| 			<img class="avatar" :src="user.avatarUrl" alt=""/> | ||||
| 			<span class="name"> | ||||
| 				<mk-user-name :user="user"/> | ||||
| 				<mk-user-name :user="user" :key="user.id"/> | ||||
| 			</span> | ||||
| 			<span class="username">@{{ user | acct }}</span> | ||||
| 		</li> | ||||
|   | ||||
| @@ -3,32 +3,31 @@ | ||||
| 	<header> | ||||
| 		<button v-for="category in categories" | ||||
| 			:title="category.text" | ||||
| 			@click="go(category.ref)" | ||||
| 			@click="go(category)" | ||||
| 			:class="{ active: category.isActive }" | ||||
| 		> | ||||
| 			<fa :icon="category.icon" fixed-width/> | ||||
| 		</button> | ||||
| 	</header> | ||||
| 	<div class="emojis" ref="emojis" @scroll.passive="onScroll"> | ||||
| 		<section v-for="category in categories" :ref="category.ref"> | ||||
| 			<header><fa :icon="category.icon" fixed-width/> {{ category.text }}</header> | ||||
| 			<div v-if="category.name"> | ||||
| 				<button v-for="emoji in Object.entries(lib).filter(([k, v]) => v.category === category.name)" | ||||
| 					:title="emoji[0]" | ||||
| 					@click="chosen(emoji[1].char)" | ||||
| 				> | ||||
| 					<mk-emoji :emoji="emoji[1].char"/> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div v-else> | ||||
| 				<button v-for="emoji in customEmojis" | ||||
| 					:title="emoji.name" | ||||
| 					@click="chosen(`:${emoji.name}:`)" | ||||
| 				> | ||||
| 					<img :src="emoji.url" :alt="emoji.name"/> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		</section> | ||||
| 	<div class="emojis"> | ||||
| 		<header><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header> | ||||
| 		<div v-if="categories.find(x => x.isActive).name"> | ||||
| 			<button v-for="emoji in Object.entries(lib).filter(([k, v]) => v.category === categories.find(x => x.isActive).name)" | ||||
| 				:title="emoji[0]" | ||||
| 				@click="chosen(emoji[1].char)" | ||||
| 				:key="emoji[0]" | ||||
| 			> | ||||
| 				<mk-emoji :emoji="emoji[1].char"/> | ||||
| 			</button> | ||||
| 		</div> | ||||
| 		<div v-else> | ||||
| 			<button v-for="emoji in customEmojis" | ||||
| 				:title="emoji.name" | ||||
| 				@click="chosen(`:${emoji.name}:`)" | ||||
| 			> | ||||
| 				<img :src="emoji.url" :alt="emoji.name"/> | ||||
| 			</button> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| @@ -48,55 +47,46 @@ export default Vue.extend({ | ||||
| 			lib, | ||||
| 			customEmojis: [], | ||||
| 			categories: [{ | ||||
| 				ref: 'customEmojiSection', | ||||
| 				text: this.$t('custom-emoji'), | ||||
| 				icon: faAsterisk, | ||||
| 				isActive: true | ||||
| 			}, { | ||||
| 				name: 'people', | ||||
| 				ref: 'peopleSection', | ||||
| 				text: this.$t('people'), | ||||
| 				icon: ['far', 'laugh'], | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'animals_and_nature', | ||||
| 				ref: 'animalsAndNatureSection', | ||||
| 				text: this.$t('animals-and-nature'), | ||||
| 				icon: faLeaf, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'food_and_drink', | ||||
| 				ref: 'foodAndDrinkSection', | ||||
| 				text: this.$t('food-and-drink'), | ||||
| 				icon: faUtensils, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'activity', | ||||
| 				ref: 'activitySection', | ||||
| 				text: this.$t('activity'), | ||||
| 				icon: faFutbol, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'travel_and_places', | ||||
| 				ref: 'travelAndPlacesSection', | ||||
| 				text: this.$t('travel-and-places'), | ||||
| 				icon: faCity, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'objects', | ||||
| 				ref: 'objectsSection', | ||||
| 				text: this.$t('objects'), | ||||
| 				icon: faDice, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'symbols', | ||||
| 				ref: 'symbolsSection', | ||||
| 				text: this.$t('symbols'), | ||||
| 				icon: faHeart, | ||||
| 				isActive: false | ||||
| 			}, { | ||||
| 				name: 'flags', | ||||
| 				ref: 'flagsSection', | ||||
| 				text: this.$t('flags'), | ||||
| 				icon: faFlag, | ||||
| 				isActive: false | ||||
| @@ -109,15 +99,9 @@ export default Vue.extend({ | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		go(ref) { | ||||
| 			this.$refs.emojis.scrollTop = this.$refs[ref][0].offsetTop; | ||||
| 		}, | ||||
|  | ||||
| 		onScroll(e) { | ||||
| 			for (const x of this.categories) { | ||||
| 				const top = e.target.scrollTop; | ||||
| 				const el = this.$refs[x.ref][0]; | ||||
| 				x.isActive = el.offsetTop <= top && el.offsetTop + el.offsetHeight > top; | ||||
| 		go(category) { | ||||
| 			for (const c of this.categories) { | ||||
| 				c.isActive = c.name === category.name; | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| @@ -156,47 +140,46 @@ export default Vue.extend({ | ||||
| 		overflow-y auto | ||||
| 		overflow-x hidden | ||||
|  | ||||
| 		> section | ||||
| 			> header | ||||
| 				position sticky | ||||
| 				top 0 | ||||
| 				left 0 | ||||
| 				z-index 1 | ||||
| 				padding 8px | ||||
| 				background var(--faceHeader) | ||||
| 				color var(--text) | ||||
| 				font-size 12px | ||||
| 		> header | ||||
| 			position sticky | ||||
| 			top 0 | ||||
| 			left 0 | ||||
| 			z-index 1 | ||||
| 			padding 8px | ||||
| 			background var(--faceHeader) | ||||
| 			color var(--text) | ||||
| 			font-size 12px | ||||
|  | ||||
| 			> div | ||||
| 				display grid | ||||
| 				grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr | ||||
| 				gap 4px | ||||
| 				padding 8px | ||||
| 		> div | ||||
| 			display grid | ||||
| 			grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr | ||||
| 			gap 4px | ||||
| 			padding 8px | ||||
|  | ||||
| 				> button | ||||
| 					padding 0 | ||||
| 					width 100% | ||||
| 			> button | ||||
| 				padding 0 | ||||
| 				width 100% | ||||
|  | ||||
| 					&:before | ||||
| 						content '' | ||||
| 						display block | ||||
| 						width 1px | ||||
| 						height 0 | ||||
| 						padding-bottom 100% | ||||
|  | ||||
| 					&:hover | ||||
| 						> * | ||||
| 							transform scale(1.2) | ||||
| 							transition transform 0s | ||||
| 				&:before | ||||
| 					content '' | ||||
| 					display block | ||||
| 					width 1px | ||||
| 					height 0 | ||||
| 					padding-bottom 100% | ||||
|  | ||||
| 				&:hover | ||||
| 					> * | ||||
| 						position absolute | ||||
| 						top 0 | ||||
| 						left 0 | ||||
| 						width 100% | ||||
| 						height 100% | ||||
| 						font-size 28px | ||||
| 						transition transform 0.2s ease | ||||
| 						pointer-events none | ||||
| 						transform scale(1.2) | ||||
| 						transition transform 0s | ||||
|  | ||||
| 				> * | ||||
| 					position absolute | ||||
| 					top 0 | ||||
| 					left 0 | ||||
| 					width 100% | ||||
| 					height 100% | ||||
| 					font-size 28px | ||||
| 					transition transform 0.2s ease | ||||
| 					pointer-events none | ||||
|  | ||||
| </style> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
| <a class="a" href="https://github.com/syuilo/misskey" target="_blank" title="View source on GitHub"> | ||||
| <a class="a" :href="repo" target="_blank" title="View source on GitHub"> | ||||
| 	<svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden"> | ||||
| 		<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> | ||||
| 		<path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path> | ||||
| @@ -8,9 +8,25 @@ | ||||
| </a> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue' | ||||
| export default Vue.extend({ | ||||
| 	data() { | ||||
| 		return { | ||||
| 			repositoryUrl: 'https://github.com/syuilo/misskey' | ||||
| 		}; | ||||
| 	}, | ||||
| 	created() { | ||||
| 		this.$root.getMeta().then(meta => { | ||||
| 			if (meta.maintainer) | ||||
| 				this.repositoryUrl = meta.maintainer.repository_url; | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style lang="stylus" scoped> | ||||
|  | ||||
|  | ||||
| .a | ||||
| 	display block | ||||
|  | ||||
|   | ||||
| @@ -5,17 +5,17 @@ | ||||
|  | ||||
| 	<div style="overflow: hidden; line-height: 28px;"> | ||||
| 		<p class="turn" v-if="!iAmPlayer && !game.isEnded"> | ||||
| 			<mfm :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> | ||||
| 			<mfm :key="'turn:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> | ||||
| 			<mk-ellipsis/> | ||||
| 		</p> | ||||
| 		<p class="turn" v-if="logPos != logs.length"> | ||||
| 			<mfm :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> | ||||
| 			<mfm :key="'past-turn-of:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> | ||||
| 		</p> | ||||
| 		<p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ $t('@.reversi.opponent-turn') }}<mk-ellipsis/></p> | ||||
| 		<p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">{{ $t('@.reversi.my-turn') }}</p> | ||||
| 		<p class="result" v-if="game.isEnded && logPos == logs.length"> | ||||
| 			<template v-if="game.winner"> | ||||
| 				<mfm :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :should-break="false" :plain-text="true" :custom-emojis="game.winner.emojis"/> | ||||
| 				<mfm :key="'won'" :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :should-break="false" :plain-text="true" :custom-emojis="game.winner.emojis"/> | ||||
| 				<span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span> | ||||
| 			</template> | ||||
| 			<template v-else>{{ $t('@.reversi.drawn') }}</template> | ||||
|   | ||||
| @@ -12,21 +12,54 @@ | ||||
| 		</li> | ||||
| 	</ul> | ||||
| 	<button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</button> | ||||
| 	<button class="add" v-else disabled>{{ $t('no-more') }}</button> | ||||
| 	<button class="destroy" @click="destroy" :title="$t('destroy')"> | ||||
| 		<fa icon="times"/> | ||||
| 	</button> | ||||
| 	<section> | ||||
| 		<ui-switch v-model="multiple">{{ $t('multiple') }}</ui-switch> | ||||
| 		<div> | ||||
| 			<ui-select v-model="expiration"> | ||||
| 				<template #label>{{ $t('expiration') }}</template> | ||||
| 				<option value="infinite">{{ $t('infinite') }}</option> | ||||
| 				<option value="at">{{ $t('at') }}</option> | ||||
| 				<option value="after">{{ $t('after') }}</option> | ||||
| 			</ui-select> | ||||
| 			<section v-if="expiration === 'at'"> | ||||
| 				<ui-input v-model="atDate" type="date">{{ $t('deadline-date') }}</ui-input> | ||||
| 				<ui-input v-model="atTime" type="time">{{ $t('deadline-time') }}</ui-input> | ||||
| 			</section> | ||||
| 			<section v-if="expiration === 'after'"> | ||||
| 				<ui-input v-model="after" type="number">{{ $t('interval') }}</ui-input> | ||||
| 				<ui-select v-model="unit"> | ||||
| 					<template #label>{{ $t('unit') }}</template> | ||||
| 					<option value="second">{{ $t('second') }}</option> | ||||
| 					<option value="minute">{{ $t('minute') }}</option> | ||||
| 					<option value="hour">{{ $t('hour') }}</option> | ||||
| 					<option value="day">{{ $t('day') }}</option> | ||||
| 				</ui-select> | ||||
| 			</section> | ||||
| 		</div> | ||||
| 	</section> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import * as moment from 'moment'; | ||||
| import i18n from '../../../i18n'; | ||||
| import { erase } from '../../../../../prelude/array'; | ||||
| export default Vue.extend({ | ||||
| 	i18n: i18n('common/views/components/poll-editor.vue'), | ||||
| 	data() { | ||||
| 		return { | ||||
| 			choices: ['', ''] | ||||
| 			choices: ['', ''], | ||||
| 			multiple: false, | ||||
| 			expiration: 'infinite', | ||||
| 			atDate: moment().add(1, 'day').toISOString().split('T')[0], | ||||
| 			atTime: '00:00', | ||||
| 			after: 0, | ||||
| 			unit: 'second' | ||||
| 		}; | ||||
| 	}, | ||||
| 	watch: { | ||||
| @@ -55,15 +88,46 @@ export default Vue.extend({ | ||||
| 		}, | ||||
|  | ||||
| 		get() { | ||||
| 			const at = () => { | ||||
| 				const [date] = moment(this.atDate).toISOString().split('T'); | ||||
| 				const [hour, minute] = this.atTime.split(':'); | ||||
| 				return moment(`${date}T${hour}:${minute}Z`).valueOf(); | ||||
| 			}; | ||||
|  | ||||
| 			const after = () => { | ||||
| 				let base = parseInt(this.after); | ||||
| 				switch (this.unit) { | ||||
| 					case 'day': base *= 24; | ||||
| 					case 'hour': base *= 60; | ||||
| 					case 'minute': base *= 60; | ||||
| 					case 'second': return base *= 1000; | ||||
| 					default: return null; | ||||
| 				} | ||||
| 			}; | ||||
|  | ||||
| 			return { | ||||
| 				choices: erase('', this.choices) | ||||
| 			} | ||||
| 				choices: erase('', this.choices), | ||||
| 				multiple: this.multiple, | ||||
| 				...( | ||||
| 					this.expiration === 'at' ? { expiresAt: at() } : | ||||
| 					this.expiration === 'after' ? { expiredAfter: after() } : {}) | ||||
| 			}; | ||||
| 		}, | ||||
|  | ||||
| 		set(data) { | ||||
| 			if (data.choices.length == 0) return; | ||||
| 			this.choices = data.choices; | ||||
| 			if (data.choices.length == 1) this.choices = this.choices.concat(''); | ||||
| 			this.multiple = data.multiple; | ||||
| 			if (data.expiresAt) { | ||||
| 				this.expiration = 'at'; | ||||
| 				this.atDate = this.atTime = data.expiresAt; | ||||
| 			} else if (typeof data.expiredAfter === 'number') { | ||||
| 				this.expiration = 'after'; | ||||
| 				this.after = data.expiredAfter; | ||||
| 			} else { | ||||
| 				this.expiration = 'infinite'; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| @@ -128,6 +192,7 @@ export default Vue.extend({ | ||||
| 		margin 8px 0 0 0 | ||||
| 		vertical-align top | ||||
| 		color var(--primary) | ||||
| 		z-index 1 | ||||
|  | ||||
| 	> .destroy | ||||
| 		position absolute | ||||
| @@ -142,4 +207,23 @@ export default Vue.extend({ | ||||
| 		&:active | ||||
| 			color var(--primaryDarken30) | ||||
|  | ||||
| 	> section | ||||
| 		margin 16px 0 -16px 0 | ||||
|  | ||||
| 		> div | ||||
| 			margin 0 8px | ||||
|  | ||||
| 			&:last-child | ||||
| 				flex 1 0 auto | ||||
|  | ||||
| 				> section | ||||
| 					align-items center | ||||
| 					display flex | ||||
| 					margin -32px 0 0 | ||||
|  | ||||
| 					> :first-child | ||||
| 						margin-right 16px | ||||
|  | ||||
| 					> .ui-input | ||||
| 						flex 1 0 auto | ||||
| </style> | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <template> | ||||
| <div class="mk-poll" :data-is-voted="isVoted"> | ||||
| <div class="mk-poll" :data-done="closed || isVoted"> | ||||
| 	<ul> | ||||
| 		<li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? $t('vote-to').replace('{}', choice.text) : ''"> | ||||
| 			<div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div> | ||||
| 		<li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!closed && !isVoted ? $t('vote-to').replace('{}', choice.text) : ''"> | ||||
| 			<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> | ||||
| 			<span> | ||||
| 				<template v-if="choice.isVoted"><fa icon="check"/></template> | ||||
| 				<mfm :text="choice.text" :should-break="false" :plain-text="true" :custom-emojis="note.emojis"/> | ||||
| @@ -10,11 +10,13 @@ | ||||
| 			</span> | ||||
| 		</li> | ||||
| 	</ul> | ||||
| 	<p v-if="total > 0"> | ||||
| 		<span>{{ $t('total-users').replace('{}', total) }}</span> | ||||
| 		<span>・</span> | ||||
| 		<a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a> | ||||
| 	<p> | ||||
| 		<span>{{ $t('total-votes').replace('{}', total) }}</span> | ||||
| 		<span> · </span> | ||||
| 		<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a> | ||||
| 		<span v-if="isVoted">{{ $t('voted') }}</span> | ||||
| 		<span v-else-if="closed">{{ $t('closed') }}</span> | ||||
| 		<span v-if="remaining > 0"> · {{ timer }}</span> | ||||
| 	</p> | ||||
| </div> | ||||
| </template> | ||||
| @@ -28,6 +30,7 @@ export default Vue.extend({ | ||||
| 	props: ['note'], | ||||
| 	data() { | ||||
| 		return { | ||||
| 			remaining: -1, | ||||
| 			showResult: false | ||||
| 		}; | ||||
| 	}, | ||||
| @@ -38,19 +41,43 @@ export default Vue.extend({ | ||||
| 		total(): number { | ||||
| 			return sum(this.poll.choices.map(x => x.votes)); | ||||
| 		}, | ||||
| 		closed(): boolean { | ||||
| 			return !this.remaining; | ||||
| 		}, | ||||
| 		timer(): string { | ||||
| 			return this.$t( | ||||
| 				this.remaining > 86400 ? 'remaining-days' : | ||||
| 				this.remaining > 3600 ? 'remaining-hours' : | ||||
| 				this.remaining > 60 ? 'remaining-minutes' : 'remaining-seconds') | ||||
| 				.replace('{s}', Math.floor(this.remaining % 60)) | ||||
| 				.replace('{m}', Math.floor(this.remaining / 60) % 60) | ||||
| 				.replace('{h}', Math.floor(this.remaining / 3600) % 24) | ||||
| 				.replace('{d}', Math.floor(this.remaining / 86400)); | ||||
| 		}, | ||||
| 		isVoted(): boolean { | ||||
| 			return this.poll.choices.some(c => c.isVoted); | ||||
| 			return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); | ||||
| 		} | ||||
| 	}, | ||||
| 	created() { | ||||
| 		this.showResult = this.isVoted; | ||||
|  | ||||
| 		if (this.note.poll.expiresAt) { | ||||
| 			const update = () => { | ||||
| 				if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) | ||||
| 					requestAnimationFrame(update); | ||||
| 				else | ||||
| 					this.showResult = true; | ||||
| 			}; | ||||
|  | ||||
| 			update(); | ||||
| 		} | ||||
| 	}, | ||||
| 	methods: { | ||||
| 		toggleShowResult() { | ||||
| 			this.showResult = !this.showResult; | ||||
| 		}, | ||||
| 		vote(id) { | ||||
| 			if (this.poll.choices.some(c => c.isVoted)) return; | ||||
| 			if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; | ||||
| 			this.$root.api('notes/polls/vote', { | ||||
| 				noteId: this.note.id, | ||||
| 				choice: id | ||||
| @@ -61,7 +88,7 @@ export default Vue.extend({ | ||||
| 						Vue.set(c, 'isVoted', true); | ||||
| 					} | ||||
| 				} | ||||
| 				this.showResult = true; | ||||
| 				if (!this.showResult) this.showResult = !this.poll.multiple; | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| @@ -114,7 +141,7 @@ export default Vue.extend({ | ||||
| 		a | ||||
| 			color inherit | ||||
|  | ||||
| 	&[data-is-voted] | ||||
| 	&[data-done] | ||||
| 		> ul > li | ||||
| 			cursor default | ||||
|  | ||||
|   | ||||
| @@ -97,6 +97,7 @@ | ||||
| 				<option value="following">{{ $t('export-targets.following-list') }}</option> | ||||
| 				<option value="mute">{{ $t('export-targets.mute-list') }}</option> | ||||
| 				<option value="blocking">{{ $t('export-targets.blocking-list') }}</option> | ||||
| 				<option value="user-lists">{{ $t('export-targets.user-lists') }}</option> | ||||
| 			</ui-select> | ||||
| 			<ui-button @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</ui-button> | ||||
| 		</div> | ||||
| @@ -284,6 +285,7 @@ export default Vue.extend({ | ||||
| 				this.exportTarget == 'following' ? 'i/export-following' : | ||||
| 				this.exportTarget == 'mute' ? 'i/export-mute' : | ||||
| 				this.exportTarget == 'blocking' ? 'i/export-blocking' : | ||||
| 				this.exportTarget == 'user-lists' ? 'i/export-user-lists' : | ||||
| 				null, {}); | ||||
|  | ||||
| 			this.$root.dialog({ | ||||
|   | ||||
| @@ -386,7 +386,7 @@ export default Vue.extend({ | ||||
| 			height: 50px; | ||||
| 			background-color: #83D8FF; | ||||
| 			border-radius: 90px - 6; | ||||
| 			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
|  | ||||
| 			&:before { | ||||
| 				content: 'Light'; | ||||
| @@ -418,14 +418,14 @@ export default Vue.extend({ | ||||
| 			background-color: #FFCF96; | ||||
| 			border-radius: 50px; | ||||
| 			box-shadow: 0 2px 6px rgba(0,0,0,.3); | ||||
| 			transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55); | ||||
| 			transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; | ||||
| 			transform:  rotate(-45deg); | ||||
|  | ||||
| 			.crater { | ||||
| 				position: absolute; | ||||
| 				background-color: #E8CDA5; | ||||
| 				opacity: 0; | ||||
| 				transition: opacity 200ms ease-in-out; | ||||
| 				transition: opacity 200ms ease-in-out !important; | ||||
| 				border-radius: 100%; | ||||
| 			} | ||||
|  | ||||
| @@ -454,7 +454,7 @@ export default Vue.extend({ | ||||
| 		.star { | ||||
| 			position: absolute; | ||||
| 			background-color: #ffffff; | ||||
| 			transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 			transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
| 			border-radius: 50%; | ||||
| 		} | ||||
|  | ||||
| @@ -486,7 +486,7 @@ export default Vue.extend({ | ||||
| 		.star--5, | ||||
| 		.star--6 { | ||||
| 			opacity: 0; | ||||
| 			transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 			transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
| 		} | ||||
|  | ||||
| 		.star--4 { | ||||
| @@ -559,13 +559,13 @@ export default Vue.extend({ | ||||
| 					transform: translate3d(0,0,0); | ||||
| 				} | ||||
| 				.star--4 { | ||||
| 					transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 					transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
| 				} | ||||
| 				.star--5 { | ||||
| 					transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 					transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
| 				} | ||||
| 				.star--6 { | ||||
| 					transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95); | ||||
| 					transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|   | ||||
| @@ -366,6 +366,9 @@ root(fill) | ||||
| 			&[type='file'] | ||||
| 				display none | ||||
|  | ||||
| 			&[type='number'] | ||||
| 				text-align right | ||||
|  | ||||
| 		> .prefix | ||||
| 		> .suffix | ||||
| 			display block | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|  | ||||
| 	<div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb"> | ||||
| 		<div ref="chart" class="chart"></div> | ||||
| 		<x-hashtag-tl :tag-tl="tagTl" class="tl"/> | ||||
| 		<x-hashtag-tl :tag-tl="tagTl" class="tl" :key="JSON.stringify(tagTl)"/> | ||||
| 	</div> | ||||
| </x-column> | ||||
| </template> | ||||
|   | ||||
| @@ -26,6 +26,7 @@ | ||||
| 					<option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> | ||||
| 					<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> | ||||
| 					<option value="server">{{ $t('@.widgets.server') }}</option> | ||||
| 					<option value="queue">{{ $t('@.widgets.queue') }}</option> | ||||
| 					<option value="nav">{{ $t('@.widgets.nav') }}</option> | ||||
| 					<option value="tips">{{ $t('@.widgets.tips') }}</option> | ||||
| 				</select> | ||||
|   | ||||
| @@ -31,3 +31,4 @@ Vue.component('mkw-version', wVersion); | ||||
| Vue.component('mkw-hashtags', wHashtags); | ||||
| Vue.component('mkw-instance', wInstance); | ||||
| Vue.component('mkw-post-form', wPostForm); | ||||
| Vue.component('mkw-queue', () => import('./queue.vue').then(m => m.default)); | ||||
|   | ||||
							
								
								
									
										157
									
								
								src/client/app/common/views/widgets/queue.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/client/app/common/views/widgets/queue.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| <template> | ||||
| <div> | ||||
| 	<ui-container :show-header="!props.compact"> | ||||
| 		<template #header><fa :icon="faTasks"/>Queue</template> | ||||
|  | ||||
| 		<div class="mntrproz"> | ||||
| 			<div> | ||||
| 				<b>In</b> | ||||
| 				<span v-if="latestStats">{{ latestStats.inbox.active | number }} / {{ latestStats.inbox.delayed | number }}</span> | ||||
| 				<div ref="in"></div> | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				<b>Out</b> | ||||
| 				<span v-if="latestStats">{{ latestStats.deliver.active | number }} / {{ latestStats.deliver.delayed | number }}</span> | ||||
| 				<div ref="out"></div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</ui-container> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import define from '../../define-widget'; | ||||
| import { faTasks } from '@fortawesome/free-solid-svg-icons'; | ||||
| import ApexCharts from 'apexcharts'; | ||||
|  | ||||
| export default define({ | ||||
| 	name: 'queue', | ||||
| 	props: () => ({ | ||||
| 		compact: false | ||||
| 	}) | ||||
| }).extend({ | ||||
| 	data() { | ||||
| 		return { | ||||
| 			stats: [], | ||||
| 			inChart: null, | ||||
| 			outChart: null, | ||||
| 			faTasks | ||||
| 		}; | ||||
| 	}, | ||||
|  | ||||
| 	watch: { | ||||
| 		stats(stats) { | ||||
| 			this.inChart.updateSeries([{ | ||||
| 				data: stats.map((x, i) => ({ x: i, y: x.inbox.active })) | ||||
| 			}, { | ||||
| 				data: stats.map((x, i) => ({ x: i, y: x.inbox.delayed })) | ||||
| 			}]); | ||||
| 			this.outChart.updateSeries([{ | ||||
| 				data: stats.map((x, i) => ({ x: i, y: x.deliver.active })) | ||||
| 			}, { | ||||
| 				data: stats.map((x, i) => ({ x: i, y: x.deliver.delayed })) | ||||
| 			}]); | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	computed: { | ||||
| 		latestStats(): any { | ||||
| 			return this.stats[this.stats.length - 1]; | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
| 	mounted() { | ||||
| 		const chartOpts = { | ||||
| 			chart: { | ||||
| 				type: 'area', | ||||
| 				height: 70, | ||||
| 				animations: { | ||||
| 					dynamicAnimation: { | ||||
| 						enabled: false | ||||
| 					} | ||||
| 				}, | ||||
| 				sparkline: { | ||||
| 					enabled: true, | ||||
| 				} | ||||
| 			}, | ||||
| 			tooltip: { | ||||
| 				enabled: false | ||||
| 			}, | ||||
| 			stroke: { | ||||
| 				curve: 'straight', | ||||
| 				width: 1 | ||||
| 			}, | ||||
| 			series: [{ | ||||
| 				data: [] as any | ||||
| 			}, { | ||||
| 				data: [] as any | ||||
| 			}], | ||||
| 			yaxis: { | ||||
| 				min: 0, | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		this.inChart = new ApexCharts(this.$refs.in, chartOpts); | ||||
| 		this.outChart = new ApexCharts(this.$refs.out, chartOpts); | ||||
|  | ||||
| 		this.inChart.render(); | ||||
| 		this.outChart.render(); | ||||
|  | ||||
| 		const connection = this.$root.stream.useSharedConnection('queueStats'); | ||||
| 		connection.on('stats', this.onStats); | ||||
| 		connection.on('statsLog', this.onStatsLog); | ||||
| 		connection.send('requestLog', { | ||||
| 			id: Math.random().toString().substr(2, 8), | ||||
| 			length: 50 | ||||
| 		}); | ||||
|  | ||||
| 		this.$once('hook:beforeDestroy', () => { | ||||
| 			connection.dispose(); | ||||
| 			this.inChart.destroy(); | ||||
| 			this.outChart.destroy(); | ||||
| 		}); | ||||
| 	}, | ||||
|  | ||||
| 	methods: { | ||||
| 		func() { | ||||
| 			this.props.compact = !this.props.compact; | ||||
| 			this.save(); | ||||
| 		}, | ||||
|  | ||||
| 		onStats(stats) { | ||||
| 			this.stats.push(stats); | ||||
| 			if (this.stats.length > 50) this.stats.shift(); | ||||
| 		}, | ||||
|  | ||||
| 		onStatsLog(statsLog) { | ||||
| 			for (const stats of statsLog.reverse()) { | ||||
| 				this.onStats(stats); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="stylus" scoped> | ||||
| .mntrproz | ||||
| 	display flex | ||||
| 	padding 4px | ||||
|  | ||||
| 	> div | ||||
| 		width 50% | ||||
| 		padding 4px | ||||
|  | ||||
| 		> b | ||||
| 			display block | ||||
| 			font-size 12px | ||||
| 			color var(--text) | ||||
|  | ||||
| 		> span | ||||
| 			position absolute | ||||
| 			top 4px | ||||
| 			right 4px | ||||
| 			opacity 0.7 | ||||
| 			font-size 12px | ||||
| 			color var(--text) | ||||
|  | ||||
| </style> | ||||
| @@ -129,9 +129,9 @@ export default Vue.extend({ | ||||
| 	mounted() { | ||||
| 		// Get replies | ||||
| 		if (!this.compact) { | ||||
| 			this.$root.api('notes/replies', { | ||||
| 			this.$root.api('notes/children', { | ||||
| 				noteId: this.appearNote.id, | ||||
| 				limit: 8 | ||||
| 				limit: 30 | ||||
| 			}).then(replies => { | ||||
| 				this.replies = replies; | ||||
| 			}); | ||||
|   | ||||
| @@ -123,9 +123,9 @@ export default Vue.extend({ | ||||
|  | ||||
| 	created() { | ||||
| 		if (this.detail) { | ||||
| 			this.$root.api('notes/replies', { | ||||
| 			this.$root.api('notes/children', { | ||||
| 				noteId: this.appearNote.id, | ||||
| 				limit: 8 | ||||
| 				limit: 30 | ||||
| 			}).then(replies => { | ||||
| 				this.replies = replies; | ||||
| 			}); | ||||
|   | ||||
| @@ -115,6 +115,8 @@ export default Vue.extend({ | ||||
| 			uploadings: [], | ||||
| 			poll: false, | ||||
| 			pollChoices: [], | ||||
| 			pollMultiple: false, | ||||
| 			pollExpiration: [], | ||||
| 			useCw: false, | ||||
| 			cw: null, | ||||
| 			geo: null, | ||||
| @@ -295,7 +297,10 @@ export default Vue.extend({ | ||||
| 		}, | ||||
|  | ||||
| 		onPollUpdate() { | ||||
| 			this.pollChoices = this.$refs.poll.get().choices; | ||||
| 			const got = this.$refs.poll.get(); | ||||
| 			this.pollChoices = got.choices; | ||||
| 			this.pollMultiple = got.multiple; | ||||
| 			this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter]; | ||||
| 			this.saveDraft(); | ||||
| 		}, | ||||
|  | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
| 						<option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> | ||||
| 						<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> | ||||
| 						<option value="server">{{ $t('@.widgets.server') }}</option> | ||||
| 						<option value="queue">{{ $t('@.widgets.queue') }}</option> | ||||
| 						<option value="nav">{{ $t('@.widgets.nav') }}</option> | ||||
| 						<option value="tips">{{ $t('@.widgets.tips') }}</option> | ||||
| 					</select> | ||||
|   | ||||
| @@ -172,7 +172,11 @@ export default class MiOS extends EventEmitter { | ||||
| 			callback(); | ||||
|  | ||||
| 			// Init service worker | ||||
| 			if (this.shouldRegisterSw) this.registerSw(); | ||||
| 			if (this.shouldRegisterSw) { | ||||
| 				this.getMeta().then(data => { | ||||
| 					this.registerSw(data.swPublickey); | ||||
| 				}); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		// キャッシュがあったとき | ||||
| @@ -302,7 +306,7 @@ export default class MiOS extends EventEmitter { | ||||
| 	 * Register service worker | ||||
| 	 */ | ||||
| 	@autobind | ||||
| 	private registerSw() { | ||||
| 	private registerSw(swPublickey) { | ||||
| 		// Check whether service worker and push manager supported | ||||
| 		const isSwSupported = | ||||
| 			('serviceWorker' in navigator) && ('PushManager' in window); | ||||
| @@ -328,7 +332,7 @@ export default class MiOS extends EventEmitter { | ||||
|  | ||||
| 				// A public key your push server will use to send | ||||
| 				// messages to client apps via a push server. | ||||
| 				applicationServerKey: urlBase64ToUint8Array(this.meta.data.swPublickey) | ||||
| 				applicationServerKey: urlBase64ToUint8Array(swPublickey) | ||||
| 			}; | ||||
|  | ||||
| 			// Subscribe push notification | ||||
|   | ||||
| @@ -135,9 +135,9 @@ export default Vue.extend({ | ||||
| 	methods: { | ||||
| 		fetchReplies() { | ||||
| 			if (this.compact) return; | ||||
| 			this.$root.api('notes/replies', { | ||||
| 			this.$root.api('notes/children', { | ||||
| 				noteId: this.appearNote.id, | ||||
| 				limit: 8 | ||||
| 				limit: 30 | ||||
| 			}).then(replies => { | ||||
| 				this.replies = replies; | ||||
| 			}); | ||||
|   | ||||
| @@ -115,9 +115,9 @@ export default Vue.extend({ | ||||
|  | ||||
| 	created() { | ||||
| 		if (this.detail) { | ||||
| 			this.$root.api('notes/replies', { | ||||
| 			this.$root.api('notes/children', { | ||||
| 				noteId: this.appearNote.id, | ||||
| 				limit: 8 | ||||
| 				limit: 30 | ||||
| 			}).then(replies => { | ||||
| 				this.replies = replies; | ||||
| 			}); | ||||
|   | ||||
| @@ -105,6 +105,7 @@ export default Vue.extend({ | ||||
| 			files: [], | ||||
| 			poll: false, | ||||
| 			pollChoices: [], | ||||
| 			pollMultiple: false, | ||||
| 			geo: null, | ||||
| 			visibility: 'public', | ||||
| 			visibleUsers: [], | ||||
| @@ -273,7 +274,9 @@ export default Vue.extend({ | ||||
| 		}, | ||||
|  | ||||
| 		onPollUpdate() { | ||||
| 			this.pollChoices = this.$refs.poll.get().choices; | ||||
| 			const got = this.$refs.poll.get(); | ||||
| 			this.pollChoices = got.choices; | ||||
| 			this.pollMultiple = got.multiple; | ||||
| 		}, | ||||
|  | ||||
| 		upload(file) { | ||||
|   | ||||
| @@ -19,6 +19,7 @@ | ||||
| 					<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> | ||||
| 					<option value="version">{{ $t('@.widgets.version') }}</option> | ||||
| 					<option value="server">{{ $t('@.widgets.server') }}</option> | ||||
| 					<option value="queue">{{ $t('@.widgets.queue') }}</option> | ||||
| 					<option value="memo">{{ $t('@.widgets.memo') }}</option> | ||||
| 					<option value="nav">{{ $t('@.widgets.nav') }}</option> | ||||
| 					<option value="tips">{{ $t('@.widgets.tips') }}</option> | ||||
|   | ||||
| @@ -43,11 +43,11 @@ export const builtinThemes = [ | ||||
| ]; | ||||
|  | ||||
| export function applyTheme(theme: Theme, persisted = true) { | ||||
| 	document.documentElement.classList.add('change-theme'); | ||||
| 	document.documentElement.classList.add('changing-theme'); | ||||
|  | ||||
| 	setTimeout(() => { | ||||
| 		document.documentElement.classList.remove('change-theme'); | ||||
| 	}, 500); | ||||
| 		document.documentElement.classList.remove('changing-theme'); | ||||
| 	}, 1000); | ||||
|  | ||||
| 	// Deep copy | ||||
| 	const _theme = JSON.parse(JSON.stringify(theme)); | ||||
|   | ||||
| @@ -20,11 +20,9 @@ html, body | ||||
| 	text-size-adjust 100% | ||||
| 	font-family sans-serif | ||||
|  | ||||
| html.change-theme | ||||
| html.changing-theme | ||||
| 	&, * | ||||
| 		transition-property all | ||||
| 		transition-duration 0.5s | ||||
| 		transition-timing-function ease | ||||
| 		transition background 1s ease !important | ||||
|  | ||||
| a | ||||
| 	text-decoration none | ||||
|   | ||||
| @@ -19,6 +19,8 @@ export type Source = { | ||||
| 		host: string; | ||||
| 		port: number; | ||||
| 		pass: string; | ||||
| 		db?: number; | ||||
| 		prefix?: string; | ||||
| 	}; | ||||
| 	elasticsearch: { | ||||
| 		host: string; | ||||
|   | ||||
							
								
								
									
										37
									
								
								src/daemons/queue-stats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/daemons/queue-stats.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import * as Deque from 'double-ended-queue'; | ||||
| import Xev from 'xev'; | ||||
| import { deliverQueue, inboxQueue } from '../queue'; | ||||
|  | ||||
| const ev = new Xev(); | ||||
|  | ||||
| const interval = 1000; | ||||
|  | ||||
| /** | ||||
|  * Report queue stats regularly | ||||
|  */ | ||||
| export default function() { | ||||
| 	const log = new Deque<any>(); | ||||
|  | ||||
| 	ev.on('requestQueueStatsLog', x => { | ||||
| 		ev.emit(`queueStatsLog:${x.id}`, log.toArray().slice(0, x.length || 50)); | ||||
| 	}); | ||||
|  | ||||
| 	async function tick() { | ||||
| 		const deliverJobCounts = await deliverQueue.getJobCounts(); | ||||
| 		const inboxJobCounts = await inboxQueue.getJobCounts(); | ||||
|  | ||||
| 		const stats = { | ||||
| 			deliver: deliverJobCounts, | ||||
| 			inbox: inboxJobCounts | ||||
| 		}; | ||||
|  | ||||
| 		ev.emit('queueStats', stats); | ||||
|  | ||||
| 		log.unshift(stats); | ||||
| 		if (log.length > 200) log.pop(); | ||||
| 	} | ||||
|  | ||||
| 	tick(); | ||||
|  | ||||
| 	setInterval(tick, interval); | ||||
| } | ||||
| @@ -5,6 +5,8 @@ export default config.redis ? redis.createClient( | ||||
| 	config.redis.port, | ||||
| 	config.redis.host, | ||||
| 	{ | ||||
| 		auth_pass: config.redis.pass | ||||
| 		auth_pass: config.redis.pass, | ||||
| 		prefix: config.redis.prefix, | ||||
| 		db: config.redis.db || 0 | ||||
| 	} | ||||
| ) : null; | ||||
|   | ||||
							
								
								
									
										18
									
								
								src/index.ts
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								src/index.ts
									
									
									
									
									
								
							| @@ -16,6 +16,7 @@ import Xev from 'xev'; | ||||
| import Logger from './services/logger'; | ||||
| import serverStats from './daemons/server-stats'; | ||||
| import notesStats from './daemons/notes-stats'; | ||||
| import queueStats from './daemons/queue-stats'; | ||||
| import loadConfig from './config/load'; | ||||
| import { Config } from './config/types'; | ||||
| import { lessThan } from './prelude/array'; | ||||
| @@ -50,6 +51,7 @@ function main() { | ||||
| 		if (program.daemons) { | ||||
| 			serverStats(); | ||||
| 			notesStats(); | ||||
| 			queueStats(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -73,7 +75,7 @@ function greet() { | ||||
| 		console.log(chalk.keyword('orange')(' If you like Misskey, please donate to support development. https://www.patreon.com/syuilo')); | ||||
|  | ||||
| 		console.log(''); | ||||
| 		console.log(chalk`<${os.hostname()} {gray (PID: ${process.pid.toString()})}>`); | ||||
| 		console.log(chalk`< ${os.hostname()} {gray (PID: ${process.pid.toString()})} >`); | ||||
| 	} | ||||
|  | ||||
| 	bootLogger.info('Welcome to Misskey!'); | ||||
| @@ -117,9 +119,6 @@ async function masterMain() { | ||||
| 		await spawnWorkers(config.clusterLimit); | ||||
| 	} | ||||
|  | ||||
| 	// start queue | ||||
| 	require('./queue').default(); | ||||
|  | ||||
| 	bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); | ||||
| } | ||||
|  | ||||
| @@ -130,6 +129,9 @@ async function workerMain() { | ||||
| 	// start server | ||||
| 	await require('./server').default(); | ||||
|  | ||||
| 	// start job queue | ||||
| 	require('./queue').default(); | ||||
|  | ||||
| 	if (cluster.isWorker) { | ||||
| 		// Send a 'ready' message to parent process | ||||
| 		process.send('ready'); | ||||
| @@ -150,13 +152,9 @@ async function queueMain() { | ||||
| 	bootLogger.succ('Misskey initialized'); | ||||
|  | ||||
| 	// start processor | ||||
| 	const queue = require('./queue').default(); | ||||
| 	require('./queue').default(); | ||||
|  | ||||
| 	if (queue) { | ||||
| 		bootLogger.succ('Queue started', null, true); | ||||
| 	} else { | ||||
| 		bootLogger.error('Queue not available'); | ||||
| 	} | ||||
| 	bootLogger.succ('Queue started', null, true); | ||||
| } | ||||
|  | ||||
| const runningNodejsVersion = process.version.slice(1).split('.').map(x => parseInt(x, 10)); | ||||
|   | ||||
| @@ -142,7 +142,7 @@ export const mfmLanguage = P.createLanguage({ | ||||
| 	}, | ||||
| 	hashtag: () => P((input, i) => { | ||||
| 		const text = input.substr(i); | ||||
| 		const match = text.match(/^#([^\s\.,!\?'"#:\/]+)/i); | ||||
| 		const match = text.match(/^#([^\s\.,!\?'"#:\/\[\]]+)/i); | ||||
| 		if (!match) return P.makeFailure(i, 'not a hashtag'); | ||||
| 		let hashtag = match[1]; | ||||
| 		hashtag = removeOrphanedBrackets(hashtag); | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import AccessToken from './access-token'; | ||||
| import db from '../db/mongodb'; | ||||
| import isObjectId from '../misc/is-objectid'; | ||||
| import config from '../config'; | ||||
| import { dbLogger } from '../db/logger'; | ||||
|  | ||||
| const App = db.get<IApp>('apps'); | ||||
| App.createIndex('secret'); | ||||
| @@ -66,6 +67,12 @@ export const pack = ( | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// (データベースの欠損などで)アプリがデータベース上に見つからなかったとき | ||||
| 	if (_app == null) { | ||||
| 		dbLogger.warn(`[DAMAGED DB] (missing) pkg: app :: ${app}`); | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	// Rename _id to id | ||||
| 	_app.id = _app._id; | ||||
| 	delete _app._id; | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import db from '../db/mongodb'; | ||||
|  | ||||
| const Log = db.get<ILog>('logs'); | ||||
| Log.createIndex('createdAt', { expireAfterSeconds: 3600 * 24 * 3 }); | ||||
| Log.createIndex('level'); | ||||
| Log.createIndex('domain'); | ||||
| export default Log; | ||||
|  | ||||
| export interface ILog { | ||||
|   | ||||
| @@ -19,6 +19,7 @@ Note.createIndex('userId'); | ||||
| Note.createIndex('mentions'); | ||||
| Note.createIndex('visibleUserIds'); | ||||
| Note.createIndex('replyId'); | ||||
| Note.createIndex('renoteId'); | ||||
| Note.createIndex('tagsLower'); | ||||
| Note.createIndex('_user.host'); | ||||
| Note.createIndex('_files._id'); | ||||
| @@ -35,6 +36,7 @@ export type INote = { | ||||
| 	_id: mongo.ObjectID; | ||||
| 	createdAt: Date; | ||||
| 	deletedAt: Date; | ||||
| 	updatedAt?: Date; | ||||
| 	fileIds: mongo.ObjectID[]; | ||||
| 	replyId: mongo.ObjectID; | ||||
| 	renoteId: mongo.ObjectID; | ||||
| @@ -99,7 +101,9 @@ export type INote = { | ||||
| }; | ||||
|  | ||||
| export type IPoll = { | ||||
| 	choices: IChoice[] | ||||
| 	choices: IChoice[]; | ||||
| 	multiple?: boolean; | ||||
| 	expiresAt?: Date; | ||||
| }; | ||||
|  | ||||
| export type IChoice = { | ||||
| @@ -313,15 +317,31 @@ export const pack = async ( | ||||
| 		// Poll | ||||
| 		if (meId && _note.poll) { | ||||
| 			_note.poll = (async poll => { | ||||
| 				if (poll.multiple) { | ||||
| 					const votes = await PollVote.find({ | ||||
| 						userId: meId, | ||||
| 						noteId: id | ||||
| 					}); | ||||
|  | ||||
| 					const myChoices = (poll.choices as IChoice[]).filter(x => votes.some(y => x.id == y.choice)); | ||||
| 					for (const myChoice of myChoices) { | ||||
| 						(myChoice as any).isVoted = true; | ||||
| 					} | ||||
|  | ||||
| 					return poll; | ||||
| 				} else { | ||||
| 					poll.multiple = false; | ||||
| 				} | ||||
|  | ||||
| 				const vote = await PollVote | ||||
| 					.findOne({ | ||||
| 						userId: meId, | ||||
| 						noteId: id | ||||
| 					}); | ||||
|  | ||||
| 				if (vote != null) { | ||||
| 					const myChoice = poll.choices | ||||
| 						.filter((c: any) => c.id == vote.choice)[0]; | ||||
| 				if (vote) { | ||||
| 					const myChoice = (poll.choices as IChoice[]) | ||||
| 						.filter(x => x.id == vote.choice)[0] as any; | ||||
|  | ||||
| 					myChoice.isVoted = true; | ||||
| 				} | ||||
|   | ||||
| @@ -2,9 +2,10 @@ import * as mongo from 'mongodb'; | ||||
| import db from '../db/mongodb'; | ||||
|  | ||||
| const PollVote = db.get<IPollVote>('pollVotes'); | ||||
| PollVote.dropIndex(['userId', 'noteId'], { unique: true }).catch(() => {}); | ||||
| PollVote.createIndex('userId'); | ||||
| PollVote.createIndex('noteId'); | ||||
| PollVote.createIndex(['userId', 'noteId'], { unique: true }); | ||||
| PollVote.createIndex(['userId', 'noteId', 'choice'], { unique: true }); | ||||
| export default PollVote; | ||||
|  | ||||
| export interface IPollVote { | ||||
|   | ||||
| @@ -1,164 +1,166 @@ | ||||
| import * as Queue from 'bee-queue'; | ||||
| import * as Queue from 'bull'; | ||||
| import * as httpSignature from 'http-signature'; | ||||
|  | ||||
| import config from '../config'; | ||||
| import { ILocalUser } from '../models/user'; | ||||
| import { program } from '../argv'; | ||||
| import handler from './processors'; | ||||
|  | ||||
| import processDeliver from './processors/deliver'; | ||||
| import processInbox from './processors/inbox'; | ||||
| import processDb from './processors/db'; | ||||
| import { queueLogger } from './logger'; | ||||
|  | ||||
| const enableQueue = !program.disableQueue; | ||||
| const enableQueueProcessing = !program.onlyServer && enableQueue; | ||||
| const queueAvailable = config.redis != null; | ||||
|  | ||||
| const queue = initializeQueue(); | ||||
|  | ||||
| function initializeQueue() { | ||||
| 	if (queueAvailable && enableQueue) { | ||||
| 		return new Queue('misskey-queue', { | ||||
| 			redis: { | ||||
| 				port: config.redis.port, | ||||
| 				host: config.redis.host, | ||||
| 				password: config.redis.pass | ||||
| 			}, | ||||
|  | ||||
| 			removeOnSuccess: true, | ||||
| 			removeOnFailure: true, | ||||
| 			getEvents: false, | ||||
| 			sendEvents: false, | ||||
| 			storeJobs: false | ||||
| 		}); | ||||
| 	} else { | ||||
| 		return null; | ||||
| 	} | ||||
| function initializeQueue(name: string) { | ||||
| 	return new Queue(name, config.redis != null ? { | ||||
| 		redis: { | ||||
| 			port: config.redis.port, | ||||
| 			host: config.redis.host, | ||||
| 			password: config.redis.pass, | ||||
| 			db: config.redis.db || 0, | ||||
| 		}, | ||||
| 		prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue' | ||||
| 	} : null); | ||||
| } | ||||
|  | ||||
| export const deliverQueue = initializeQueue('deliver'); | ||||
| export const inboxQueue = initializeQueue('inbox'); | ||||
| export const dbQueue = initializeQueue('db'); | ||||
|  | ||||
| const deliverLogger = queueLogger.createSubLogger('deliver'); | ||||
| const inboxLogger = queueLogger.createSubLogger('inbox'); | ||||
|  | ||||
| deliverQueue | ||||
| 	.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) | ||||
| 	.on('active', (job) => deliverLogger.debug(`active id=${job.id} to=${job.data.to}`)) | ||||
| 	.on('completed', (job, result) => deliverLogger.debug(`completed(${result}) id=${job.id} to=${job.data.to}`)) | ||||
| 	.on('failed', (job, err) => deliverLogger.debug(`failed(${err}) id=${job.id} to=${job.data.to}`)) | ||||
| 	.on('error', (error) => deliverLogger.error(`error ${error}`)) | ||||
| 	.on('stalled', (job) => deliverLogger.warn(`stalled id=${job.id} to=${job.data.to}`)); | ||||
|  | ||||
| inboxQueue | ||||
| 	.on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) | ||||
| 	.on('active', (job) => inboxLogger.debug(`active id=${job.id}`)) | ||||
| 	.on('completed', (job, result) => inboxLogger.debug(`completed(${result}) id=${job.id}`)) | ||||
| 	.on('failed', (job, err) => inboxLogger.debug(`failed(${err}) id=${job.id}`)) | ||||
| 	.on('error', (error) => inboxLogger.error(`error ${error}`)) | ||||
| 	.on('stalled', (job) => inboxLogger.warn(`stalled id=${job.id}`)); | ||||
|  | ||||
| export function deliver(user: ILocalUser, content: any, to: any) { | ||||
| 	if (content == null) return; | ||||
| 	if (content == null) return null; | ||||
|  | ||||
| 	const data = { | ||||
| 		type: 'deliver', | ||||
| 		user, | ||||
| 		content, | ||||
| 		to | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data) | ||||
| 			.retries(8) | ||||
| 			.backoff('exponential', 1000) | ||||
| 			.save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	return deliverQueue.add(data, { | ||||
| 		attempts: 8, | ||||
| 		backoff: { | ||||
| 			type: 'exponential', | ||||
| 			delay: 60 * 1000 | ||||
| 		}, | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function processInbox(activity: any, signature: httpSignature.IParsedSignature) { | ||||
| export function inbox(activity: any, signature: httpSignature.IParsedSignature) { | ||||
| 	const data = { | ||||
| 		type: 'processInbox', | ||||
| 		activity: activity, | ||||
| 		signature | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data) | ||||
| 			.retries(3) | ||||
| 			.backoff('exponential', 500) | ||||
| 			.save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	return inboxQueue.add(data, { | ||||
| 		attempts: 8, | ||||
| 		backoff: { | ||||
| 			type: 'exponential', | ||||
| 			delay: 1000 | ||||
| 		}, | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createDeleteNotesJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'deleteNotes', | ||||
| 	return dbQueue.add('deleteNotes', { | ||||
| 		user: user | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createDeleteDriveFilesJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'deleteDriveFiles', | ||||
| 	return dbQueue.add('deleteDriveFiles', { | ||||
| 		user: user | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createExportNotesJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'exportNotes', | ||||
| 	return dbQueue.add('exportNotes', { | ||||
| 		user: user | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createExportFollowingJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'exportFollowing', | ||||
| 	return dbQueue.add('exportFollowing', { | ||||
| 		user: user | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createExportMuteJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'exportMute', | ||||
| 	return dbQueue.add('exportMute', { | ||||
| 		user: user | ||||
| 	}; | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function createExportBlockingJob(user: ILocalUser) { | ||||
| 	const data = { | ||||
| 		type: 'exportBlocking', | ||||
| 	return dbQueue.add('exportBlocking', { | ||||
| 		user: user | ||||
| 	}; | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		return queue.createJob(data).save(); | ||||
| 	} else { | ||||
| 		return handler({ data }, () => {}); | ||||
| 	} | ||||
| export function createExportUserListsJob(user: ILocalUser) { | ||||
| 	return dbQueue.add('exportUserLists', { | ||||
| 		user: user | ||||
| 	}, { | ||||
| 		removeOnComplete: true, | ||||
| 		removeOnFail: true | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export default function() { | ||||
| 	if (queueAvailable && enableQueueProcessing) { | ||||
| 		queue.process(128, handler); | ||||
| 		queueLogger.succ('Processing started'); | ||||
| 	if (!program.onlyServer) { | ||||
| 		deliverQueue.process(128, processDeliver); | ||||
| 		inboxQueue.process(128, processInbox); | ||||
| 		processDb(dbQueue); | ||||
| 	} | ||||
|  | ||||
| 	return queue; | ||||
| } | ||||
|  | ||||
| export function destroy() { | ||||
| 	queue.destroy().then(n => { | ||||
| 		queueLogger.succ(`All job removed (${n} jobs)`); | ||||
| 	deliverQueue.once('cleaned', (jobs, status) => { | ||||
| 		deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); | ||||
| 	}); | ||||
| 	deliverQueue.clean(0, 'wait'); | ||||
|  | ||||
| 	inboxQueue.once('cleaned', (jobs, status) => { | ||||
| 		inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); | ||||
| 	}); | ||||
| 	inboxQueue.clean(0, 'wait'); | ||||
| } | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import User from '../../models/user'; | ||||
| import DriveFile from '../../models/drive-file'; | ||||
| import deleteFile from '../../services/drive/delete-file'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import User from '../../../models/user'; | ||||
| import DriveFile from '../../../models/drive-file'; | ||||
| import deleteFile from '../../../services/drive/delete-file'; | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('delete-drive-files'); | ||||
| 
 | ||||
| export async function deleteDriveFiles(job: bq.Job, done: any): Promise<void> { | ||||
| export async function deleteDriveFiles(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Deleting drive files of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -32,7 +32,7 @@ export async function deleteDriveFiles(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (files.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -47,7 +47,7 @@ export async function deleteDriveFiles(job: bq.Job, done: any): Promise<void> { | ||||
| 			userId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(deletedCount / total); | ||||
| 		job.progress(deletedCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	logger.succ(`All drive files (${deletedCount}) of ${user._id} has been deleted.`); | ||||
| @@ -1,14 +1,14 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import Note from '../../models/note'; | ||||
| import deleteNote from '../../services/note/delete'; | ||||
| import User from '../../models/user'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import Note from '../../../models/note'; | ||||
| import deleteNote from '../../../services/note/delete'; | ||||
| import User from '../../../models/user'; | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('delete-notes'); | ||||
| 
 | ||||
| export async function deleteNotes(job: bq.Job, done: any): Promise<void> { | ||||
| export async function deleteNotes(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Deleting notes of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -32,7 +32,7 @@ export async function deleteNotes(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (notes.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -47,7 +47,7 @@ export async function deleteNotes(job: bq.Job, done: any): Promise<void> { | ||||
| 			userId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(deletedCount / total); | ||||
| 		job.progress(deletedCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	logger.succ(`All notes (${deletedCount}) of ${user._id} has been deleted.`); | ||||
| @@ -1,18 +1,18 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as tmp from 'tmp'; | ||||
| import * as fs from 'fs'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import addFile from '../../services/drive/add-file'; | ||||
| import User from '../../models/user'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import addFile from '../../../services/drive/add-file'; | ||||
| import User from '../../../models/user'; | ||||
| import dateFormat = require('dateformat'); | ||||
| import Blocking from '../../models/blocking'; | ||||
| import config from '../../config'; | ||||
| import Blocking from '../../../models/blocking'; | ||||
| import config from '../../../config'; | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('export-blocking'); | ||||
| 
 | ||||
| export async function exportBlocking(job: bq.Job, done: any): Promise<void> { | ||||
| export async function exportBlocking(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Exporting blocking of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -48,7 +48,7 @@ export async function exportBlocking(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (blockings.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -74,7 +74,7 @@ export async function exportBlocking(job: bq.Job, done: any): Promise<void> { | ||||
| 			blockerId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(exportedCount / total); | ||||
| 		job.progress(exportedCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	stream.end(); | ||||
| @@ -1,18 +1,18 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as tmp from 'tmp'; | ||||
| import * as fs from 'fs'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import addFile from '../../services/drive/add-file'; | ||||
| import User from '../../models/user'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import addFile from '../../../services/drive/add-file'; | ||||
| import User from '../../../models/user'; | ||||
| import dateFormat = require('dateformat'); | ||||
| import Following from '../../models/following'; | ||||
| import config from '../../config'; | ||||
| import Following from '../../../models/following'; | ||||
| import config from '../../../config'; | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('export-following'); | ||||
| 
 | ||||
| export async function exportFollowing(job: bq.Job, done: any): Promise<void> { | ||||
| export async function exportFollowing(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Exporting following of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -48,7 +48,7 @@ export async function exportFollowing(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (followings.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -74,7 +74,7 @@ export async function exportFollowing(job: bq.Job, done: any): Promise<void> { | ||||
| 			followerId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(exportedCount / total); | ||||
| 		job.progress(exportedCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	stream.end(); | ||||
| @@ -1,18 +1,18 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as tmp from 'tmp'; | ||||
| import * as fs from 'fs'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import addFile from '../../services/drive/add-file'; | ||||
| import User from '../../models/user'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import addFile from '../../../services/drive/add-file'; | ||||
| import User from '../../../models/user'; | ||||
| import dateFormat = require('dateformat'); | ||||
| import Mute from '../../models/mute'; | ||||
| import config from '../../config'; | ||||
| import Mute from '../../../models/mute'; | ||||
| import config from '../../../config'; | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('export-mute'); | ||||
| 
 | ||||
| export async function exportMute(job: bq.Job, done: any): Promise<void> { | ||||
| export async function exportMute(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Exporting mute of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -48,7 +48,7 @@ export async function exportMute(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (mutes.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -74,7 +74,7 @@ export async function exportMute(job: bq.Job, done: any): Promise<void> { | ||||
| 			muterId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(exportedCount / total); | ||||
| 		job.progress(exportedCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	stream.end(); | ||||
| @@ -1,17 +1,17 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as tmp from 'tmp'; | ||||
| import * as fs from 'fs'; | ||||
| import * as mongo from 'mongodb'; | ||||
| 
 | ||||
| import { queueLogger } from '../logger'; | ||||
| import Note, { INote } from '../../models/note'; | ||||
| import addFile from '../../services/drive/add-file'; | ||||
| import User from '../../models/user'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import Note, { INote } from '../../../models/note'; | ||||
| import addFile from '../../../services/drive/add-file'; | ||||
| import User from '../../../models/user'; | ||||
| import dateFormat = require('dateformat'); | ||||
| 
 | ||||
| const logger = queueLogger.createSubLogger('export-notes'); | ||||
| 
 | ||||
| export async function exportNotes(job: bq.Job, done: any): Promise<void> { | ||||
| export async function exportNotes(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Exporting notes of ${job.data.user._id} ...`); | ||||
| 
 | ||||
| 	const user = await User.findOne({ | ||||
| @@ -58,7 +58,7 @@ export async function exportNotes(job: bq.Job, done: any): Promise<void> { | ||||
| 
 | ||||
| 		if (notes.length === 0) { | ||||
| 			ended = true; | ||||
| 			if (job.reportProgress) job.reportProgress(100); | ||||
| 			job.progress(100); | ||||
| 			break; | ||||
| 		} | ||||
| 
 | ||||
| @@ -83,7 +83,7 @@ export async function exportNotes(job: bq.Job, done: any): Promise<void> { | ||||
| 			userId: user._id, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (job.reportProgress) job.reportProgress(exportedNotesCount / total); | ||||
| 		job.progress(exportedNotesCount / total); | ||||
| 	} | ||||
| 
 | ||||
| 	await new Promise((res, rej) => { | ||||
							
								
								
									
										73
									
								
								src/queue/processors/db/export-user-lists.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/queue/processors/db/export-user-lists.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import * as Bull from 'bull'; | ||||
| import * as tmp from 'tmp'; | ||||
| import * as fs from 'fs'; | ||||
| import * as mongo from 'mongodb'; | ||||
|  | ||||
| import { queueLogger } from '../../logger'; | ||||
| import addFile from '../../../services/drive/add-file'; | ||||
| import User from '../../../models/user'; | ||||
| import dateFormat = require('dateformat'); | ||||
| import config from '../../../config'; | ||||
| import UserList from '../../../models/user-list'; | ||||
|  | ||||
| const logger = queueLogger.createSubLogger('export-user-lists'); | ||||
|  | ||||
| export async function exportUserLists(job: Bull.Job, done: any): Promise<void> { | ||||
| 	logger.info(`Exporting user lists of ${job.data.user._id} ...`); | ||||
|  | ||||
| 	const user = await User.findOne({ | ||||
| 		_id: new mongo.ObjectID(job.data.user._id.toString()) | ||||
| 	}); | ||||
|  | ||||
| 	const lists = await UserList.find({ | ||||
| 		userId: user._id | ||||
| 	}); | ||||
|  | ||||
| 	// Create temp file | ||||
| 	const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { | ||||
| 		tmp.file((e, path, fd, cleanup) => { | ||||
| 			if (e) return rej(e); | ||||
| 			res([path, cleanup]); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	logger.info(`Temp file is ${path}`); | ||||
|  | ||||
| 	const stream = fs.createWriteStream(path, { flags: 'a' }); | ||||
|  | ||||
| 	for (const list of lists) { | ||||
| 		const users = await User.find({ | ||||
| 			_id: { $in: list.userIds } | ||||
| 		}, { | ||||
| 			fields: { | ||||
| 				username: true, | ||||
| 				host: true | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		for (const u of users) { | ||||
| 			const acct = u.host ? `${u.username}@${u.host}` : `${u.username}@${config.host}`; | ||||
| 			const content = `${list.title},${acct}`; | ||||
| 			await new Promise((res, rej) => { | ||||
| 				stream.write(content + '\n', err => { | ||||
| 					if (err) { | ||||
| 						logger.error(err); | ||||
| 						rej(err); | ||||
| 					} else { | ||||
| 						res(); | ||||
| 					} | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	stream.end(); | ||||
| 	logger.succ(`Exported to: ${path}`); | ||||
|  | ||||
| 	const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.csv'; | ||||
| 	const driveFile = await addFile(user, path, fileName); | ||||
|  | ||||
| 	logger.succ(`Exported to: ${driveFile._id}`); | ||||
| 	cleanup(); | ||||
| 	done(); | ||||
| } | ||||
| @@ -1,31 +1,24 @@ | ||||
| import deliver from './http/deliver'; | ||||
| import processInbox from './http/process-inbox'; | ||||
| import * as Bull from 'bull'; | ||||
| import { deleteNotes } from './delete-notes'; | ||||
| import { deleteDriveFiles } from './delete-drive-files'; | ||||
| import { exportNotes } from './export-notes'; | ||||
| import { exportFollowing } from './export-following'; | ||||
| import { exportMute } from './export-mute'; | ||||
| import { exportBlocking } from './export-blocking'; | ||||
| import { queueLogger } from '../logger'; | ||||
| import { exportUserLists } from './export-user-lists'; | ||||
| 
 | ||||
| const handlers: any = { | ||||
| 	deliver, | ||||
| 	processInbox, | ||||
| const jobs = { | ||||
| 	deleteNotes, | ||||
| 	deleteDriveFiles, | ||||
| 	exportNotes, | ||||
| 	exportFollowing, | ||||
| 	exportMute, | ||||
| 	exportBlocking, | ||||
| }; | ||||
| 	exportUserLists | ||||
| } as any; | ||||
| 
 | ||||
| export default (job: any, done: any) => { | ||||
| 	const handler = handlers[job.data.type]; | ||||
| 
 | ||||
| 	if (handler) { | ||||
| 		handler(job, done); | ||||
| 	} else { | ||||
| 		queueLogger.error(`Unknown job: ${job.data.type}`); | ||||
| 		done(); | ||||
| export default function(dbQueue: Bull.Queue) { | ||||
| 	for (const [k, v] of Object.entries(jobs)) { | ||||
| 		dbQueue.process(k, v as any); | ||||
| 	} | ||||
| }; | ||||
| } | ||||
| @@ -1,15 +1,22 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import request from '../../remote/activitypub/request'; | ||||
| import { registerOrFetchInstanceDoc } from '../../services/register-or-fetch-instance-doc'; | ||||
| import Instance from '../../models/instance'; | ||||
| import instanceChart from '../../services/chart/instance'; | ||||
| import Logger from '../../services/logger'; | ||||
| 
 | ||||
| import request from '../../../remote/activitypub/request'; | ||||
| import { queueLogger } from '../../logger'; | ||||
| import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc'; | ||||
| import Instance from '../../../models/instance'; | ||||
| import instanceChart from '../../../services/chart/instance'; | ||||
| const logger = new Logger('deliver'); | ||||
| 
 | ||||
| export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| let latest: string = null; | ||||
| 
 | ||||
| export default async (job: Bull.Job) => { | ||||
| 	const { host } = new URL(job.data.to); | ||||
| 
 | ||||
| 	try { | ||||
| 		if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { | ||||
| 			logger.debug(`delivering ${latest}`); | ||||
| 		} | ||||
| 
 | ||||
| 		await request(job.data.user, job.data.to, job.data.content); | ||||
| 
 | ||||
| 		// Update stats
 | ||||
| @@ -26,7 +33,7 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 			instanceChart.requestSent(i.host, true); | ||||
| 		}); | ||||
| 
 | ||||
| 		done(); | ||||
| 		return 'Success'; | ||||
| 	} catch (res) { | ||||
| 		// Update stats
 | ||||
| 		registerOrFetchInstanceDoc(host).then(i => { | ||||
| @@ -42,18 +49,21 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 		}); | ||||
| 
 | ||||
| 		if (res != null && res.hasOwnProperty('statusCode')) { | ||||
| 			queueLogger.warn(`deliver failed: ${res.statusCode} ${res.statusMessage} to=${job.data.to}`); | ||||
| 			logger.warn(`deliver failed: ${res.statusCode} ${res.statusMessage} to=${job.data.to}`); | ||||
| 
 | ||||
| 			// 4xx
 | ||||
| 			if (res.statusCode >= 400 && res.statusCode < 500) { | ||||
| 				// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
 | ||||
| 				// 何回再送しても成功することはないということなのでエラーにはしないでおく
 | ||||
| 				done(); | ||||
| 			} else { | ||||
| 				done(res.statusMessage); | ||||
| 				return `${res.statusCode} ${res.statusMessage}`; | ||||
| 			} | ||||
| 
 | ||||
| 			// 5xx etc.
 | ||||
| 			throw `${res.statusCode} ${res.statusMessage}`; | ||||
| 		} else { | ||||
| 			queueLogger.warn(`deliver failed: ${res} to=${job.data.to}`); | ||||
| 			done(); | ||||
| 			// DNS error, socket error, timeout ...
 | ||||
| 			logger.warn(`deliver failed: ${res} to=${job.data.to}`); | ||||
| 			throw res; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| @@ -1,21 +1,21 @@ | ||||
| import * as bq from 'bee-queue'; | ||||
| import * as Bull from 'bull'; | ||||
| import * as httpSignature from 'http-signature'; | ||||
| import parseAcct from '../../../misc/acct/parse'; | ||||
| import User, { IRemoteUser } from '../../../models/user'; | ||||
| import perform from '../../../remote/activitypub/perform'; | ||||
| import { resolvePerson, updatePerson } from '../../../remote/activitypub/models/person'; | ||||
| import parseAcct from '../../misc/acct/parse'; | ||||
| import User, { IRemoteUser } from '../../models/user'; | ||||
| import perform from '../../remote/activitypub/perform'; | ||||
| import { resolvePerson, updatePerson } from '../../remote/activitypub/models/person'; | ||||
| import { toUnicode } from 'punycode'; | ||||
| import { URL } from 'url'; | ||||
| import { publishApLogStream } from '../../../services/stream'; | ||||
| import Logger from '../../../services/logger'; | ||||
| import { registerOrFetchInstanceDoc } from '../../../services/register-or-fetch-instance-doc'; | ||||
| import Instance from '../../../models/instance'; | ||||
| import instanceChart from '../../../services/chart/instance'; | ||||
| import { publishApLogStream } from '../../services/stream'; | ||||
| import Logger from '../../services/logger'; | ||||
| import { registerOrFetchInstanceDoc } from '../../services/register-or-fetch-instance-doc'; | ||||
| import Instance from '../../models/instance'; | ||||
| import instanceChart from '../../services/chart/instance'; | ||||
| 
 | ||||
| const logger = new Logger('inbox'); | ||||
| 
 | ||||
| // ユーザーのinboxにアクティビティが届いた時の処理
 | ||||
| export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| export default async (job: Bull.Job): Promise<void> => { | ||||
| 	const signature = job.data.signature; | ||||
| 	const activity = job.data.activity; | ||||
| 
 | ||||
| @@ -33,7 +33,6 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 		const { username, host } = parseAcct(keyIdLower.slice('acct:'.length)); | ||||
| 		if (host === null) { | ||||
| 			logger.warn(`request was made by local user: @${username}`); | ||||
| 			done(); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| @@ -42,7 +41,6 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 			ValidateActivity(activity, host); | ||||
| 		} catch (e) { | ||||
| 			logger.warn(e.message); | ||||
| 			done(); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| @@ -50,8 +48,7 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 		// TODO: いちいちデータベースにアクセスするのはコスト高そうなのでどっかにキャッシュしておく
 | ||||
| 		const instance = await Instance.findOne({ host: host.toLowerCase() }); | ||||
| 		if (instance && instance.isBlocked) { | ||||
| 			logger.warn(`Blocked request: ${host}`); | ||||
| 			done(); | ||||
| 			logger.info(`Blocked request: ${host}`); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| @@ -63,7 +60,6 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 			ValidateActivity(activity, host); | ||||
| 		} catch (e) { | ||||
| 			logger.warn(e.message); | ||||
| 			done(); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| @@ -72,7 +68,6 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 		const instance = await Instance.findOne({ host: host.toLowerCase() }); | ||||
| 		if (instance && instance.isBlocked) { | ||||
| 			logger.warn(`Blocked request: ${host}`); | ||||
| 			done(); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| @@ -82,7 +77,7 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 		}) as IRemoteUser; | ||||
| 	} | ||||
| 
 | ||||
| 	// Update activityの場合は、ここで署名検証/更新処理まで実施して終了
 | ||||
| 	// Update Person activityの場合は、ここで署名検証/更新処理まで実施して終了
 | ||||
| 	if (activity.type === 'Update') { | ||||
| 		if (activity.object && activity.object.type === 'Person') { | ||||
| 			if (user == null) { | ||||
| @@ -92,9 +87,8 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 			} else { | ||||
| 				updatePerson(activity.actor, null, activity.object); | ||||
| 			} | ||||
| 			return; | ||||
| 		} | ||||
| 		done(); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	// アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
 | ||||
| @@ -103,13 +97,11 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 	} | ||||
| 
 | ||||
| 	if (user === null) { | ||||
| 		done(new Error('failed to resolve user')); | ||||
| 		return; | ||||
| 		throw new Error('failed to resolve user'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!httpSignature.verifySignature(signature, user.publicKey.publicKeyPem)) { | ||||
| 		logger.error('signature verification failed'); | ||||
| 		done(); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| @@ -136,12 +128,7 @@ export default async (job: bq.Job, done: any): Promise<void> => { | ||||
| 	}); | ||||
| 
 | ||||
| 	// アクティビティを処理
 | ||||
| 	try { | ||||
| 		await perform(user, activity); | ||||
| 		done(); | ||||
| 	} catch (e) { | ||||
| 		done(e); | ||||
| 	} | ||||
| 	await perform(user, activity); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @@ -27,6 +27,10 @@ export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> => | ||||
| 		announceNote(resolver, actor, activity, object as INote); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Question': | ||||
| 		announceNote(resolver, actor, activity, object as INote); | ||||
| 		break; | ||||
|  | ||||
| 	default: | ||||
| 		logger.warn(`Unknown announce type: ${object.type}`); | ||||
| 		break; | ||||
|   | ||||
| @@ -29,7 +29,19 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity: | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const renote = await resolveNote(note); | ||||
| 	// Announce対象をresolve | ||||
| 	let renote; | ||||
| 	try { | ||||
| 		renote = await resolveNote(note); | ||||
| 	} catch (e) { | ||||
| 		// 対象が4xxならスキップ | ||||
| 		if (e.statusCode >= 400 && e.statusCode < 500) { | ||||
| 			logger.warn(`Ignored announce target ${note.inReplyTo} - ${e.statusCode}`); | ||||
| 			return; | ||||
| 		} | ||||
| 		logger.warn(`Error in announce target ${note.inReplyTo} - ${e.statusCode || e}`); | ||||
| 		throw e; | ||||
| 	} | ||||
|  | ||||
| 	logger.info(`Creating the (Re)Note: ${uri}`); | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import Resolver from '../../resolver'; | ||||
| import { IRemoteUser } from '../../../../models/user'; | ||||
| import createNote from './note'; | ||||
| import createImage from './image'; | ||||
| import createNote from './note'; | ||||
| import { ICreate } from '../../type'; | ||||
| import { apLogger } from '../../logger'; | ||||
|  | ||||
| @@ -32,6 +32,10 @@ export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => { | ||||
| 		createNote(resolver, actor, object); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Question': | ||||
| 		createNote(resolver, actor, object); | ||||
| 		break; | ||||
|  | ||||
| 	default: | ||||
| 		logger.warn(`Unknown type: ${object.type}`); | ||||
| 		break; | ||||
|   | ||||
| @@ -24,6 +24,10 @@ export default async (actor: IRemoteUser, activity: IDelete): Promise<void> => { | ||||
| 		deleteNote(actor, uri); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Question': | ||||
| 		deleteNote(actor, uri); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Tombstone': | ||||
| 		const note = await Note.findOne({ uri }); | ||||
| 		if (note != null) { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { Object } from '../type'; | ||||
| import { IRemoteUser } from '../../../models/user'; | ||||
| import create from './create'; | ||||
| import performDeleteActivity from './delete'; | ||||
| import performUpdateActivity from './update'; | ||||
| import follow from './follow'; | ||||
| import undo from './undo'; | ||||
| import like from './like'; | ||||
| @@ -23,6 +24,10 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { | ||||
| 		await performDeleteActivity(actor, activity); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Update': | ||||
| 		await performUpdateActivity(actor, activity); | ||||
| 		break; | ||||
|  | ||||
| 	case 'Follow': | ||||
| 		await follow(actor, activity); | ||||
| 		break; | ||||
|   | ||||
							
								
								
									
										28
									
								
								src/remote/activitypub/kernel/update/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/remote/activitypub/kernel/update/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import { IRemoteUser } from '../../../../models/user'; | ||||
| import { IUpdate, IObject } from '../../type'; | ||||
| import { apLogger } from '../../logger'; | ||||
| import { updateQuestion } from '../../models/question'; | ||||
|  | ||||
| /** | ||||
|  * Updateアクティビティを捌きます | ||||
|  */ | ||||
| export default async (actor: IRemoteUser, activity: IUpdate): Promise<void> => { | ||||
| 	if ('actor' in activity && actor.uri !== activity.actor) { | ||||
| 		throw new Error('invalid actor'); | ||||
| 	} | ||||
|  | ||||
| 	apLogger.debug('Update'); | ||||
|  | ||||
| 	const object = activity.object as IObject; | ||||
|  | ||||
| 	switch (object.type) { | ||||
| 		case 'Question': | ||||
| 			apLogger.debug('Question'); | ||||
| 			await updateQuestion(object).catch(e => console.log(e)); | ||||
| 			break; | ||||
|  | ||||
| 		default: | ||||
| 			apLogger.warn(`Unknown type: ${object.type}`); | ||||
| 			break; | ||||
| 	} | ||||
| }; | ||||
| @@ -27,7 +27,17 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<IDriv | ||||
| 	const instance = await fetchMeta(); | ||||
| 	const cache = instance.cacheRemoteFiles; | ||||
|  | ||||
| 	let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache); | ||||
| 	let file; | ||||
| 	try { | ||||
| 		file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache); | ||||
| 	} catch (e) { | ||||
| 		// 4xxの場合は添付されてなかったことにする | ||||
| 		if (e >= 400 && e < 500) { | ||||
| 			logger.warn(`Ignored image: ${image.url} - ${e}`); | ||||
| 			return null; | ||||
| 		} | ||||
| 		throw e; | ||||
| 	} | ||||
|  | ||||
| 	if (file.metadata.isRemote) { | ||||
| 		// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import { extractPollFromQuestion } from './question'; | ||||
| import vote from '../../../services/note/polls/vote'; | ||||
| import { apLogger } from '../logger'; | ||||
| import { IDriveFile } from '../../../models/drive-file'; | ||||
| import { deliverQuestionUpdate } from '../../../services/note/polls/update'; | ||||
|  | ||||
| const logger = apLogger; | ||||
|  | ||||
| @@ -52,15 +53,23 @@ export async function fetchNote(value: string | IObject, resolver?: Resolver): P | ||||
| export async function createNote(value: any, resolver?: Resolver, silent = false): Promise<INote> { | ||||
| 	if (resolver == null) resolver = new Resolver(); | ||||
|  | ||||
| 	const object = await resolver.resolve(value) as any; | ||||
| 	const object: any = await resolver.resolve(value); | ||||
|  | ||||
| 	if (object == null || object.type !== 'Note') { | ||||
| 		logger.error(`invalid note: ${object}`); | ||||
| 	if (!object || !['Note', 'Question'].includes(object.type)) { | ||||
| 		logger.error(`invalid note: ${value}`, { | ||||
| 			resolver: { | ||||
| 				history: resolver.getHistory() | ||||
| 			}, | ||||
| 			value: value, | ||||
| 			object: object | ||||
| 		}); | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	const note: INoteActivityStreamsObject = object; | ||||
|  | ||||
| 	logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); | ||||
|  | ||||
| 	logger.info(`Creating the Note: ${note.id}`); | ||||
|  | ||||
| 	// 投稿者をフェッチ | ||||
| @@ -72,6 +81,9 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
| 	} | ||||
|  | ||||
| 	//#region Visibility | ||||
| 	note.to = note.to == null ? [] : typeof note.to == 'string' ? [note.to] : note.to; | ||||
| 	note.cc = note.cc == null ? [] : typeof note.cc == 'string' ? [note.cc] : note.cc; | ||||
|  | ||||
| 	let visibility = 'public'; | ||||
| 	let visibleUsers: IUser[] = []; | ||||
| 	if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) { | ||||
| @@ -83,7 +95,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
| 			visibility = 'specified'; | ||||
| 			visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver))); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 	//#endergion | ||||
|  | ||||
| 	const apMentions = await extractMentionedUsers(actor, note.to, note.cc, resolver); | ||||
| @@ -95,13 +107,26 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
| 	// TODO: attachmentは必ずしも配列ではない | ||||
| 	// Noteがsensitiveなら添付もsensitiveにする | ||||
| 	const limit = promiseLimit(2); | ||||
|  | ||||
| 	note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; | ||||
| 	const files = note.attachment | ||||
| 		.map(attach => attach.sensitive = note.sensitive) | ||||
| 		? await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<IDriveFile>)) | ||||
| 		? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<IDriveFile>))) | ||||
| 			.filter(image => image != null) | ||||
| 		: []; | ||||
|  | ||||
| 	// リプライ | ||||
| 	const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null; | ||||
| 	const reply = note.inReplyTo | ||||
| 		? await resolveNote(note.inReplyTo, resolver).catch(e => { | ||||
| 			// 4xxの場合はリプライしてないことにする | ||||
| 			if (e.statusCode >= 400 && e.statusCode < 500) { | ||||
| 				logger.warn(`Ignored inReplyTo ${note.inReplyTo} - ${e.statusCode} `); | ||||
| 				return null; | ||||
| 			} | ||||
| 			logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`); | ||||
| 			throw e; | ||||
| 		}) | ||||
| 		: null; | ||||
|  | ||||
| 	// 引用 | ||||
| 	let quote: INote; | ||||
| @@ -113,15 +138,34 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
| 	const cw = note.summary === '' ? null : note.summary; | ||||
|  | ||||
| 	// テキストのパース | ||||
| 	const text = note._misskey_content ? note._misskey_content : fromHtml(note.content); | ||||
| 	const text = note._misskey_content || fromHtml(note.content); | ||||
|  | ||||
| 	// vote | ||||
| 	if (reply && reply.poll && text != null) { | ||||
| 		const m = text.match(/([0-9])$/); | ||||
| 		if (m) { | ||||
| 			logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${m[0]}`); | ||||
| 			await vote(actor, reply, Number(m[1])); | ||||
| 	if (reply && reply.poll) { | ||||
| 		const tryCreateVote = async (name: string, index: number): Promise<null> => { | ||||
| 			if (reply.poll.expiresAt && Date.now() > new Date(reply.poll.expiresAt).getTime()) { | ||||
| 				logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); | ||||
| 			} else if (index >= 0) { | ||||
| 				logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); | ||||
| 				await vote(actor, reply, index); | ||||
|  | ||||
| 				// リモートフォロワーにUpdate配信 | ||||
| 				deliverQuestionUpdate(reply._id); | ||||
| 			} | ||||
| 			return null; | ||||
| 		}; | ||||
|  | ||||
| 		if (note.name) { | ||||
| 			return await tryCreateVote(note.name, reply.poll.choices.findIndex(x => x.text === note.name)); | ||||
| 		} | ||||
|  | ||||
| 		// 後方互換性のため | ||||
| 		if (text) { | ||||
| 			const m = text.match(/(\d+)$/); | ||||
|  | ||||
| 			if (m) { | ||||
| 				return await tryCreateVote(m[0], Number(m[1])); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -133,7 +177,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
| 	const apEmojis = emojis.map(emoji => emoji.name); | ||||
|  | ||||
| 	const questionUri = note._misskey_question; | ||||
| 	const poll = questionUri ? await extractPollFromQuestion(questionUri).catch(() => undefined) : undefined; | ||||
| 	const poll = await extractPollFromQuestion(note._misskey_question || note).catch(() => undefined); | ||||
|  | ||||
| 	// ユーザーの情報が古かったらついでに更新しておく | ||||
| 	if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { | ||||
| @@ -142,11 +186,11 @@ export async function createNote(value: any, resolver?: Resolver, silent = false | ||||
|  | ||||
| 	return await post(actor, { | ||||
| 		createdAt: new Date(note.published), | ||||
| 		files: files, | ||||
| 		files, | ||||
| 		reply, | ||||
| 		renote: quote, | ||||
| 		cw: cw, | ||||
| 		text: text, | ||||
| 		cw, | ||||
| 		text, | ||||
| 		viaMobile: false, | ||||
| 		localOnly: false, | ||||
| 		geo: undefined, | ||||
|   | ||||
| @@ -1,19 +1,79 @@ | ||||
| import { IChoice, IPoll } from '../../../models/note'; | ||||
| import config from '../../../config'; | ||||
| import Note, { IChoice, IPoll } from '../../../models/note'; | ||||
| import Resolver from '../resolver'; | ||||
| import { IQuestion } from '../type'; | ||||
| import { apLogger } from '../logger'; | ||||
|  | ||||
| export async function extractPollFromQuestion(questionUri: string): Promise<IPoll> { | ||||
| 	const resolver = new Resolver(); | ||||
| 	const question = await resolver.resolve(questionUri) as any; | ||||
| export async function extractPollFromQuestion(source: string | IQuestion): Promise<IPoll> { | ||||
| 	const question = typeof source === 'string' ? await new Resolver().resolve(source) as IQuestion : source; | ||||
| 	const multiple = !question.oneOf; | ||||
| 	const expiresAt = question.endTime ? new Date(question.endTime) : null; | ||||
|  | ||||
| 	const choices: IChoice[] = question.oneOf.map((x: any, i: number) => { | ||||
| 			return { | ||||
| 				id: i, | ||||
| 				text: x.name, | ||||
| 				votes: x._misskey_votes || 0, | ||||
| 			} as IChoice; | ||||
| 	}); | ||||
| 	if (multiple && !question.anyOf) { | ||||
| 		throw 'invalid question'; | ||||
| 	} | ||||
|  | ||||
| 	const choices = question[multiple ? 'anyOf' : 'oneOf'] | ||||
| 		.map((x, i) => ({ | ||||
| 			id: i, | ||||
| 			text: x.name, | ||||
| 			votes: x.replies && x.replies.totalItems || x._misskey_votes || 0, | ||||
| 		} as IChoice)); | ||||
|  | ||||
| 	return { | ||||
| 		choices | ||||
| 		choices, | ||||
| 		multiple, | ||||
| 		expiresAt | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Update votes of Question | ||||
|  * @param uri URI of AP Question object | ||||
|  * @returns true if updated | ||||
|  */ | ||||
| export async function updateQuestion(value: any) { | ||||
| 	const uri = typeof value == 'string' ? value : value.id; | ||||
|  | ||||
| 	// URIがこのサーバーを指しているならスキップ | ||||
| 	if (uri.startsWith(config.url + '/')) throw 'uri points local'; | ||||
|  | ||||
| 	//#region このサーバーに既に登録されているか | ||||
| 	const note = await Note.findOne({ uri }); | ||||
|  | ||||
| 	if (note == null) throw 'Question is not registed'; | ||||
| 	//#endregion | ||||
|  | ||||
| 	// resolve new Question object | ||||
| 	const resolver = new Resolver(); | ||||
| 	const question = await resolver.resolve(value) as IQuestion; | ||||
| 	apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); | ||||
|  | ||||
| 	if (question.type !== 'Question') throw 'object is not a Question'; | ||||
|  | ||||
| 	const apChoices = question.oneOf || question.anyOf; | ||||
| 	const dbChoices = note.poll.choices; | ||||
|  | ||||
| 	let changed = false; | ||||
|  | ||||
| 	for (const db of dbChoices) { | ||||
| 		const oldCount = db.votes; | ||||
| 		const newCount = apChoices.filter(ap => ap.name === db.text)[0].replies.totalItems; | ||||
|  | ||||
| 		if (oldCount != newCount) { | ||||
| 			changed = true; | ||||
| 			db.votes = newCount; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	await Note.update({ | ||||
| 		_id: note._id | ||||
| 	}, { | ||||
| 		$set: { | ||||
| 			'poll.choices': dbChoices, | ||||
| 			updatedAt: new Date(), | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| 	return changed; | ||||
| } | ||||
|   | ||||
| @@ -15,9 +15,10 @@ export default async function renderNote(note: INote, dive = true): Promise<any> | ||||
| 		: Promise.resolve([]); | ||||
|  | ||||
| 	let inReplyTo; | ||||
| 	let inReplyToNote: INote; | ||||
|  | ||||
| 	if (note.replyId) { | ||||
| 		const inReplyToNote = await Note.findOne({ | ||||
| 		inReplyToNote = await Note.findOne({ | ||||
| 			_id: note.replyId, | ||||
| 		}); | ||||
|  | ||||
| @@ -134,6 +135,29 @@ export default async function renderNote(note: INote, dive = true): Promise<any> | ||||
| 		...apemojis, | ||||
| 	]; | ||||
|  | ||||
| 	const { | ||||
| 		choices = [], | ||||
| 		expiresAt = null, | ||||
| 		multiple = false | ||||
| 	} = note.poll || {}; | ||||
|  | ||||
| 	const asPoll = note.poll ? { | ||||
| 		type: 'Question', | ||||
| 		content: toHtml(Object.assign({}, note, { | ||||
| 			text: text | ||||
| 		})), | ||||
| 		_misskey_fallback_content: content, | ||||
| 		[expiresAt && expiresAt < new Date() ? 'closed' : 'endTime']: expiresAt, | ||||
| 		[multiple ? 'anyOf' : 'oneOf']: choices.map(({ text, votes }) => ({ | ||||
| 			type: 'Note', | ||||
| 			name: text, | ||||
| 			replies: { | ||||
| 				type: 'Collection', | ||||
| 				totalItems: votes | ||||
| 			} | ||||
| 		})) | ||||
| 	} : {}; | ||||
|  | ||||
| 	return { | ||||
| 		id: `${config.url}/notes/${note._id}`, | ||||
| 		type: 'Note', | ||||
| @@ -149,7 +173,8 @@ export default async function renderNote(note: INote, dive = true): Promise<any> | ||||
| 		inReplyTo, | ||||
| 		attachment: files.map(renderDocument), | ||||
| 		sensitive: files.some(file => file.metadata.isSensitive), | ||||
| 		tag | ||||
| 		tag, | ||||
| 		...asPoll | ||||
| 	}; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,17 +3,19 @@ import { ILocalUser } from '../../../models/user'; | ||||
| import { INote } from '../../../models/note'; | ||||
|  | ||||
| export default async function renderQuestion(user: ILocalUser, note: INote) { | ||||
| 	const question =  { | ||||
| 	const question = { | ||||
| 		type: 'Question', | ||||
| 		id: `${config.url}/questions/${note._id}`, | ||||
| 		actor: `${config.url}/users/${user._id}`, | ||||
| 		content:  note.text != null ? note.text : '', | ||||
| 		oneOf: note.poll.choices.map(c => { | ||||
| 			return { | ||||
| 				name: c.text, | ||||
| 				_misskey_votes: c.votes, | ||||
| 			}; | ||||
| 		}), | ||||
| 		content:  note.text || '', | ||||
| 		[note.poll.multiple ? 'anyOf' : 'oneOf']: note.poll.choices.map(c => ({ | ||||
| 			name: c.text, | ||||
| 			_misskey_votes: c.votes, | ||||
| 			replies: { | ||||
| 				type: 'Collection', | ||||
| 				totalItems: c.votes | ||||
| 			} | ||||
| 		})) | ||||
| 	}; | ||||
|  | ||||
| 	return question; | ||||
|   | ||||
							
								
								
									
										22
									
								
								src/remote/activitypub/renderer/vote.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/remote/activitypub/renderer/vote.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import config from '../../../config'; | ||||
| import { INote } from '../../../models/note'; | ||||
| import { IRemoteUser, ILocalUser } from '../../../models/user'; | ||||
| import { IPollVote } from '../../../models/poll-vote'; | ||||
|  | ||||
| export default async function renderVote(user: ILocalUser, vote: IPollVote, pollNote: INote, pollOwner: IRemoteUser): Promise<any> { | ||||
| 	return { | ||||
| 		id: `${config.url}/users/${user._id}#votes/${vote._id}/activity`, | ||||
| 		actor: `${config.url}/users/${user._id}`, | ||||
| 		type: 'Create', | ||||
| 		to: [pollOwner.uri], | ||||
| 		published: new Date().toISOString(), | ||||
| 		object: { | ||||
| 			id: `${config.url}/users/${user._id}#votes/${vote._id}`, | ||||
| 			type: 'Note', | ||||
| 			attributedTo: `${config.url}/users/${user._id}`, | ||||
| 			to: [pollOwner.uri], | ||||
| 			inReplyTo: pollNote.uri, | ||||
| 			name: pollNote.poll.choices.find(x => x.id === vote.choice).text | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
| @@ -14,7 +14,7 @@ import Instance from '../../models/instance'; | ||||
|  | ||||
| export const logger = apLogger.createSubLogger('deliver'); | ||||
|  | ||||
| export default (user: ILocalUser, url: string, object: any) => new Promise(async (resolve, reject) => { | ||||
| export default async (user: ILocalUser, url: string, object: any) => { | ||||
| 	logger.info(`--> ${url}`); | ||||
|  | ||||
| 	const timeout = 10 * 1000; | ||||
| @@ -32,53 +32,57 @@ export default (user: ILocalUser, url: string, object: any) => new Promise(async | ||||
| 	sha256.update(data); | ||||
| 	const hash = sha256.digest('base64'); | ||||
|  | ||||
| 	const addr = await resolveAddr(hostname).catch(e => reject(e)); | ||||
| 	const addr = await resolveAddr(hostname); | ||||
| 	if (!addr) return; | ||||
|  | ||||
| 	const req = request({ | ||||
| 		protocol, | ||||
| 		hostname: addr, | ||||
| 		setHost: false, | ||||
| 		port, | ||||
| 		method: 'POST', | ||||
| 		path: pathname + search, | ||||
| 		timeout, | ||||
| 		headers: { | ||||
| 			'Host': host, | ||||
| 			'User-Agent': config.userAgent, | ||||
| 			'Content-Type': 'application/activity+json', | ||||
| 			'Digest': `SHA-256=${hash}` | ||||
| 		} | ||||
| 	}, res => { | ||||
| 		if (res.statusCode >= 400) { | ||||
| 			logger.warn(`${url} --> ${res.statusCode}`); | ||||
| 			reject(res); | ||||
| 		} else { | ||||
| 			logger.succ(`${url} --> ${res.statusCode}`); | ||||
| 			resolve(); | ||||
| 		} | ||||
| 	const _ = new Promise((resolve, reject) => { | ||||
| 		const req = request({ | ||||
| 			protocol, | ||||
| 			hostname: addr, | ||||
| 			setHost: false, | ||||
| 			port, | ||||
| 			method: 'POST', | ||||
| 			path: pathname + search, | ||||
| 			timeout, | ||||
| 			headers: { | ||||
| 				'Host': host, | ||||
| 				'User-Agent': config.userAgent, | ||||
| 				'Content-Type': 'application/activity+json', | ||||
| 				'Digest': `SHA-256=${hash}` | ||||
| 			} | ||||
| 		}, res => { | ||||
| 			if (res.statusCode >= 400) { | ||||
| 				logger.warn(`${url} --> ${res.statusCode}`); | ||||
| 				reject(res); | ||||
| 			} else { | ||||
| 				logger.succ(`${url} --> ${res.statusCode}`); | ||||
| 				resolve(); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		sign(req, { | ||||
| 			authorizationHeaderName: 'Signature', | ||||
| 			key: user.keypair, | ||||
| 			keyId: `${config.url}/users/${user._id}/publickey`, | ||||
| 			headers: ['date', 'host', 'digest'] | ||||
| 		}); | ||||
|  | ||||
| 		// Signature: Signature ... => Signature: ... | ||||
| 		let sig = req.getHeader('Signature').toString(); | ||||
| 		sig = sig.replace(/^Signature /, ''); | ||||
| 		req.setHeader('Signature', sig); | ||||
|  | ||||
| 		req.on('timeout', () => req.abort()); | ||||
|  | ||||
| 		req.on('error', e => { | ||||
| 			if (req.aborted) reject('timeout'); | ||||
| 			reject(e); | ||||
| 		}); | ||||
|  | ||||
| 		req.end(data); | ||||
| 	}); | ||||
|  | ||||
| 	sign(req, { | ||||
| 		authorizationHeaderName: 'Signature', | ||||
| 		key: user.keypair, | ||||
| 		keyId: `${config.url}/users/${user._id}/publickey`, | ||||
| 		headers: ['date', 'host', 'digest'] | ||||
| 	}); | ||||
|  | ||||
| 	// Signature: Signature ... => Signature: ... | ||||
| 	let sig = req.getHeader('Signature').toString(); | ||||
| 	sig = sig.replace(/^Signature /, ''); | ||||
| 	req.setHeader('Signature', sig); | ||||
|  | ||||
| 	req.on('timeout', () => req.abort()); | ||||
|  | ||||
| 	req.on('error', e => { | ||||
| 		if (req.aborted) reject('timeout'); | ||||
| 		reject(e); | ||||
| 	}); | ||||
|  | ||||
| 	req.end(data); | ||||
| 	await _; | ||||
|  | ||||
| 	//#region Log | ||||
| 	publishApLogStream({ | ||||
| @@ -88,7 +92,7 @@ export default (user: ILocalUser, url: string, object: any) => new Promise(async | ||||
| 		actor: user.username | ||||
| 	}); | ||||
| 	//#endregion | ||||
| }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Resolve host (with cached, asynchrony) | ||||
|   | ||||
| @@ -1,9 +1,6 @@ | ||||
| import * as request from 'request-promise-native'; | ||||
| import { IObject } from './type'; | ||||
| import config from '../../config'; | ||||
| import { apLogger } from './logger'; | ||||
|  | ||||
| export const logger = apLogger.createSubLogger('resolver'); | ||||
|  | ||||
| export default class Resolver { | ||||
| 	private history: Set<string>; | ||||
| @@ -13,6 +10,10 @@ export default class Resolver { | ||||
| 		this.history = new Set(); | ||||
| 	} | ||||
|  | ||||
| 	public getHistory(): string[] { | ||||
| 		return Array.from(this.history); | ||||
| 	} | ||||
|  | ||||
| 	public async resolveCollection(value: any) { | ||||
| 		const collection = typeof value === 'string' | ||||
| 			? await this.resolve(value) | ||||
| @@ -30,7 +31,6 @@ export default class Resolver { | ||||
| 			} | ||||
|  | ||||
| 			default: { | ||||
| 				logger.error(`unknown collection type: ${collection.type}`); | ||||
| 				throw new Error(`unknown collection type: ${collection.type}`); | ||||
| 			} | ||||
| 		} | ||||
| @@ -40,7 +40,6 @@ export default class Resolver { | ||||
|  | ||||
| 	public async resolve(value: any): Promise<IObject> { | ||||
| 		if (value == null) { | ||||
| 			logger.error('resolvee is null (or undefined)'); | ||||
| 			throw new Error('resolvee is null (or undefined)'); | ||||
| 		} | ||||
|  | ||||
| @@ -49,7 +48,6 @@ export default class Resolver { | ||||
| 		} | ||||
|  | ||||
| 		if (this.history.has(value)) { | ||||
| 			logger.error(`cannot resolve already resolved one: ${value}`); | ||||
| 			throw new Error('cannot resolve already resolved one'); | ||||
| 		} | ||||
|  | ||||
| @@ -64,12 +62,6 @@ export default class Resolver { | ||||
| 				Accept: 'application/activity+json, application/ld+json' | ||||
| 			}, | ||||
| 			json: true | ||||
| 		}).catch(e => { | ||||
| 			logger.error(`request error: ${value}: ${e.message}`, { | ||||
| 				url: value, | ||||
| 				e: e | ||||
| 			}); | ||||
| 			throw new Error(`request error: ${e.message}`); | ||||
| 		}); | ||||
|  | ||||
| 		if (object === null || ( | ||||
| @@ -77,10 +69,6 @@ export default class Resolver { | ||||
| 				!object['@context'].includes('https://www.w3.org/ns/activitystreams') : | ||||
| 				object['@context'] !== 'https://www.w3.org/ns/activitystreams' | ||||
| 		)) { | ||||
| 			logger.error(`invalid response: ${value}`, { | ||||
| 				url: value, | ||||
| 				object: object | ||||
| 			}); | ||||
| 			throw new Error('invalid response'); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,11 @@ export interface IObject { | ||||
| 	attributedTo: string; | ||||
| 	attachment?: any[]; | ||||
| 	inReplyTo?: any; | ||||
| 	replies?: ICollection; | ||||
| 	content: string; | ||||
| 	name?: string; | ||||
| 	startTime?: Date; | ||||
| 	endTime?: Date; | ||||
| 	icon?: any; | ||||
| 	image?: any; | ||||
| 	url?: string; | ||||
| @@ -39,12 +43,28 @@ export interface IOrderedCollection extends IObject { | ||||
| } | ||||
|  | ||||
| export interface INote extends IObject { | ||||
| 	type: 'Note'; | ||||
| 	type: 'Note' | 'Question'; | ||||
| 	_misskey_content: string; | ||||
| 	_misskey_quote: string; | ||||
| 	_misskey_question: string; | ||||
| } | ||||
|  | ||||
| export interface IQuestion extends IObject { | ||||
| 	type: 'Note' | 'Question'; | ||||
| 	_misskey_content: string; | ||||
| 	_misskey_quote: string; | ||||
| 	_misskey_question: string; | ||||
| 	oneOf?: IQuestionChoice[]; | ||||
| 	anyOf?: IQuestionChoice[]; | ||||
| 	endTime?: Date; | ||||
| } | ||||
|  | ||||
| interface IQuestionChoice { | ||||
| 	name?: string; | ||||
| 	replies?: ICollection; | ||||
| 	_misskey_votes?: number; | ||||
| } | ||||
|  | ||||
| export interface IPerson extends IObject { | ||||
| 	type: 'Person'; | ||||
| 	name: string; | ||||
| @@ -77,6 +97,10 @@ export interface IDelete extends IActivity { | ||||
| 	type: 'Delete'; | ||||
| } | ||||
|  | ||||
| export interface IUpdate extends IActivity { | ||||
| 	type: 'Update'; | ||||
| } | ||||
|  | ||||
| export interface IUndo extends IActivity { | ||||
| 	type: 'Undo'; | ||||
| } | ||||
| @@ -119,6 +143,7 @@ export type Object = | ||||
| 	IOrderedCollection | | ||||
| 	ICreate | | ||||
| 	IDelete | | ||||
| 	IUpdate | | ||||
| 	IUndo | | ||||
| 	IFollow | | ||||
| 	IAccept | | ||||
|   | ||||
| @@ -16,7 +16,7 @@ import Followers from './activitypub/followers'; | ||||
| import Following from './activitypub/following'; | ||||
| import Featured from './activitypub/featured'; | ||||
| import renderQuestion from '../remote/activitypub/renderer/question'; | ||||
| import { processInbox } from '../queue'; | ||||
| import { inbox as processInbox } from '../queue'; | ||||
|  | ||||
| // Init router | ||||
| const router = new Router(); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { performance } from 'perf_hooks'; | ||||
| import limiter from './limiter'; | ||||
| import { IUser } from '../../models/user'; | ||||
| import { IApp } from '../../models/app'; | ||||
| @@ -71,6 +72,7 @@ export default async (endpoint: string, user: IUser, app: IApp, data: any, file? | ||||
| 	} | ||||
|  | ||||
| 	// API invoking | ||||
| 	const before = performance.now(); | ||||
| 	return await ep.exec(data, user, app, file).catch((e: Error) => { | ||||
| 		if (e instanceof ApiError) { | ||||
| 			throw e; | ||||
| @@ -88,5 +90,11 @@ export default async (endpoint: string, user: IUser, app: IApp, data: any, file? | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	}).finally(() => { | ||||
| 		const after = performance.now(); | ||||
| 		const time = after - before; | ||||
| 		if (time > 1000) { | ||||
| 			apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										21
									
								
								src/server/api/endpoints/admin/queue/stats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/server/api/endpoints/admin/queue/stats.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import define from '../../../define'; | ||||
| import { deliverQueue, inboxQueue } from '../../../../../queue'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
|  | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
|  | ||||
| 	params: {} | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps) => { | ||||
| 	const deliverJobCounts = await deliverQueue.getJobCounts(); | ||||
| 	const inboxJobCounts = await inboxQueue.getJobCounts(); | ||||
|  | ||||
| 	return { | ||||
| 		deliver: deliverJobCounts, | ||||
| 		inbox: inboxJobCounts | ||||
| 	}; | ||||
| }); | ||||
| @@ -97,7 +97,7 @@ async function fetchAny(uri: string) { | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	if (object.type === 'Note') { | ||||
| 	if (['Note', 'Question'].includes(object.type)) { | ||||
| 		const note = await createNote(object.id); | ||||
| 		return { | ||||
| 			type: 'Note', | ||||
|   | ||||
							
								
								
									
										18
									
								
								src/server/api/endpoints/i/export-user-lists.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/server/api/endpoints/i/export-user-lists.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import define from '../../define'; | ||||
| import { createExportUserListsJob } from '../../../../queue'; | ||||
| import ms = require('ms'); | ||||
|  | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| 	requireCredential: true, | ||||
| 	limit: { | ||||
| 		duration: ms('1min'), | ||||
| 		max: 1, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	createExportUserListsJob(user); | ||||
|  | ||||
| 	return; | ||||
| }); | ||||
							
								
								
									
										132
									
								
								src/server/api/endpoints/notes/children.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/server/api/endpoints/notes/children.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| import $ from 'cafy'; | ||||
| import ID, { transform } from '../../../../misc/cafy-id'; | ||||
| import Note, { packMany } from '../../../../models/note'; | ||||
| import define from '../../define'; | ||||
| import { getFriends } from '../../common/get-friends'; | ||||
| import { getHideUserIds } from '../../common/get-hide-users'; | ||||
|  | ||||
| export const meta = { | ||||
| 	desc: { | ||||
| 		'ja-JP': '指定した投稿への返信/引用を取得します。', | ||||
| 		'en-US': 'Get replies/quotes of a note.' | ||||
| 	}, | ||||
|  | ||||
| 	tags: ['notes'], | ||||
|  | ||||
| 	requireCredential: false, | ||||
|  | ||||
| 	params: { | ||||
| 		noteId: { | ||||
| 			validator: $.type(ID), | ||||
| 			transform: transform, | ||||
| 			desc: { | ||||
| 				'ja-JP': '対象の投稿のID', | ||||
| 				'en-US': 'Target note ID' | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		limit: { | ||||
| 			validator: $.optional.num.range(1, 100), | ||||
| 			default: 10 | ||||
| 		}, | ||||
|  | ||||
| 		sinceId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			transform: transform, | ||||
| 		}, | ||||
|  | ||||
| 		untilId: { | ||||
| 			validator: $.optional.type(ID), | ||||
| 			transform: transform, | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	res: { | ||||
| 		type: 'array', | ||||
| 		items: { | ||||
| 			type: 'Note', | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const [followings, hideUserIds] = await Promise.all([ | ||||
| 		// フォローを取得 | ||||
| 		// Fetch following | ||||
| 		user ? getFriends(user._id) : [], | ||||
|  | ||||
| 		// 隠すユーザーを取得 | ||||
| 		getHideUserIds(user) | ||||
| 	]); | ||||
|  | ||||
| 	const visibleQuery = user == null ? [{ | ||||
| 		visibility: { $in: [ 'public', 'home' ] } | ||||
| 	}] : [{ | ||||
| 		visibility: { $in: [ 'public', 'home' ] } | ||||
| 	}, { | ||||
| 		// myself (for followers/specified/private) | ||||
| 		userId: user._id | ||||
| 	}, { | ||||
| 		// to me (for specified) | ||||
| 		visibleUserIds: { $in: [ user._id ] } | ||||
| 	}, { | ||||
| 		visibility: 'followers', | ||||
| 		$or: [{ | ||||
| 			// フォロワーの投稿 | ||||
| 			userId: { $in: followings.map(f => f.id) }, | ||||
| 		}, { | ||||
| 			// 自分の投稿へのリプライ | ||||
| 			'_reply.userId': user._id, | ||||
| 		}, { | ||||
| 			// 自分へのメンションが含まれている | ||||
| 			mentions: { $in: [ user._id ] } | ||||
| 		}] | ||||
| 	}]; | ||||
|  | ||||
| 	const q = { | ||||
| 		$and: [{ | ||||
| 			$or: [{ | ||||
| 				replyId: ps.noteId, | ||||
| 			}, { | ||||
| 				renoteId: ps.noteId, | ||||
| 				$or: [{ | ||||
| 					text: { $ne: null } | ||||
| 				}, { | ||||
| 					fileIds: { $ne: [] } | ||||
| 				}, { | ||||
| 					poll: { $ne: null } | ||||
| 				}] | ||||
| 			}] | ||||
| 		}, { | ||||
| 			$or: visibleQuery | ||||
| 		}] | ||||
| 	} as any; | ||||
|  | ||||
| 	if (hideUserIds && hideUserIds.length > 0) { | ||||
| 		q['userId'] = { | ||||
| 			$nin: hideUserIds | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	const sort = { | ||||
| 		_id: -1 | ||||
| 	}; | ||||
|  | ||||
| 	if (ps.sinceId) { | ||||
| 		sort._id = 1; | ||||
| 		q._id = { | ||||
| 			$gt: ps.sinceId | ||||
| 		}; | ||||
| 	} else if (ps.untilId) { | ||||
| 		q._id = { | ||||
| 			$lt: ps.untilId | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	const notes = await Note.find(q, { | ||||
| 		limit: ps.limit, | ||||
| 		sort: sort | ||||
| 	}); | ||||
|  | ||||
| 	return await packMany(notes, user); | ||||
| }); | ||||
| @@ -165,7 +165,10 @@ export const meta = { | ||||
| 				choices: $.arr($.str) | ||||
| 					.unique() | ||||
| 					.range(2, 10) | ||||
| 					.each(c => c.length > 0 && c.length < 50) | ||||
| 					.each(c => c.length > 0 && c.length < 50), | ||||
| 				multiple: $.optional.bool, | ||||
| 				expiresAt: $.optional.nullable.num.int(), | ||||
| 				expiredAfter: $.optional.nullable.num.int().min(1) | ||||
| 			}).strict(), | ||||
| 			desc: { | ||||
| 				'ja-JP': 'アンケート' | ||||
| @@ -214,6 +217,12 @@ export const meta = { | ||||
| 			code: 'CONTENT_REQUIRED', | ||||
| 			id: '6f57e42b-c348-439b-bc45-993995cc515a' | ||||
| 		}, | ||||
|  | ||||
| 		cannotCreateAlreadyExpiredPoll: { | ||||
| 			message: 'Poll is already expired.', | ||||
| 			code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', | ||||
| 			id: '04da457d-b083-4055-9082-955525eda5a5' | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| @@ -275,6 +284,13 @@ export default define(meta, async (ps, user, app) => { | ||||
| 			text: choice.trim(), | ||||
| 			votes: 0 | ||||
| 		})); | ||||
|  | ||||
| 		if (typeof ps.poll.expiresAt === 'number') { | ||||
| 			if (ps.poll.expiresAt < Date.now()) | ||||
| 				throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); | ||||
| 		} else if (typeof ps.poll.expiredAfter === 'number') { | ||||
| 			ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー | ||||
| @@ -291,7 +307,11 @@ export default define(meta, async (ps, user, app) => { | ||||
| 	const note = await create(user, { | ||||
| 		createdAt: new Date(), | ||||
| 		files: files, | ||||
| 		poll: ps.poll, | ||||
| 		poll: ps.poll ? { | ||||
| 			choices: ps.poll.choices, | ||||
| 			multiple: ps.poll.multiple || false, | ||||
| 			expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null | ||||
| 		} : undefined, | ||||
| 		text: ps.text, | ||||
| 		reply, | ||||
| 		renote, | ||||
|   | ||||
| @@ -7,10 +7,13 @@ import watch from '../../../../../services/note/watch'; | ||||
| import { publishNoteStream } from '../../../../../services/stream'; | ||||
| import notify from '../../../../../services/create-notification'; | ||||
| import define from '../../../define'; | ||||
| import createNote from '../../../../../services/note/create'; | ||||
| import User from '../../../../../models/user'; | ||||
| import User, { IRemoteUser } from '../../../../../models/user'; | ||||
| import { ApiError } from '../../../error'; | ||||
| import { getNote } from '../../../common/getters'; | ||||
| import { deliver } from '../../../../../queue'; | ||||
| import { renderActivity } from '../../../../../remote/activitypub/renderer'; | ||||
| import renderVote from '../../../../../remote/activitypub/renderer/vote'; | ||||
| import { deliverQuestionUpdate } from '../../../../../services/note/polls/update'; | ||||
|  | ||||
| export const meta = { | ||||
| 	desc: { | ||||
| @@ -63,10 +66,18 @@ export const meta = { | ||||
| 			code: 'ALREADY_VOTED', | ||||
| 			id: '0963fc77-efac-419b-9424-b391608dc6d8' | ||||
| 		}, | ||||
|  | ||||
| 		alreadyExpired: { | ||||
| 			message: 'The poll is already expired.', | ||||
| 			code: 'ALREADY_EXPIRED', | ||||
| 			id: '1022a357-b085-4054-9083-8f8de358337e' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	const createdAt = new Date(); | ||||
|  | ||||
| 	// Get votee | ||||
| 	const note = await getNote(ps.noteId).catch(e => { | ||||
| 		if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); | ||||
| @@ -77,23 +88,32 @@ export default define(meta, async (ps, user) => { | ||||
| 		throw new ApiError(meta.errors.noPoll); | ||||
| 	} | ||||
|  | ||||
| 	if (note.poll.expiresAt && note.poll.expiresAt < createdAt) { | ||||
| 		throw new ApiError(meta.errors.alreadyExpired); | ||||
| 	} | ||||
|  | ||||
| 	if (!note.poll.choices.some(x => x.id == ps.choice)) { | ||||
| 		throw new ApiError(meta.errors.invalidChoice); | ||||
| 	} | ||||
|  | ||||
| 	// if already voted | ||||
| 	const exist = await Vote.findOne({ | ||||
| 	const exist = await Vote.find({ | ||||
| 		noteId: note._id, | ||||
| 		userId: user._id | ||||
| 	}); | ||||
|  | ||||
| 	if (exist !== null) { | ||||
| 		throw new ApiError(meta.errors.alreadyVoted); | ||||
| 	if (exist.length) { | ||||
| 		if (note.poll.multiple) { | ||||
| 			if (exist.some(x => x.choice == ps.choice)) | ||||
| 				throw new ApiError(meta.errors.alreadyVoted); | ||||
| 		} else { | ||||
| 			throw new ApiError(meta.errors.alreadyVoted); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Create vote | ||||
| 	await Vote.insert({ | ||||
| 		createdAt: new Date(), | ||||
| 	const vote = await Vote.insert({ | ||||
| 		createdAt, | ||||
| 		noteId: note._id, | ||||
| 		userId: user._id, | ||||
| 		choice: ps.choice | ||||
| @@ -146,18 +166,15 @@ export default define(meta, async (ps, user) => { | ||||
|  | ||||
| 	// リモート投票の場合リプライ送信 | ||||
| 	if (note._user.host != null) { | ||||
| 		const pollOwner = await User.findOne({ | ||||
| 		const pollOwner: IRemoteUser = await User.findOne({ | ||||
| 			_id: note.userId | ||||
| 		}); | ||||
|  | ||||
| 		createNote(user, { | ||||
| 			createdAt: new Date(), | ||||
| 			text: ps.choice.toString(), | ||||
| 			reply: note, | ||||
| 			visibility: 'specified', | ||||
| 			visibleUsers: [ pollOwner ], | ||||
| 		}); | ||||
| 		deliver(user, renderActivity(await renderVote(user, vote, note, pollOwner)), pollOwner.inbox); | ||||
| 	} | ||||
|  | ||||
| 	// リモートフォロワーにUpdate配信 | ||||
| 	deliverQuestionUpdate(note._id); | ||||
|  | ||||
| 	return; | ||||
| }); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import hybridTimeline from './hybrid-timeline'; | ||||
| import globalTimeline from './global-timeline'; | ||||
| import notesStats from './notes-stats'; | ||||
| import serverStats from './server-stats'; | ||||
| import queueStats from './queue-stats'; | ||||
| import userList from './user-list'; | ||||
| import messaging from './messaging'; | ||||
| import messagingIndex from './messaging-index'; | ||||
| @@ -23,6 +24,7 @@ export default { | ||||
| 	globalTimeline, | ||||
| 	notesStats, | ||||
| 	serverStats, | ||||
| 	queueStats, | ||||
| 	userList, | ||||
| 	messaging, | ||||
| 	messagingIndex, | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user