Compare commits

..

39 Commits

Author SHA1 Message Date
syuilo 43a8c3584f wip 2025-05-14 17:18:39 +09:00
syuilo e72ce6085a fix(frontend): tweak use-pagination behaviour 2025-05-14 09:17:40 +09:00
syuilo 59fc18f2cd enhance(frontend): add performance tip 2025-05-14 08:26:30 +09:00
syuilo 26506677c2 enhance: いくつかの設定ファイルの項目をコントロールパネルで設定するように (#16026)
* wip

* Update CHANGELOG.md

* feat: migrate to existing config value (#16030)

* wip

* Update CHANGELOG.md

---------

Co-authored-by: anatawa12 <anatawa12@icloud.com>
2025-05-12 16:55:01 +09:00
syuilo 51b5d740f6 🎨
Fix #16018
2025-05-12 12:17:14 +09:00
github-actions[bot] fe7b2298f6 Bump version to 2025.5.1-alpha.2 2025-05-12 01:02:03 +00:00
taichan 5bc52b6743 feat(frontend): 絵文字をミュート可能にする機能 (#15966)
* wip ( 絵文字ミュートの基礎実装, PoC )

* refactor: 絵文字のmute/unmute処理の共通化

* SPDX

* リアクションからも絵文字ミュート可能に

* emojiMute/emojiUnmute

* replace resource of emojiMute

* add vitest preferstate for mutedEmojis

* add vitest to preferReactive

* 混入削除

* Fix typo (mutedEmojis -> mutingEmojis)

* reactiveやめる

* add時の判定ミスを修正

* Add CHANGELOG

* Revert "reactiveやめる"

This reverts commit 442742c371.

* Update Changelog
2025-05-12 10:00:06 +09:00
syuilo b18d6b4cef lint 2025-05-11 17:17:07 +09:00
syuilo a78f654322 🎨 2025-05-11 16:28:04 +09:00
syuilo 3d60d18e80 tweak MkMediaImage.vue 2025-05-11 16:25:49 +09:00
かっこかり c793038a8b fix(frontend): ノート購読の挙動改善 (#16023)
* fix(frontend): ノート購読の挙動改善

* fix

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-05-11 15:53:02 +09:00
かっこかり 3df421da1a refactor(frontend): MkMarquee のコードの可読性の向上 (#16017)
* fix(frontend/MkMarquee): Composition API に移行

* move animation to scoped environment

* refactor

---------

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
2025-05-11 15:43:16 +09:00
syuilo 2ead6b8adc 🎨 2025-05-11 15:40:58 +09:00
syuilo 5465dccda9 Update CHANGELOG.md 2025-05-11 15:39:39 +09:00
anatawa12 cbc53de823 fix: RateLimiterService (#13997)
* fix rate limit check never ends

* fix: long term / short term limitがないときでもそれぞれ用のnew Limiterとlimiter.getが呼ばれる問題

* refactor: wrap ratelimiter with promise

* refactor: reimplement max/min with async

* refactor: reimplement limit with async

* refactor: do not check long term limit inside min

* refactor: check if there is rate limit inside min/max function

* refactor: remove unnecessary return in min/max function

* refactor: remove unnecessary max/min function

* refactor: return rate limit instead of throwing an object

* fix: レートリミットのfactorが二回適用されて二乗の効果がある問題を修正

* fix lint error

---------

Co-authored-by: Kisaragi <48310258+KisaragiEffective@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
2025-05-11 15:37:46 +09:00
syuilo 2c96f7f258 enhance(backend): increase MAX_ROOM_MEMBERS to 50 2025-05-11 15:32:36 +09:00
syuilo 8793176be1 Update about-misskey.vue 2025-05-11 14:26:03 +09:00
syuilo 3100caadda 🎨 2025-05-11 14:23:58 +09:00
syuilo 4d08c0a82b enhance(frontend): blurhash無効化オプションを追加 2025-05-11 14:17:08 +09:00
syuilo 039aacb31f refactor(frontend): better type def of usePagination 2025-05-10 14:42:18 +09:00
github-actions[bot] 92aca4498b Bump version to 2025.5.1-alpha.1 2025-05-10 03:58:55 +00:00
かっこかり 0133dbfe7e fix(frontend): サウンドのフォールバックが効かなくなっていた問題を修正 (#16012) 2025-05-10 12:34:59 +09:00
syuilo 0a0d42bb48 enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように 2025-05-10 11:25:00 +09:00
かっこかり e1cd7c94fb refactor(frontend): use* 関数の格納場所のフォルダ名を composables に変更 (#16004)
* refactor(frontend): use* 関数の格納場所を正式名称(composables)に変更

* migrate

* move useLoading
2025-05-10 07:58:26 +09:00
github-actions[bot] c803f842ba Bump version to 2025.5.1-alpha.0 2025-05-09 12:18:54 +00:00
syuilo b3f8ce60c6 New Crowdin updates (#16003)
* New translations ja-jp.yml (Romanian)

* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Arabic)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Czech)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Dutch)

* New translations ja-jp.yml (Norwegian)

* New translations ja-jp.yml (Polish)

* New translations ja-jp.yml (Portuguese)

* New translations ja-jp.yml (Russian)

* New translations ja-jp.yml (Slovak)

* New translations ja-jp.yml (Swedish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Ukrainian)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Vietnamese)

* New translations ja-jp.yml (Indonesian)

* New translations ja-jp.yml (Bengali)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Uzbek)

* New translations ja-jp.yml (Japanese, Kansai)

* New translations ja-jp.yml (Korean (Gyeongsang))
2025-05-09 21:16:40 +09:00
かっこかり 04928ba7d1 enhance(frontend): Instance Tickerのデザイン改善 (#15946)
* feat(tms): インスタンス情報の表示位置 (taiyme#198)

* migrate

* fix(InstanceTicker): リモートサーバーのアイコンが初期画像になる問題 (taiyme#211)

* refactor(InstanceTicker): 参照の構造を変更

Co-Authored-By: taiy <53635909+taiyme@users.noreply.github.com>

* perf(TmsInstanceTicker): color関数に変更 (taiyme#283)

* 🎨

* Update Changelog

* remove unused property

* revert unrelated changes

* fix

* 実装を簡略化

* 🎨

* Update CHANGELOG.md

* 色の計算をコンポーネント内に移動

---------

Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
2025-05-09 21:16:21 +09:00
syuilo 1b3a8cd97d fix(frontend): "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない
Fix #15994
2025-05-09 20:41:50 +09:00
syuilo bbe26607eb enhance: リプライ元にアンケートがあることが表示されるように
Resolve #15998
2025-05-09 20:36:14 +09:00
syuilo 6a2dc2d2d2 follow up of 8c2ab25e5f 2025-05-09 20:27:11 +09:00
syuilo 1febae7128 fix(backend): チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正 2025-05-09 20:12:33 +09:00
syuilo 2c63ab6fe3 New Crowdin updates (#15969)
* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Romanian)

* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Arabic)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Czech)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Dutch)

* New translations ja-jp.yml (Polish)

* New translations ja-jp.yml (Portuguese)

* New translations ja-jp.yml (Russian)

* New translations ja-jp.yml (Slovak)

* New translations ja-jp.yml (Ukrainian)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Vietnamese)

* New translations ja-jp.yml (Indonesian)

* New translations ja-jp.yml (Bengali)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Japanese, Kansai)
2025-05-09 19:42:04 +09:00
syuilo 96bc042d67 Feat: サーバー初期設定ウィザード (#15954)
* wip

* wip

* Update welcome.setup.vue

* wip

* wip

* wip

* wip

* Update MkServerSetupWizard.vue

* Update MkServerSetupWizard.vue

* wip

* wip

* wip

* Update types.ts

* wip

* wip

* Update CHANGELOG.md
2025-05-09 19:41:54 +09:00
anatawa12 0bd44bca6d fix: exported antenna data cannot be imported in some cases (#15985)
* chore: add ExportedAntenna type

* chore: use ExportedAntenna on export and fix schema

* fix: excludeNotesInSensitiveChannel is not included

* chore: revert unnecessary changes

* chore: add doc for future developer

* docs: update changelog
2025-05-09 17:48:01 +09:00
syuilo ebf291084f Feat: UGCの公開範囲設定機能 (#15938)
* wip

* Update CHANGELOG.md

* wip

* wip

* Update show.ts
2025-05-09 17:44:35 +09:00
syuilo 8c2ab25e5f Feat: No websocket mode (#15851)
* wip

* wip

* wip

* wip

* Update MkTimeline.vue

* wip

* wip

* wip

* Update MkTimeline.vue

* Update use-pagination.ts

* wip

* wip

* Update MkTimeline.vue

* Update MkTimeline.vue

* wip

* wip

* Update MkTimeline.vue

* Update MkTimeline.vue

* Update MkTimeline.vue

* wip

* Update use-pagination.ts

* wip

* Update use-pagination.ts

* Update MkNotifications.vue

* Update MkNotifications.vue

* wip

* wip

* wip

* Update use-note-capture.ts

* Update use-note-capture.ts

* Update use-note-capture.ts

* wip

* wip

* wip

* wip

* Update MkNoteDetailed.vue

* wip

* wip

* Update MkTimeline.vue

* wip

* fix

* Update MkTimeline.vue

* wip

* test

* Revert "test"

This reverts commit 3375619396.

* Update use-pagination.ts

* test

* Revert "test"

This reverts commit 42c53c830e.

* test

* Revert "test"

This reverts commit c4f8cda4aa.

* Update style.scss

* Update MkTimeline.vue

* Update MkTimeline.vue

* Update MkTimeline.vue

* ✌️

* Update MkTimeline.vue

* wip

* wip

* test

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkTimeline.vue

* wip

* tweak navbar

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update home.vue

* wip

* refactor

* wip

* wip

* Update note.vue

* Update navbar.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* wip

* Update MkStreamingNotificationsTimeline.vue

* Update use-pagination.ts

* wip

* improve perf

* wip

* Update MkNotesTimeline.vue

* wip

* megre

* Update use-pagination.ts

* Update use-pagination.ts

* Update MkStreamingNotesTimeline.vue

* Update use-pagination.ts

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-05-09 17:40:08 +09:00
syuilo 5603cb7e22 refactor 2025-05-07 16:10:43 +09:00
syuilo 86996d9563 🎨 2025-05-07 13:41:51 +09:00
github-actions[bot] 978289535b [skip ci] Update CHANGELOG.md (prepend template) 2025-05-07 02:46:44 +00:00
236 changed files with 4810 additions and 2843 deletions
-14
View File
@@ -215,20 +215,6 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
allowedPrivateNetworks: [
'127.0.0.1/32'
]
# Disable automatic redirect for ActivityPub object lookup. (default: false)
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
#disallowExternalApRedirect: true
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
-14
View File
@@ -227,12 +227,6 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
@@ -240,14 +234,6 @@ signToActivityPubGet: true
# '127.0.0.1/32'
#]
# Disable automatic redirect for ActivityPub object lookup. (default: false)
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
#disallowExternalApRedirect: true
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# Log settings
# logging:
# sql:
-15
View File
@@ -319,19 +319,12 @@ proxyBypassHosts:
# * Perform image compression (on a different server resource than the main process)
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
proxyRemoteFiles: true
# Movie Thumbnail Generation URL
# There is no reference implementation.
# For example, Misskey will point to the following URL:
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
@@ -339,14 +332,6 @@ signToActivityPubGet: true
# '127.0.0.1/32'
#]
# Disable automatic redirect for ActivityPub object lookup. (default: false)
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
#disallowExternalApRedirect: true
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# PID File of master process
#pidFile: /tmp/misskey.pid
-9
View File
@@ -202,15 +202,6 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
proxyRemoteFiles: true
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
allowedPrivateNetworks: [
'127.0.0.1/32'
]
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
+45
View File
@@ -1,3 +1,48 @@
## 2025.5.1
### Note
- 設定ファイルの以下の項目がコントロールパネルから設定するようになりました
- signToActivityPubGet
- proxyRemoteFiles
- disallowExternalApRedirect
- 許可しないかどうかではなく、許可するかどうかの設定(allowExternalApRedirect)になりました
- 設定ファイルの maxFileSize が削除されました
- リクエストの最大ペイロードサイズは、nginxやCDNなどのMisskeyの前段で制限をするか、ロール設定で制限してください。
### General
- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました
- モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます
- 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
### Client
- Feat: サーバー初期設定ウィザードが実装されました
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
- サーバーのパフォーマンス向上に寄与することが期待されます
- 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です
- 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました
- チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます
- Feat: 絵文字をミュート可能にする機能
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
- Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように
- Enhance: リプライ元にアンケートがあることが表示されるように
- Enhance: ノートのサーバー情報のデザインを改善・パフォーマンス向上
(Based on https://github.com/taiyme/misskey/pull/198, https://github.com/taiyme/misskey/pull/211, https://github.com/taiyme/misskey/pull/283)
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
### Server
- Enhance: チャットルームの最大メンバー数を30人から50人に調整
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
- Enhance: レートリミットの計算方法を調整 (#13997)
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
- Fix: ユーザ除外アンテナをインポートできない問題を修正
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
## 2025.5.0
### Note
-6
View File
@@ -221,12 +221,6 @@ id: "aidx"
# Media Proxy
#mediaProxy: https://example.com/proxy
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
-5
View File
@@ -2,11 +2,6 @@ import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:61812',
},
})
+9
View File
@@ -31,6 +31,15 @@ describe('Before setup instance', () => {
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
-22
View File
@@ -1,22 +0,0 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
-2
View File
@@ -215,7 +215,6 @@ noUsers: "ليس هناك مستخدمون"
editProfile: "تعديل الملف التعريفي"
noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟"
pinLimitExceeded: "لا يمكنك تثبيت الملاحظات بعد الآن."
intro: "لقد انتهت عملية تنصيب Misskey. الرجاء إنشاء حساب إداري."
done: "تمّ"
processing: "المعالجة جارية"
preview: "معاينة"
@@ -676,7 +675,6 @@ experimental: "اختباري"
developer: "المطور"
makeExplorable: "أظهر الحساب في صفحة \"استكشاف\""
makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\""
showGapBetweenNotesInTimeline: "أظهر فجوات بين المشاركات في الخيط الزمني"
left: "يسار"
center: "وسط"
wide: "عريض"
-2
View File
@@ -215,7 +215,6 @@ noUsers: "কোন ব্যাবহারকারী নেই"
editProfile: "প্রোফাইল সম্পাদনা করুন"
noteDeleteConfirm: "আপনি কি নোট ডিলিট করার ব্যাপারে নিশ্চিত?"
pinLimitExceeded: "আপনি আর কোন নোট পিন করতে পারবেন না"
intro: "Misskey এর ইন্সটলেশন সম্পন্ন হয়েছে!দয়া করে অ্যাডমিন ইউজার তৈরি করুন।"
done: "সম্পন্ন"
processing: "প্রক্রিয়াধীন..."
preview: "পূর্বরূপ দেখুন"
@@ -673,7 +672,6 @@ experimentalFeatures: "পরীক্ষামূলক বৈশিষ্ট
developer: "ডেভেলপার"
makeExplorable: "অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় দেখান"
makeExplorableDescription: "আপনি এটি বন্ধ করলে, আপনার অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় প্রদর্শিত হবে না।"
showGapBetweenNotesInTimeline: "টাইমলাইন এবং নোটের মাঝে ফাকা জায়গা রাখুন"
duplicate: "প্রতিরূপ"
left: "বাম"
center: "মাঝখান"
+1 -3
View File
@@ -251,7 +251,6 @@ noUsers: "No hi ha usuaris"
editProfile: "Edita el perfil"
noteDeleteConfirm: "Segur que voleu eliminar aquesta publicació?"
pinLimitExceeded: "No podeu fixar més publicacions"
intro: "La instal·lació de Misskey ha acabat! Crea un usuari d'administrador."
done: "Fet"
processing: "S'està processant..."
preview: "Vista prèvia"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Aquesta és una característica experimental. La sev
developer: "Programador"
makeExplorable: "Fes que el compte sigui visible a la secció \"Explorar\""
makeExplorableDescription: "Si desactives aquesta opció, el teu compte no sortirà a la secció \"Explorar\""
showGapBetweenNotesInTimeline: "Notes separades a la línia de temps"
duplicate: "Duplicat"
left: "Esquerra"
center: "Centre"
@@ -1238,7 +1236,6 @@ showAvatarDecorations: "Mostrar les decoracions dels avatars"
releaseToRefresh: "Deixar anar per actualitzar"
refreshing: "Recarregant..."
pullDownToRefresh: "Llisca cap a baix per recarregar"
disableStreamingTimeline: "Desactivar l'actualització en temps real de les línies de temps"
useGroupedNotifications: "Mostrar les notificacions agrupades "
signupPendingError: "Hi ha hagut un problema verificant l'adreça de correu electrònic. L'enllaç pot haver caducat."
cwNotationRequired: "Si està activat \"Amagar contingut\" s'ha d'escriure una descripció "
@@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Nom del perfil"
profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu."
profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc"
manageProfiles: "Gestionar perfils"
_preferencesBackup:
autoBackup: "Còpia de seguretat automàtica "
restoreFromBackup: "Restaurar des d'una còpia de seguretat"
-2
View File
@@ -228,7 +228,6 @@ noUsers: "Žádní uživatelé"
editProfile: "Upravit můj profil"
noteDeleteConfirm: "Jste si jistí že chcete smazat tuhle poznámku?"
pinLimitExceeded: "Nemůžete připnout další poznámky."
intro: "Instalace Misskey byla dokončena! Prosím vytvořte admina."
done: "Hotovo"
processing: "Zpracovávám"
preview: "Náhled"
@@ -726,7 +725,6 @@ thisIsExperimentalFeature: "Tohle je experimentální funkce. Její funkce se m
developer: "Vývojář"
makeExplorable: "Udělat účet viditelný v \"Objevit\""
makeExplorableDescription: "Pokud tohle vypnete, tak se účet přestane zobrazovat v sekci \"Objevit\"."
showGapBetweenNotesInTimeline: "Zobrazit mezeru mezi příspěvkama na časové ose"
duplicate: "Duplikovat"
left: "Vlevo"
center: "Uprostřed"
+1 -3
View File
@@ -251,7 +251,6 @@ noUsers: "Keine Benutzer gefunden"
editProfile: "Profil bearbeiten"
noteDeleteConfirm: "Möchtest du diese Notiz wirklich löschen?"
pinLimitExceeded: "Du kannst nicht noch mehr Notizen anheften."
intro: "Misskey ist installiert! Lass uns nun ein Administratorkonto einrichten."
done: "Fertig"
processing: "In Bearbeitung …"
preview: "Vorschau"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Dies ist eine experimentelle Funktion. Änderungen a
developer: "Entwickler"
makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen"
makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Benutzerkonto nicht im „Erkunden“-Bereich sichtbar."
showGapBetweenNotesInTimeline: "Abstände zwischen Notizen auf der Chronik anzeigen"
duplicate: "Duplizieren"
left: "Links"
center: "Mittig"
@@ -1238,7 +1236,6 @@ showAvatarDecorations: "Profilbilddekoration anzeigen"
releaseToRefresh: "Zum Aktualisieren loslassen"
refreshing: "Wird aktualisiert..."
pullDownToRefresh: "Zum Aktualisieren ziehen"
disableStreamingTimeline: "Echtzeitaktualisierung der Chronik deaktivieren"
useGroupedNotifications: "Benachrichtigungen gruppieren"
signupPendingError: "Beim Überprüfen der Mailadresse ist etwas schiefgelaufen. Der Link könnte abgelaufen sein."
cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden."
@@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Profilname"
profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert."
profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\""
manageProfiles: "Profile verwalten"
_preferencesBackup:
autoBackup: "Automatische Sicherung"
restoreFromBackup: "Wiederherstellen aus der Sicherung"
+1 -3
View File
@@ -251,7 +251,6 @@ noUsers: "There are no users"
editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this note?"
pinLimitExceeded: "You cannot pin any more notes"
intro: "Installation of Misskey has been finished! Please create an admin user."
done: "Done"
processing: "Processing..."
preview: "Preview"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "This is an experimental feature. Its functionality i
developer: "Developer"
makeExplorable: "Make account visible in \"Explore\""
makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section."
showGapBetweenNotesInTimeline: "Show a gap between posts on the timeline"
duplicate: "Duplicate"
left: "Left"
center: "Center"
@@ -1238,7 +1236,6 @@ showAvatarDecorations: "Show avatar decorations"
releaseToRefresh: "Release to refresh"
refreshing: "Refreshing..."
pullDownToRefresh: "Pull down to refresh"
disableStreamingTimeline: "Disable real-time timeline updates"
useGroupedNotifications: "Display grouped notifications"
signupPendingError: "There was a problem verifying the email address. The link may have expired."
cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided."
@@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Profile name"
profileNameDescription: "Set a name that identifies this device."
profileNameDescription2: "Example: \"Main PC\", \"Smartphone\""
manageProfiles: "Manage Profiles"
_preferencesBackup:
autoBackup: "Auto backup"
restoreFromBackup: "Restore from backup"
-3
View File
@@ -250,7 +250,6 @@ noUsers: "No hay usuarios"
editProfile: "Editar perfil"
noteDeleteConfirm: "¿Desea borrar esta nota?"
pinLimitExceeded: "Ya no se pueden fijar más posts"
intro: "¡La instalación de Misskey ha terminado! Crea el usuario administrador."
done: "Terminado"
processing: "Procesando"
preview: "Vista previa"
@@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Se trata de una función experimental. Las especific
developer: "Desarrolladores"
makeExplorable: "Hacer visible la cuenta en \"Explorar\""
makeExplorableDescription: "Si desactiva esta opción, su cuenta no aparecerá en la sección \"Explorar\"."
showGapBetweenNotesInTimeline: "Mostrar un intervalo entre notas en la línea de tiempo"
duplicate: "Duplicar"
left: "Izquierda"
center: "Centrar"
@@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostrar decoraciones de avatar"
releaseToRefresh: "Soltar para recargar"
refreshing: "Recargando..."
pullDownToRefresh: "Tira hacia abajo para recargar"
disableStreamingTimeline: "Desactivar actualizaciones en tiempo real de la línea de tiempo"
useGroupedNotifications: "Mostrar notificaciones agrupadas"
signupPendingError: "Ha habido un problema al verificar tu dirección de correo electrónico. Es posible que el enlace haya caducado."
cwNotationRequired: "Si se ha activado \"ocultar contenido\", es necesario proporcionar una descripción."
-3
View File
@@ -238,7 +238,6 @@ noUsers: "Il ny a pas dutilisateur·rice·s"
editProfile: "Modifier votre profil"
noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette note ?"
pinLimitExceeded: "Vous ne pouvez plus épingler dautres notes."
intro: "Linstallation de Misskey est terminée ! Veuillez créer un compte administrateur."
done: "Terminé"
processing: "Traitement en cours"
preview: "Aperçu"
@@ -760,7 +759,6 @@ thisIsExperimentalFeature: "Ceci est une fonctionnalité expérimentale. Il y a
developer: "Développeur"
makeExplorable: "Rendre le compte visible sur la page \"Découvrir\"."
makeExplorableDescription: "Si vous désactivez cette option, votre compte n'apparaîtra pas sur la page \"Découvrir\"."
showGapBetweenNotesInTimeline: "Afficher un écart entre les notes sur la Timeline"
duplicate: "Duliquer"
left: "Gauche"
center: "Centrer"
@@ -1209,7 +1207,6 @@ showAvatarDecorations: "Afficher les décorations d'avatar"
releaseToRefresh: "Relâcher pour rafraîchir"
refreshing: "Rafraîchissement..."
pullDownToRefresh: "Tirer vers le bas pour rafraîchir"
disableStreamingTimeline: "Désactiver les mises à jour en temps réel de la ligne du temps"
useGroupedNotifications: "Grouper les notifications"
signupPendingError: "Un problème est survenu lors de la vérification de votre adresse e-mail. Le lien a peut-être expiré."
cwNotationRequired: "Si « Masquer le contenu » est activé, une description doit être fournie."
-3
View File
@@ -241,7 +241,6 @@ noUsers: "Tidak ada pengguna"
editProfile: "Sunting profil"
noteDeleteConfirm: "Apakah kamu yakin ingin menghapus catatan ini?"
pinLimitExceeded: "Kamu tidak dapat menyematkan catatan lagi"
intro: "Instalasi Misskey telah selesai! Mohon untuk membuat pengguna admin."
done: "Selesai"
processing: "Memproses"
preview: "Pratinjau"
@@ -761,7 +760,6 @@ thisIsExperimentalFeature: "Fitur ini eksperimental. Fungsionalitas dari fitur i
developer: "Pengembang"
makeExplorable: "Buat akun tampil di \"Jelajahi\""
makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di menu \"Jelajahi\""
showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada lini masa"
duplicate: "Duplikat"
left: "Kiri"
center: "Tengah"
@@ -1206,7 +1204,6 @@ showAvatarDecorations: "Tampilkan dekorasi avatar"
releaseToRefresh: "Lepaskan untuk memuat ulang"
refreshing: "Sedang memuat ulang..."
pullDownToRefresh: "Tarik ke bawah untuk memuat ulang"
disableStreamingTimeline: "Nonaktifkan pembaharuan lini masa real-time"
useGroupedNotifications: "Tampilkan notifikasi secara dikelompokkan"
signupPendingError: "Terdapat masalah ketika memverifikasi alamat surel. Tautan kemungkinan telah kedaluwarsa."
cwNotationRequired: "Jika \"Sembunyikan konten\" diaktifkan, deskripsi harus disediakan."
+321 -13
View File
@@ -707,7 +707,7 @@ export interface Locale extends ILocale {
*/
"cacheRemoteFiles": string;
/**
* default.ymlでproxyRemoteFilesをtrueにすることをお勧めします
*
*/
"cacheRemoteFilesDescription": string;
/**
@@ -1022,10 +1022,6 @@ export interface Locale extends ILocale {
*
*/
"pinLimitExceeded": string;
/**
* Misskeyのインストールが完了しました
*/
"intro": string;
/**
*
*/
@@ -2322,6 +2318,10 @@ export interface Locale extends ILocale {
*
*/
"newNoteRecived": string;
/**
*
*/
"newNote": string;
/**
*
*/
@@ -3158,10 +3158,6 @@ export interface Locale extends ILocale {
*
*/
"makeExplorableDescription": string;
/**
*
*/
"showGapBetweenNotesInTimeline": string;
/**
*
*/
@@ -3190,6 +3186,10 @@ export interface Locale extends ILocale {
*
*/
"needReloadToApply": string;
/**
*
*/
"needToRestartServerToApply": string;
/**
*
*/
@@ -4970,10 +4970,6 @@ export interface Locale extends ILocale {
*
*/
"pullDownToRefresh": string;
/**
*
*/
"disableStreamingTimeline": string;
/**
*
*/
@@ -5417,6 +5413,38 @@ export interface Locale extends ILocale {
*
*/
"scrollToClose": string;
/**
*
*/
"advice": string;
/**
*
*/
"realtimeMode": string;
/**
*
*/
"turnItOn": string;
/**
*
*/
"turnItOff": string;
/**
*
*/
"emojiMute": string;
/**
*
*/
"emojiUnmute": string;
/**
* {x}
*/
"muteX": ParameterizedString<"x">;
/**
* {x}
*/
"unmuteX": ParameterizedString<"x">;
"_chat": {
/**
*
@@ -5547,6 +5575,14 @@ export interface Locale extends ILocale {
* 使
*/
"cannotChatWithTheUser_description": string;
/**
*
*/
"youAreNotAMemberOfThisRoomButInvited": string;
/**
*
*/
"doYouAcceptInvitation": string;
/**
*
*/
@@ -5697,6 +5733,14 @@ export interface Locale extends ILocale {
*
*/
"useStickyIcons": string;
/**
*
*/
"enableHighQualityImagePlaceholders": string;
/**
* UIのアニメーション
*/
"uiAnimations": string;
/**
*
*/
@@ -5721,6 +5765,22 @@ export interface Locale extends ILocale {
*
*/
"enablePullToRefresh_description": string;
/**
*
*/
"realtimeMode_description": string;
/**
*
*/
"contentsUpdateFrequency": string;
/**
*
*/
"contentsUpdateFrequency_description": string;
/**
*
*/
"contentsUpdateFrequency_description2": string;
"_chat": {
/**
*
@@ -6388,6 +6448,64 @@ export interface Locale extends ILocale {
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
*/
"deliverSuspendedSoftwareDescription": string;
/**
*
*/
"singleUserMode": string;
/**
*
*/
"singleUserMode_description": string;
/**
* GETリクエストに署名する
*/
"signToActivityPubGet": string;
/**
*
*/
"signToActivityPubGet_description": string;
/**
*
*/
"proxyRemoteFiles": string;
/**
*
*/
"proxyRemoteFiles_description": string;
/**
* ActivityPub経由の照会にリダイレクトを許可する
*/
"allowExternalApRedirect": string;
/**
*
*/
"allowExternalApRedirect_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description2": string;
"_userGeneratedContentsVisibilityForVisitor": {
/**
*
*/
"all": string;
/**
*
*/
"localOnly": string;
/**
*
*/
"none": string;
};
};
"_accountMigration": {
/**
@@ -11620,6 +11738,196 @@ export interface Locale extends ILocale {
*/
"serverHostPlaceholder": string;
};
"_serverSetupWizard": {
/**
* Misskeyのインストールが完了しました
*/
"installCompleted": string;
/**
*
*/
"firstCreateAccount": string;
/**
*
*/
"accountCreated": string;
/**
*
*/
"serverSetting": string;
/**
*
*/
"youCanEasilyConfigureOptimalServerSettingsWithThisWizard": string;
/**
*
*/
"settingsYouMakeHereCanBeChangedLater": string;
/**
* Misskeyをどのように使いますか
*/
"howWillYouUseMisskey": string;
"_use": {
/**
*
*/
"single": string;
/**
* 使
*/
"single_description": string;
/**
*
*/
"single_youCanCreateMultipleAccounts": string;
/**
*
*/
"group": string;
/**
* 使
*/
"group_description": string;
/**
*
*/
"open": string;
/**
*
*/
"open_description": string;
};
/**
*
*/
"openServerAdvice": string;
/**
* reCAPTCHAといったアンチボット機能を有効にするなど
*/
"openServerAntiSpamAdvice": string;
/**
*
*/
"howManyUsersDoYouExpect": string;
"_scale": {
/**
* 100 ()
*/
"small": string;
/**
* 1001000 ()
*/
"medium": string;
/**
* 1000 ()
*/
"large": string;
};
/**
*
*/
"largeScaleServerAdvice": string;
/**
* Fediverseと接続しますか
*/
"doYouConnectToFediverse": string;
/**
* (Fediverse)
*/
"doYouConnectToFediverse_description1": string;
/**
* Fediverseと接続することは
*/
"doYouConnectToFediverse_description2": string;
/**
*
*/
"youCanConfigureMoreFederationSettingsLater": string;
/**
*
*/
"adminInfo": string;
/**
* 使
*/
"adminInfo_description": string;
/**
*
*/
"adminInfo_mustBeFilled": string;
/**
*
*/
"followingSettingsAreRecommended": string;
/**
*
*/
"applyTheseSettings": string;
/**
*
*/
"skipSettings": string;
/**
*
*/
"settingsCompleted": string;
/**
* 使
*/
"settingsCompleted_description": string;
/**
*
*/
"settingsCompleted_description2": string;
/**
*
*/
"donationRequest": string;
"_donationRequest": {
/**
* Misskeyは有志によって開発されている無料のソフトウェアです
*/
"text1": string;
/**
*
*/
"text2": string;
/**
*
*/
"text3": string;
};
};
"_clientPerformanceIssueTip": {
/**
*
*/
"title": string;
/**
*
*/
"makeSureDisabledAdBlocker": string;
/**
* OSの機能やブラウザの機能
*/
"makeSureDisabledAdBlocker_description": string;
/**
* CSSを無効にしてください
*/
"makeSureDisabledCustomCss": string;
/**
* CSSや
*/
"makeSureDisabledCustomCss_description": string;
/**
*
*/
"makeSureDisabledAddons": string;
/**
*
*/
"makeSureDisabledAddons_description": string;
};
}
declare const locales: {
[lang: string]: Locale;
-3
View File
@@ -250,7 +250,6 @@ noUsers: "Non ci sono profili"
editProfile: "Modifica profilo"
noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?"
pinLimitExceeded: "Non puoi fissare altre note "
intro: "L'installazione di Misskey è terminata! Si prega di creare il profilo amministratore."
done: "Fine"
processing: "In elaborazione"
preview: "Anteprima"
@@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Questa è una funzionalità sperimentale. Potrebbe e
developer: "Sviluppatore"
makeExplorable: "Profilo visibile pubblicamente nella pagina \"Esplora\""
makeExplorableDescription: "Disabilitando questa opzione, il tuo profilo non verrà elencato nella pagina \"Esplora\"."
showGapBetweenNotesInTimeline: "Mostrare un intervallo tra le note sulla timeline"
duplicate: "Duplica"
left: "Sinistra"
center: "Centro"
@@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostra decorazione della foto profilo"
releaseToRefresh: "Rilascia per aggiornare"
refreshing: "Aggiornamento..."
pullDownToRefresh: "Trascinare per aggiornare"
disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale"
useGroupedNotifications: "Mostra le notifiche raggruppate"
signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo."
cwNotationRequired: "Devi indicare perché il contenuto è indicato come esplicito."
+87 -4
View File
@@ -172,7 +172,7 @@ emojiUrl: "絵文字画像URL"
addEmoji: "絵文字を追加"
settingGuide: "おすすめ設定"
cacheRemoteFiles: "リモートのファイルをキャッシュする"
cacheRemoteFilesDescription: "この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持しますが、画像のサムネイル生成やユーザーのプライバシー保護のために、default.ymlでproxyRemoteFilesをtrueにすることをお勧めします。"
cacheRemoteFilesDescription: "この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持します。"
youCanCleanRemoteFilesCache: "ファイル管理の🗑️ボタンで全てのキャッシュを削除できます。"
cacheRemoteSensitiveFiles: "リモートのセンシティブなファイルをキャッシュする"
cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リモートのセンシティブなファイルはキャッシュせず直リンクするようになります。"
@@ -251,7 +251,6 @@ noUsers: "ユーザーはいません"
editProfile: "プロフィールを編集"
noteDeleteConfirm: "このノートを削除しますか?"
pinLimitExceeded: "これ以上ピン留めできません"
intro: "Misskeyのインストールが完了しました!管理者アカウントを作成しましょう。"
done: "完了"
processing: "処理中"
preview: "プレビュー"
@@ -576,6 +575,7 @@ showFixedPostForm: "タイムライン上部に投稿フォームを表示する
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする"
newNoteRecived: "新しいノートがあります"
newNote: "新しいノート"
sounds: "サウンド"
sound: "サウンド"
listen: "聴く"
@@ -785,7 +785,6 @@ thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更
developer: "開発者"
makeExplorable: "アカウントを見つけやすくする"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示"
duplicate: "複製"
left: "左"
center: "中央"
@@ -793,6 +792,7 @@ wide: "広い"
narrow: "狭い"
reloadToApplySetting: "設定はページリロード後に反映されます。"
needReloadToApply: "反映には再起動が必要です。"
needToRestartServerToApply: "反映にはサーバーの再起動が必要です。"
showTitlebar: "タイトルバーを表示する"
clearCache: "キャッシュをクリア"
onlineUsersCount: "{n}人がオンライン"
@@ -1238,7 +1238,6 @@ showAvatarDecorations: "アイコンのデコレーションを表示"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
useGroupedNotifications: "通知をグルーピング"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
@@ -1349,6 +1348,14 @@ goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nノートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる"
advice: "アドバイス"
realtimeMode: "リアルタイムモード"
turnItOn: "オンにする"
turnItOff: "オフにする"
emojiMute: "絵文字ミュート"
emojiUnmute: "絵文字ミュート解除"
muteX: "{x}をミュート"
unmuteX: "{x}のミュートを解除"
_chat:
noMessagesYet: "まだメッセージはありません"
@@ -1383,6 +1390,8 @@ _chat:
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
youAreNotAMemberOfThisRoomButInvited: "あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。"
doYouAcceptInvitation: "招待を承認しますか?"
chatWithThisUser: "チャットする"
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。"
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。"
@@ -1424,12 +1433,18 @@ _settings:
makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする"
makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。"
useStickyIcons: "アイコンをスクロールに追従させる"
enableHighQualityImagePlaceholders: "高品質な画像のプレースホルダを表示"
uiAnimations: "UIのアニメーション"
showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示"
ifOn: "オンのとき"
ifOff: "オフのとき"
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新"
enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。"
realtimeMode_description: "サーバーと接続を確立し、リアルタイムでコンテンツを更新します。通信量とバッテリーの消費が多くなる場合があります。"
contentsUpdateFrequency: "コンテンツの取得頻度"
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
_chat:
showSenderName: "送信者の名前を表示"
@@ -1623,6 +1638,22 @@ _serverSettings:
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
deliverSuspendedSoftware: "配信停止中のソフトウェア"
deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。"
singleUserMode: "お一人様モード"
singleUserMode_description: "このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。"
signToActivityPubGet: "GETリクエストに署名する"
signToActivityPubGet_description: "通常は有効にしてください。連合の通信に関する問題がある場合に、無効にすると改善することがありますが、逆にサーバーによっては通信が不可になることがあります。"
proxyRemoteFiles: "リモートファイルをプロキシする"
proxyRemoteFiles_description: "有効にすると、リモートのファイルをプロキシして提供します。画像のサムネイル生成やユーザーのプライバシー保護に役立ちます。"
allowExternalApRedirect: "ActivityPub経由の照会にリダイレクトを許可する"
allowExternalApRedirect_description: "有効にすると、他のサーバーがこのサーバーを通して第三者のコンテンツを照会することが可能になりますが、コンテンツのなりすましが発生する可能性があります。"
userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲"
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。"
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。"
_userGeneratedContentsVisibilityForVisitor:
all: "全て公開"
localOnly: "ローカルコンテンツのみ公開し、リモートコンテンツは非公開"
none: "全て非公開"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"
@@ -3107,3 +3138,55 @@ _search:
pleaseEnterServerHost: "サーバーのホストを入力してください"
pleaseSelectUser: "ユーザーを選択してください"
serverHostPlaceholder: "例: misskey.example.com"
_serverSetupWizard:
installCompleted: "Misskeyのインストールが完了しました!"
firstCreateAccount: "まずは、管理者アカウントを作成しましょう。"
accountCreated: "管理者アカウントが作成されました!"
serverSetting: "サーバーの設定"
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単に最適なサーバーの設定が行えます。"
settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変更できます。"
howWillYouUseMisskey: "Misskeyをどのように使いますか?"
_use:
single: "お一人様サーバー"
single_description: "自分専用のサーバーとして、一人で使う"
single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用する場合でも、アカウントは必要に応じて複数作成可能です。"
group: "グループサーバー"
group_description: "信頼できる他の利用者を招待して、複数人で使う"
open: "オープンサーバー"
open_description: "不特定多数の利用者を受け入れる運営を行う"
openServerAdvice: "不特定多数の利用者を受け入れることはリスクが伴います。トラブルに対処できるよう、確実なモデレーション体制で運営することを推奨します。"
openServerAntiSpamAdvice: "自サーバーがスパムの踏み台にならないように、reCAPTCHAといったアンチボット機能を有効にするなど、セキュリティについても細心の注意が必要です。"
howManyUsersDoYouExpect: "どれくらいの人数を想定していますか?"
_scale:
small: "100人以下 (小規模)"
medium: "100人以上1000人以下 (中規模)"
large: "1000人以上 (大規模)"
largeScaleServerAdvice: "大規模なサーバーでは、ロードバランシングやデータベースのレプリケーションなど、高度なインフラストラクチャーの知識が必要になる場合があります。"
doYouConnectToFediverse: "Fediverseと接続しますか?"
doYouConnectToFediverse_description1: "分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。"
doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれます。"
youCanConfigureMoreFederationSettingsLater: "連合可能なサーバーの指定など、高度な設定も後ほど可能です。"
adminInfo: "管理者情報"
adminInfo_description: "問い合わせを受け付けるために使用される管理者情報を設定します。"
adminInfo_mustBeFilled: "オープンサーバー、または連合がオンの場合は必ず入力が必要です。"
followingSettingsAreRecommended: "以下の設定が推奨されます"
applyTheseSettings: "この設定を適用"
skipSettings: "設定をスキップ"
settingsCompleted: "設定が完了しました!"
settingsCompleted_description: "お疲れ様でした。準備が整ったので、さっそくサーバーの使用を開始できます。"
settingsCompleted_description2: "詳細なサーバー設定は、「コントロールパネル」から行えます。"
donationRequest: "寄付のお願い"
_donationRequest:
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
text3: "支援者向け特典もあります!"
_clientPerformanceIssueTip:
title: "バッテリー消費が多いと感じたら"
makeSureDisabledAdBlocker: "アドブロッカーを無効にしてください"
makeSureDisabledAdBlocker_description: "アドブロッカーはパフォーマンスに影響を及ぼすことがあります。OSの機能やブラウザの機能・アドオンなどでアドブロッカーが有効になっていないか確認してください。"
makeSureDisabledCustomCss: "カスタムCSSを無効にしてください"
makeSureDisabledCustomCss_description: "スタイルを上書きするとパフォーマンスに影響を及ぼすことがあります。カスタムCSSや、スタイルを上書きする拡張機能が有効になっていないか確認してください。"
makeSureDisabledAddons: "拡張機能を無効にしてください"
makeSureDisabledAddons_description: "一部の拡張機能はクライアントの動作に干渉しパフォーマンスに影響を及ぼすことがあります。ブラウザの拡張機能を無効にして改善するか確認してください。"
-3
View File
@@ -250,7 +250,6 @@ noUsers: "ユーザーはおらん"
editProfile: "プロフィールをいじる"
noteDeleteConfirm: "このノートをほかしてええか?"
pinLimitExceeded: "これ以上ピン留めできひん"
intro: "Misskeyのインストールが完了したで!管理者アカウントを作ってや。"
done: "でけた"
processing: "処理しとる"
preview: "プレビュー"
@@ -781,7 +780,6 @@ thisIsExperimentalFeature: "これは実験的な機能やから、仕様が変
developer: "開発者やで"
makeExplorable: "アカウントを見つけやすくするで"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで"
duplicate: "複製"
left: "左"
center: "真ん中"
@@ -1233,7 +1231,6 @@ showAvatarDecorations: "アイコンのデコレーション映す"
releaseToRefresh: "離したらリロード"
refreshing: "リロードしとる"
pullDownToRefresh: "引っ張ってリロードするで"
disableStreamingTimeline: "タイムラインのリアルタイム更新をやめるで"
useGroupedNotifications: "通知をグループ分けして出すで"
signupPendingError: "メアド確認してたらなんか変なことなったわ。リンクの期限切れてるかもしれん。"
cwNotationRequired: "「内容を隠す」んやったら注釈書かなアカンで。"
-1
View File
@@ -224,7 +224,6 @@ noUsers: "사용자가 어ᇝ십니다"
editProfile: "프로필 적기"
noteDeleteConfirm: "요 노트럴 뭉캡니꺼?"
pinLimitExceeded: "더 몬 붙입니다"
intro: "Misskey럴 다 깔앗십니다! 간리자 게정얼 맨걸어 보입시다."
done: "햇어예"
processing: "처리하고 잇어예"
preview: "미리보기"
+10 -3
View File
@@ -220,6 +220,7 @@ silenceThisInstance: "서버를 사일런스"
mediaSilenceThisInstance: "서버의 미디어를 사일런스"
operations: "작업"
software: "소프트웨어"
softwareName: "소프트웨어 이름"
version: "버전"
metadata: "메타데이터"
withNFiles: "{n}개의 파일"
@@ -250,7 +251,6 @@ noUsers: "아무도 없습니다"
editProfile: "프로필 수정"
noteDeleteConfirm: "이 노트를 삭제하시겠습니까?"
pinLimitExceeded: "더 이상 고정할 수 없습니다."
intro: "Misskey의 설치가 완료되었습니다! 관리자 계정을 생성해주세요."
done: "완료"
processing: "처리중"
preview: "미리보기"
@@ -784,7 +784,6 @@ thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양
developer: "개발자"
makeExplorable: "계정을 쉽게 발견하도록 하기"
makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다."
showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시"
duplicate: "복제"
left: "왼쪽"
center: "가운데"
@@ -1237,7 +1236,6 @@ showAvatarDecorations: "아바타 장식 표시"
releaseToRefresh: "놓아서 새로고침"
refreshing: "새로고침 중"
pullDownToRefresh: "아래로 내려서 새로고침"
disableStreamingTimeline: "타임라인의 실시간 갱신을 무효화하기"
useGroupedNotifications: "알림을 그룹화하고 표시"
signupPendingError: "메일 주소 확인중에 문제가 발생했습니다. 링크의 유효기간이 지났을 가능성이 있습니다."
cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써야 합니다."
@@ -1346,6 +1344,8 @@ settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십
readonly: "읽기 전용"
goToDeck: "덱으로 돌아가기"
federationJobs: "연합 작업"
driveAboutTip: "드라이브는 이전에 업로드한 파일 목록을 표시해요. <br>\n노트에 첨부할 때 다시 사용하거나 나중에 게시할 파일을 미리 업로드할 수 있어요. <br>\n<b>파일을 삭제하면, 지금까지 그 파일을 사용한 모든 장소(노트, 페이지, 아바타, 배너 등)에서도 보이지 않게 되므로 주의해 주세요. 폴더를 만들고 정리할 수도 있어요.</b><br>"
scrollToClose: "스크롤하여 닫기"
_chat:
noMessagesYet: "아직 메시지가 없습니다"
newMessage: "새로운 메시지"
@@ -1422,6 +1422,8 @@ _settings:
ifOn: "켜져 있을 때"
ifOff: "꺼져 있을 때"
enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화"
enablePullToRefresh: "계속해서 갱신"
enablePullToRefresh_description: "마우스에서 휠을 누르면서 드래그해요."
_chat:
showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기"
@@ -1429,6 +1431,7 @@ _preferencesProfile:
profileName: "프로필 이름"
profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요."
profileNameDescription2: "예: '메인PC', '스마트폰' 등"
manageProfiles: "프로파일 관리"
_preferencesBackup:
autoBackup: "자동 백업"
restoreFromBackup: "백업으로 복구"
@@ -1467,6 +1470,7 @@ _delivery:
manuallySuspended: "수동 정지 중"
goneSuspended: "서버 삭제를 이유로 정지 중"
autoSuspendedForNotResponding: "서버 응답 없음을 이유로 정지 중"
softwareSuspended: "전달 정지 중인 소프트웨어이므로 정지 중"
_bubbleGame:
howToPlay: "설명"
hold: "홀드"
@@ -1598,6 +1602,8 @@ _serverSettings:
openRegistration: "회원 가입을 활성화 하기"
openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
deliverSuspendedSoftware: "전달 정지 중인 소프트웨어"
deliverSuspendedSoftwareDescription: "취약성 등의 이유로 서버의 소프트웨어 이름 및 버전 범위를 지정하여 전달을 정지할 수 있어요. 이 버전 정보는 서버가 제공한 것이며 신뢰성은 보장되지 않아요. 버전 지정에는 semver의 범위 지정을 사용할 수 있지만, >= 2024.3.1로 지정하면 2024.3.1-custom.0과 같은 custom.0과 같은 custom 버전이 포함되지 않기 때문에 >= 2024.3.1-0과 같이 prerelease를 지정하는 것이 좋아요."
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성"
@@ -1915,6 +1921,7 @@ _role:
canManageCustomEmojis: "커스텀 이모지 관리"
canManageAvatarDecorations: "아바타 꾸미기 관리"
driveCapacity: "드라이브 용량"
maxFileSize: "업로드 가능한 최대 파일 크기"
alwaysMarkNsfw: "파일을 항상 NSFW로 지정"
canUpdateBioMedia: "아바타 및 배너 이미지 변경 허용"
pinMax: "고정할 수 있는 노트 수"
-2
View File
@@ -250,7 +250,6 @@ noUsers: "Er zijn geen gebruikers."
editProfile: "Bewerk Profiel"
noteDeleteConfirm: "Ben je zeker dat je dit bericht wil verwijderen?"
pinLimitExceeded: "Je kunt geen berichten meer vastprikken"
intro: "Installatie van Misskey geëindigd! Maak nu een beheerder aan."
done: "Klaar"
processing: "Bezig met verwerken"
preview: "Voorbeeld"
@@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Dit is een experimentele functie. De functionaliteit
developer: "Ontwikkelaar"
makeExplorable: "Gebruikersaccount zichtbaar maken in “Verkennen”"
makeExplorableDescription: "Als deze optie is uitgeschakeld, is uw gebruikersaccount niet zichtbaar in het gedeelte “Verkennen”."
showGapBetweenNotesInTimeline: "Een gat tussen noten op de tijdlijn weergeven"
duplicate: "Dupliceren"
left: "Links"
center: "Center"
-1
View File
@@ -171,7 +171,6 @@ noUsers: "Det er ingen brukere"
editProfile: "Rediger profil"
noteDeleteConfirm: "Er du sikker på at du vil slette denne Noten?"
pinLimitExceeded: "Du kan ikke feste flere."
intro: "Installasjonen av Misskey er ferdig! Vennligst opprett en administratorkonto."
done: "Ferdig"
default: "Standard"
defaultValueIs: "Standard: {value}"
-2
View File
@@ -230,7 +230,6 @@ noUsers: "Brak użytkowników"
editProfile: "Edytuj profil"
noteDeleteConfirm: "Czy na pewno chcesz usunąć ten wpis?"
pinLimitExceeded: "Nie możesz przypiąć więcej wpisów."
intro: "Zakończono instalację Misskey! Utwórz konto administratora."
done: "Gotowe"
processing: "Przetwarzanie"
preview: "Podgląd"
@@ -749,7 +748,6 @@ thisIsExperimentalFeature: "Ta funkcja jest eksperymentalna. Jej funkcjonalnoś
developer: "Programista"
makeExplorable: "Pokazuj konto na stronie „Eksploruj”"
makeExplorableDescription: "Jeżeli wyłączysz tę opcję, Twoje konto nie będzie wyświetlać się w sekcji „Eksploruj”."
showGapBetweenNotesInTimeline: "Pokazuj odstęp między wpisami na osi czasu."
duplicate: "Duplikuj"
left: "Lewo"
center: "Wyśsrodkuj"
-3
View File
@@ -250,7 +250,6 @@ noUsers: "Sem usuários"
editProfile: "Editar Perfil"
noteDeleteConfirm: "Deseja excluir esta nota?"
pinLimitExceeded: "Não é possível fixar novas notas"
intro: "A instalação do Misskey está completa! Crie uma conta de administrador."
done: "Concluído"
processing: "Em Progresso"
preview: "Pré-visualizar"
@@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Este é um recurso experimental. As funções podem
developer: "Programador"
makeExplorable: "Deixe a sua conta encontrável em \"Explorar\"."
makeExplorableDescription: "Se você desativá-lo, outros usuários não poderão encontrar a sua conta na aba Descoberta."
showGapBetweenNotesInTimeline: "Mostrar um espaço entre as notas na linha de tempo"
duplicate: "Duplicar"
left: "Esquerda"
center: "Centralizar"
@@ -1237,7 +1235,6 @@ showAvatarDecorations: "Exibir decorações de avatar"
releaseToRefresh: "Solte para atualizar"
refreshing: "Atualizando..."
pullDownToRefresh: "Puxe para baixo para atualizar"
disableStreamingTimeline: "Desabilitar atualizações em tempo real da linha do tempo"
useGroupedNotifications: "Agrupar notificações"
signupPendingError: "Houve um problema ao verificar o endereço de email. O link pode ter expirado."
cwNotationRequired: "Se \"Esconder conteúdo\" está habilitado, uma descrição deve ser adicionada."
-2
View File
@@ -250,7 +250,6 @@ noUsers: "Niciun utilizator"
editProfile: "Editează profilul"
noteDeleteConfirm: "Ești sigur(ă) că vrei să ștergi această notă?"
pinLimitExceeded: "Nu poți mai fixa mai multe note"
intro: "Misskey s-a instalat! Te rog crează un utilizator admin."
done: "Gata"
processing: "Se procesează"
preview: "Previzualizare"
@@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Aceasta este o funcție experimentală. Funcționali
developer: "Dezvoltator"
makeExplorable: "Fă-ți contul vizibil în secțiunea„Explorați”"
makeExplorableDescription: "Dacă dezactivezi această opțiune, contul dvs. nu va fi vizibil în secțiunea\"Explorați\"."
showGapBetweenNotesInTimeline: "Afișați un decalaj între postările de pe cronologie"
duplicate: "Duplicat"
left: "Stânga"
center: "Centru"
-3
View File
@@ -251,7 +251,6 @@ noUsers: "Нет ни одного пользователя"
editProfile: "Редактировать профиль"
noteDeleteConfirm: "Вы хотите удалить эту заметку?"
pinLimitExceeded: "Нельзя закрепить ещё больше заметок"
intro: "Установка Misskey завершена! А теперь создайте учетную запись администратора."
done: "Готово"
processing: "Обработка"
preview: "Предпросмотр"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Это экспериментальная функц
developer: "Разработчик"
makeExplorable: "Опубликовать профиль в «Обзоре»."
makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»."
showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте"
duplicate: "Дубликат"
left: "Слева"
center: "По центру"
@@ -1200,7 +1198,6 @@ privacyPolicyUrl: "Ссылка на Политику Конфиденциаль
attach: "Прикрепить"
angle: "Угол"
flip: "Переворот"
disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени"
useGroupedNotifications: "Отображать уведомления сгруппировано"
doReaction: "Добавить реакцию"
code: "Код"
-2
View File
@@ -204,7 +204,6 @@ noUsers: "Žiadni používatelia"
editProfile: "Upraviť profil"
noteDeleteConfirm: "Naozaj chcete odstrániť túto poznámku?"
pinLimitExceeded: "Ďalšie poznámky už nemôžete pripnúť."
intro: "Inštalácia Misskey je dokončená! Prosím vytvorte administrátora."
done: "Hotovo"
processing: "Pracujem..."
preview: "Náhľad"
@@ -682,7 +681,6 @@ experimentalFeatures: "Experimentálne funkcie"
developer: "Vývojár"
makeExplorable: "Spraviť účet viditeľný v \"Objavovať\""
makeExplorableDescription: "Ak toto vypnete, váš účet sa nezobrazí v sekcii \"Objavovat\"."
showGapBetweenNotesInTimeline: "Zobraziť medzeru medzi príspevkami časovej osi."
duplicate: "Duplikovať"
left: "Naľavo"
center: "Stred"
-1
View File
@@ -211,7 +211,6 @@ noUsers: "Det finns inga användare"
editProfile: "Redigera profil"
noteDeleteConfirm: "Är du säker på att du vill ta bort denna not?"
pinLimitExceeded: "Du kan inte fästa fler noter"
intro: "Misskey har installerats! Vänligen skapa en adminanvändare."
done: "Klar"
processing: "Bearbetar..."
preview: "Förhandsvisning"
-3
View File
@@ -250,7 +250,6 @@ noUsers: "ไม่พบผู้ใช้งาน"
editProfile: "แก้ไขโปรไฟล์"
noteDeleteConfirm: "ต้องการลบโน้ตนี้ใช่ไหม?"
pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก"
intro: "การติดตั้ง Misskey เสร็จสิ้นแล้วนะ! โปรดสร้างผู้ใช้งานที่เป็นผู้ดูแลระบบ"
done: "เสร็จสิ้น"
processing: "กำลังประมวลผล..."
preview: "แสดงตัวอย่าง"
@@ -778,7 +777,6 @@ thisIsExperimentalFeature: "นี่เป็นฟีเจอร์ทดล
developer: "สำหรับนักพัฒนา"
makeExplorable: "ทำให้บัญชีมองเห็นใน “สำรวจ”"
makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน “สำรวจ”"
showGapBetweenNotesInTimeline: "แสดงช่องว่างระหว่างโพสต์บนไทม์ไลน์"
duplicate: "ทำซ้ำ"
left: "ซ้าย"
center: "กึ่งกลาง"
@@ -1227,7 +1225,6 @@ showAvatarDecorations: "แสดงตกแต่งอวตาร"
releaseToRefresh: "ปล่อยเพื่อรีเฟรช"
refreshing: "กำลังรีเฟรช..."
pullDownToRefresh: "ดึงลงเพื่อรีเฟรช"
disableStreamingTimeline: "ปิดใช้งานอัปเดตไทม์ไลน์แบบเรียลไทม์"
useGroupedNotifications: "แสดงผลการแจ้งเตือนแบบกลุ่มแล้ว"
signupPendingError: "มีปัญหาในการตรวจสอบที่อยู่อีเมลลิงก์อาจหมดอายุแล้ว"
cwNotationRequired: "หากเปิดใช้งาน “ซ่อนเนื้อหา” จะต้องระบุคำอธิบาย"
-1
View File
@@ -224,7 +224,6 @@ noUsers: "Kullanıcı yok"
editProfile: "Profili düzenle"
noteDeleteConfirm: "Bu notu silmek istediğinizden emin misiniz?"
pinLimitExceeded: "Daha fazla not sabitlenemez"
intro: "Misskey yüklemesi tamamlandı! Lütfen yönetici hesabını oluşturun."
done: "Tamamlandı"
preview: "Önizleme"
default: "Varsayılan"
-2
View File
@@ -208,7 +208,6 @@ noUsers: "Немає користувачів"
editProfile: "Редагувати обліковий запис"
noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?"
pinLimitExceeded: "Більше записів не можна закріпити"
intro: "Встановлення Misskey завершено! Будь ласка, створіть обліковий запис адміністратора."
done: "Готово"
processing: "Обробка"
preview: "Попередній перегляд"
@@ -681,7 +680,6 @@ experimentalFeatures: "Експериментальні функції"
developer: "Розробник"
makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\""
makeExplorableDescription: "Вимкніть, щоб обліковий запис не показувався у розділі \"Огляд\"."
showGapBetweenNotesInTimeline: "Показувати розрив між записами у стрічці новин"
duplicate: "Дублікат"
left: "Лівий"
center: "Центр"
-1
View File
@@ -219,7 +219,6 @@ noUsers: "Foydalanuvchilar yoq"
editProfile: "Profilni o'zgartirish"
noteDeleteConfirm: "Haqiqatan ham bu qaydni oʻchirib tashlamoqchimisiz?"
pinLimitExceeded: "Siz boshqa qaydlarni mahkamlay olmaysiz"
intro: "Misskeyni o'rnatish tugallandi! Iltimos, administrator foydalanuvchi yarating."
done: "Bajarildi"
processing: "Amaliyotda"
preview: "Ko'rish"
-2
View File
@@ -250,7 +250,6 @@ noUsers: "Chưa có ai"
editProfile: "Sửa hồ sơ"
noteDeleteConfirm: "Bạn có chắc muốn xóa tút này?"
pinLimitExceeded: "Bạn không thể ghim bài viết nữa"
intro: "Đã cài đặt Misskey! Xin hãy tạo tài khoản admin."
done: "Xong"
processing: "Đang xử lý"
preview: "Xem trước"
@@ -783,7 +782,6 @@ thisIsExperimentalFeature: "Tính năng này đang trong quá trình thử nghi
developer: "Nhà phát triển"
makeExplorable: "Không hiện tôi trong \"Khám phá\""
makeExplorableDescription: "Nếu bạn tắt, tài khoản của bạn sẽ không hiện trong mục \"Khám phá\"."
showGapBetweenNotesInTimeline: "Hiện dải phân cách giữa các tút trên bảng tin"
duplicate: "Tạo bản sao"
left: "Bên trái"
center: "Giữa"
+2 -3
View File
@@ -251,7 +251,6 @@ noUsers: "无用户"
editProfile: "编辑资料"
noteDeleteConfirm: "确定要删除该帖子吗?"
pinLimitExceeded: "无法置顶更多了"
intro: "Misskey 的部署结束啦!创建管理员账号吧!"
done: "完成"
processing: "正在处理"
preview: "预览"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "这是一项实验性功能。规范可能会变更
developer: "开发者"
makeExplorable: "使账号可见。"
makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。"
showGapBetweenNotesInTimeline: "时间线上的帖子分开显示。"
duplicate: "复制"
left: "左"
center: "中央"
@@ -1238,7 +1236,6 @@ showAvatarDecorations: "显示头像挂件"
releaseToRefresh: "松开以刷新"
refreshing: "刷新中"
pullDownToRefresh: "下拉以刷新"
disableStreamingTimeline: "禁止实时更新时间线"
useGroupedNotifications: "分组显示通知"
signupPendingError: "确认电子邮件时出现错误。链接可能已过期。"
cwNotationRequired: "在启用「隐藏内容」时必须输入注释"
@@ -1348,6 +1345,7 @@ readonly: "只读"
goToDeck: "返回至 Deck"
federationJobs: "联合作业"
driveAboutTip: "网盘可以显示以前上传的文件。<br>\n也可以在发布帖子时重复使用文件,或在发布帖子前预先上传文件。<br>\n<b>删除文件时,其将从至今为止所有用到该文件的地方(如帖子、页面、头像、横幅)消失。</b><br>\n也可以新建文件夹来整理文件。"
scrollToClose: "滑动并关闭"
_chat:
noMessagesYet: "还没有消息"
newMessage: "新消息"
@@ -1432,6 +1430,7 @@ _preferencesProfile:
profileName: "配置名"
profileNameDescription: "请指定用于识别此设备的名称"
profileNameDescription2: "如「PC」、「手机」等"
manageProfiles: "管理配置文件"
_preferencesBackup:
autoBackup: "自动备份"
restoreFromBackup: "从备份恢复"
+1 -3
View File
@@ -251,7 +251,6 @@ noUsers: "沒有任何使用者"
editProfile: "編輯個人檔案"
noteDeleteConfirm: "確定刪除此貼文嗎?"
pinLimitExceeded: "不能置頂更多貼文了"
intro: "Misskey 部署完成!請建立管理員帳戶。"
done: "完成"
processing: "處理中"
preview: "預覽"
@@ -785,7 +784,6 @@ thisIsExperimentalFeature: "這是一項實驗性功能,其行為會隨需要
developer: "開發者"
makeExplorable: "使自己的帳戶更容易被找到"
makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。"
showGapBetweenNotesInTimeline: "分開顯示時間軸上的貼文"
duplicate: "複製"
left: "左"
center: "置中"
@@ -1238,7 +1236,6 @@ showAvatarDecorations: "顯示頭像裝飾"
releaseToRefresh: "放開以更新內容"
refreshing: "載入更新中"
pullDownToRefresh: "往下拉來更新內容"
disableStreamingTimeline: "停用時間軸的即時更新"
useGroupedNotifications: "分組顯示通知訊息"
signupPendingError: "驗證您的電子郵件地址時出現問題。連結可能已過期。"
cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。"
@@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "設定檔案名稱"
profileNameDescription: "設定一個名稱來識別此裝置。"
profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等"
manageProfiles: "管理個人檔案"
_preferencesBackup:
autoBackup: "自動備份"
restoreFromBackup: "從備份還原"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.5.0",
"version": "2025.5.1-alpha.2",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -34,7 +34,7 @@
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"lint": "pnpm -r lint",
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 {
name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`);
}
}
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SingleUserMode1746422049376 {
name = 'SingleUserMode1746422049376'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "singleUserMode" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "singleUserMode"`);
}
}
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {loadConfig} from "./js/migration-config.js";
export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
async up(queryRunner) {
const config = loadConfig();
// $1 cannot be used in ALTER TABLE queries
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowExternalApRedirect"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "signToActivityPubGet"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyRemoteFiles"`);
}
}
@@ -3,6 +3,29 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { path as configYamlPath } from '../../built/config.js';
import * as yaml from 'js-yaml';
import fs from "node:fs";
export function isConcurrentIndexMigrationEnabled() {
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
}
let loadedConfigCache = undefined;
function loadConfigInternal() {
const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8'));
return {
disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false),
proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false),
signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true),
}
}
export function loadConfig() {
if (loadedConfigCache === undefined) {
loadedConfigCache = loadConfigInternal();
}
return loadedConfigCache;
}
+1 -15
View File
@@ -79,9 +79,6 @@ type Source = {
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
disallowExternalApRedirect?: boolean;
maxFileSize?: number;
clusterLimit?: number;
@@ -100,11 +97,8 @@ type Source = {
inboxJobMaxAttempts?: number;
mediaProxy?: string;
proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number;
deactivateAntennaThreshold?: number;
@@ -156,8 +150,6 @@ export type Config = {
proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined;
disallowExternalApRedirect: boolean;
maxFileSize: number;
clusterLimit: number | undefined;
id: string;
outgoingAddress: string | undefined;
@@ -170,8 +162,6 @@ export type Config = {
relationshipJobPerSec: number | undefined;
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined;
signToActivityPubGet: boolean | undefined;
logging?: {
sql?: {
disableQueryTruncation?: boolean,
@@ -229,7 +219,7 @@ const dir = `${_dirname}/../../../.config`;
/**
* Path of configuration file
*/
const path = process.env.MISSKEY_CONFIG_YML
export const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
@@ -300,8 +290,6 @@ export function loadConfig(): Config {
proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks,
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily,
@@ -313,8 +301,6 @@ export function loadConfig(): Config {
relationshipJobPerSec: config.relationshipJobPerSec,
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles,
signToActivityPubGet: config.signToActivityPubGet ?? true,
mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ?
+21 -1
View File
@@ -29,7 +29,7 @@ import { emojiRegex } from '@/misc/emoji-regex.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
const MAX_ROOM_MEMBERS = 30;
const MAX_ROOM_MEMBERS = 50;
const MAX_REACTIONS_PER_MESSAGE = 100;
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
@@ -578,6 +578,20 @@ export class ChatService {
@bindThis
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: room.id })).map(m => ({
userId: m.userId,
})).concat({ // ownerはmembershipレコードを作らないため
userId: room.ownerId,
});
// 未読フラグ削除
const redisPipeline = this.redisClient.pipeline();
for (const membership of memberships) {
redisPipeline.del(`newRoomChatMessageExists:${membership.userId}:${room.id}`);
redisPipeline.srem(`newChatMessagesExists:${membership.userId}`, `room:${room.id}`);
}
await redisPipeline.exec();
await this.chatRoomsRepository.delete(room.id);
if (deleter) {
@@ -709,6 +723,12 @@ export class ChatService {
public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
await this.chatRoomMembershipsRepository.delete(membership.id);
// 未読フラグを消す (「既読にする」というわけでもないのでreadメソッドは使わないでおく)
const redisPipeline = this.redisClient.pipeline();
redisPipeline.del(`newRoomChatMessageExists:${userId}:${roomId}`);
redisPipeline.srem(`newChatMessagesExists:${userId}`, `room:${roomId}`);
await redisPipeline.exec();
}
@bindThis
+1 -1
View File
@@ -41,7 +41,7 @@ export class DownloadService {
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize;
const maxSize = 1024 * 1024 * 1024; // 1GB
const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
@@ -104,7 +104,7 @@ export class Resolver {
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked');
}
if (this.config.signToActivityPubGet && !this.user) {
if (this.meta.signToActivityPubGet && !this.user) {
this.user = await this.systemAccountService.fetch('actor');
}
@@ -238,13 +238,15 @@ export class ChatEntityService {
options?: {
_hint_?: {
packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
myMemberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
myInvitations?: Map<MiChatRoom['id'], MiChatRoomInvitation | null | undefined>;
};
},
): Promise<Packed<'ChatRoom'>> {
const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
const membership = me && me.id !== room.ownerId ? (options?._hint_?.myMemberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
const invitation = me && me.id !== room.ownerId ? (options?._hint_?.myInvitations?.get(room.id) ?? await this.chatRoomInvitationsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
return {
id: room.id,
@@ -254,6 +256,7 @@ export class ChatEntityService {
ownerId: room.ownerId,
owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
isMuted: membership != null ? membership.isMuted : false,
invitationExists: invitation != null,
};
}
@@ -278,7 +281,7 @@ export class ChatEntityService {
const owners = _rooms.map(x => x.owner ?? x.ownerId);
const [packedOwners, memberships] = await Promise.all([
const [packedOwners, myMemberships, myInvitations] = await Promise.all([
this.userEntityService.packMany(owners, me)
.then(users => new Map(users.map(u => [u.id, u]))),
this.chatRoomMembershipsRepository.find({
@@ -287,9 +290,15 @@ export class ChatEntityService {
userId: me.id,
},
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
this.chatRoomInvitationsRepository.find({
where: {
roomId: In(_rooms.map(x => x.id)),
userId: me.id,
},
}).then(invitations => new Map(_rooms.map(r => [r.id, invitations.find(i => i.roomId === r.id)]))),
]);
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, myMemberships, myInvitations } })));
}
@bindThis
@@ -6,7 +6,7 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository } from '@/models/_.js';
import type { DriveFilesRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
@@ -34,6 +34,9 @@ export class DriveFileEntityService {
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@@ -95,7 +98,7 @@ export class DriveFileEntityService {
return this.getProxiedUrl(file.uri, 'static');
}
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
if (file.uri != null && file.isLink && this.meta.proxyRemoteFiles) {
// リモートかつ期限切れはローカルプロキシを試みる
// 従来は/files/${thumbnailAccessKey}にアクセスしていたが、
// /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する
@@ -115,7 +118,7 @@ export class DriveFileEntityService {
}
// リモートかつ期限切れはローカルプロキシを試みる
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
if (file.uri != null && file.isLink && this.meta.proxyRemoteFiles) {
const key = file.webpublicAccessKey;
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
@@ -131,7 +131,6 @@ export class MetaEntityService {
mediaProxy: this.config.mediaProxy,
enableUrlPreview: instance.urlPreviewEnabled,
noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local',
maxFileSize: this.config.maxFileSize,
federation: this.meta.federation,
};
@@ -429,6 +429,7 @@ export class NoteEntityService implements OnModuleInit {
userId: channel.userId,
} : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined,
hasPoll: note.hasPoll || undefined,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
@@ -593,4 +594,42 @@ export class NoteEntityService implements OnModuleInit {
relations: ['user'],
});
}
@bindThis
public async fetchDiffs(noteIds: MiNote['id'][]) {
if (noteIds.length === 0) return [];
const notes = await this.notesRepository.find({
where: {
id: In(noteIds),
},
select: {
id: true,
userHost: true,
reactions: true,
reactionAndUserPairCache: true,
},
});
const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null;
const packings = notes.map(note => {
const bufferedReactions = bufferedReactionsMap?.get(note.id);
//const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {}));
const reactionEmojiNames = Object.keys(reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
return this.customEmojiService.populateEmojis(reactionEmojiNames, note.userHost).then(reactionEmojis => ({
id: note.id,
reactions,
reactionEmojis,
}));
});
return await Promise.all(packings);
}
}
+3
View File
@@ -106,3 +106,6 @@ export class MiAntenna {
})
public excludeNotesInSensitiveChannel: boolean;
}
// Note for future developers: When you added a new column,
// You should update ExportAntennaProcessorService and ImportAntennaProcessorService
// to export and import antennas correctly.
+26
View File
@@ -659,6 +659,12 @@ export class MiMeta {
})
public federationHosts: string[];
@Column('varchar', {
length: 128,
default: 'local',
})
public ugcVisibilityForVisitor: 'all' | 'local' | 'none';
@Column('varchar', {
length: 64,
nullable: true,
@@ -669,6 +675,26 @@ export class MiMeta {
default: [],
})
public deliverSuspendedSoftware: SoftwareSuspension[];
@Column('boolean', {
default: false,
})
public singleUserMode: boolean;
@Column('boolean', {
default: true,
})
public proxyRemoteFiles: boolean;
@Column('boolean', {
default: true,
})
public signToActivityPubGet: boolean;
@Column('boolean', {
default: true,
})
public allowExternalApRedirect: boolean;
}
export type SoftwareSuspension = {
@@ -36,5 +36,9 @@ export const packedChatRoomSchema = {
type: 'boolean',
optional: true, nullable: false,
},
invitationExists: {
type: 'boolean',
optional: true, nullable: false,
},
},
} as const;
@@ -293,10 +293,6 @@ export const packedMetaLiteSchema = {
optional: false, nullable: false,
default: 'local',
},
maxFileSize: {
type: 'number',
optional: false, nullable: false,
},
federation: {
type: 'string',
enum: ['all', 'specified', 'none'],
@@ -256,6 +256,10 @@ export const packedNoteSchema = {
type: 'number',
optional: true, nullable: false,
},
hasPoll: {
type: 'boolean',
optional: true, nullable: false,
},
myReaction: {
type: 'string',
@@ -15,6 +15,7 @@ import { bindThis } from '@/decorators.js';
import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ExportedAntenna } from '@/queue/processors/ImportAntennasProcessorService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type { DBExportAntennasData } from '../types.js';
import type * as Bull from 'bullmq';
@@ -86,7 +87,8 @@ export class ExportAntennasProcessorService {
excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
}));
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
} satisfies Required<ExportedAntenna>));
if (antennas.length - 1 !== index) {
write(', ');
}
@@ -11,17 +11,18 @@ import Logger from '@/logger.js';
import type { AntennasRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { Schema, SchemaType } from '@/misc/json-schema.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { DBAntennaImportJobData } from '../types.js';
import type * as Bull from 'bullmq';
const Ajv = _Ajv.default;
const validate = new Ajv().compile({
const exportedAntennaSchema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
src: { type: 'string', enum: ['home', 'all', 'users', 'list'] },
src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] },
userListAccts: {
type: 'array',
items: {
@@ -47,9 +48,14 @@ const validate = new Ajv().compile({
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
excludeNotesInSensitiveChannel: { type: 'boolean' },
},
required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
});
} as const satisfies Schema;
export type ExportedAntenna = SchemaType<typeof exportedAntennaSchema>;
const validate = new Ajv().compile<ExportedAntenna>(exportedAntennaSchema);
@Injectable()
export class ImportAntennasProcessorService {
@@ -91,6 +97,7 @@ export class ImportAntennasProcessorService {
excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
});
this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result);
+3 -3
View File
@@ -108,7 +108,7 @@ export class ServerService implements OnApplicationShutdown {
// this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com
//
// this is not required by standard but protect us from peers that did not validate final URL.
if (this.config.disallowExternalApRedirect) {
if (!this.meta.allowExternalApRedirect) {
const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i;
fastify.addHook('onSend', (request, reply, _, done) => {
const location = reply.getHeader('location');
@@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown {
reply.header('content-type', 'text/plain; charset=utf-8');
reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
done(null, [
"Refusing to relay remote ActivityPub object lookup.",
"",
'Refusing to relay remote ActivityPub object lookup.',
'',
`Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
].join('\n'));
});
@@ -326,19 +326,15 @@ export class ApiCallService implements OnApplicationShutdown {
if (factor > 0) {
// Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
if ('info' in err) {
// errはLimiter.LimiterInfoであることが期待される
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
}, err.info);
} else {
throw new TypeError('information must be a rate-limiter information.');
}
});
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if (rateLimit != null) {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
}, rateLimit.info);
}
}
}
@@ -51,7 +51,6 @@ export class ApiServerService {
fastify.register(multipart, {
limits: {
fileSize: this.config.maxFileSize,
files: 1,
},
});
@@ -12,6 +12,14 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type { IEndpointMeta } from './endpoints.js';
type RateLimitInfo = {
code: 'BRIEF_REQUEST_INTERVAL',
info: Limiter.LimiterInfo,
} | {
code: 'RATE_LIMIT_EXCEEDED',
info: Limiter.LimiterInfo,
};
@Injectable()
export class RateLimiterService {
private logger: Logger;
@@ -31,77 +39,55 @@ export class RateLimiterService {
}
@bindThis
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
{
if (this.disabled) {
return Promise.resolve();
}
private checkLimiter(options: Limiter.LimiterOption): Promise<Limiter.LimiterInfo> {
return new Promise<Limiter.LimiterInfo>((resolve, reject) => {
new Limiter(options).get((err, info) => {
if (err) {
return reject(err);
}
resolve(info);
});
});
}
// Short-term limit
const min = new Promise<void>((ok, reject) => {
const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval! * factor,
max: 1,
db: this.redisClient,
});
@bindThis
public async limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1): Promise<RateLimitInfo | null> {
if (this.disabled) {
return null;
}
minIntervalLimiter.get((err, info) => {
if (err) {
return reject({ code: 'ERR', info });
}
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) {
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
} else {
if (hasLongTermLimit) {
return max.then(ok, reject);
} else {
return ok();
}
}
});
// Short-term limit
if (limitation.minInterval != null) {
const info = await this.checkLimiter({
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval * factor,
max: 1,
db: this.redisClient,
});
// Long term limit
const max = new Promise<void>((ok, reject) => {
const limiter = new Limiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration! * factor,
max: limitation.max! / factor,
db: this.redisClient,
});
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
limiter.get((err, info) => {
if (err) {
return reject({ code: 'ERR', info });
}
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
} else {
return ok();
}
});
});
const hasShortTermLimit = typeof limitation.minInterval === 'number';
const hasLongTermLimit =
typeof limitation.duration === 'number' &&
typeof limitation.max === 'number';
if (hasShortTermLimit) {
return min;
} else if (hasLongTermLimit) {
return max;
} else {
return Promise.resolve();
if (info.remaining === 0) {
return { code: 'BRIEF_REQUEST_INTERVAL', info };
}
}
// Long term limit
if (limitation.duration != null && limitation.max != null) {
const info = await this.checkLimiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max / factor,
db: this.redisClient,
});
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
return { code: 'RATE_LIMIT_EXCEEDED', info };
}
}
return null;
}
}
@@ -89,10 +89,9 @@ export class SigninApiService {
return { error };
}
try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
} catch (err) {
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
@@ -323,6 +323,7 @@ export * as 'notes/replies' from './endpoints/notes/replies.js';
export * as 'notes/search' from './endpoints/notes/search.js';
export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js';
export * as 'notes/show' from './endpoints/notes/show.js';
export * as 'notes/show-partial-bulk' from './endpoints/notes/show-partial-bulk.js';
export * as 'notes/state' from './endpoints/notes/state.js';
export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js';
export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js';
@@ -546,6 +546,27 @@ export const meta = {
},
},
},
singleUserMode: {
type: 'boolean',
optional: false, nullable: false,
},
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
optional: false, nullable: false,
},
proxyRemoteFiles: {
type: 'boolean',
optional: false, nullable: false,
},
signToActivityPubGet: {
type: 'boolean',
optional: false, nullable: false,
},
allowExternalApRedirect: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
} as const;
@@ -691,6 +712,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
singleUserMode: instance.singleUserMode,
ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor,
proxyRemoteFiles: instance.proxyRemoteFiles,
signToActivityPubGet: instance.signToActivityPubGet,
allowExternalApRedirect: instance.allowExternalApRedirect,
};
});
}
@@ -196,6 +196,14 @@ export const paramDef = {
required: ['software', 'versionRange'],
},
},
singleUserMode: { type: 'boolean' },
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
},
proxyRemoteFiles: { type: 'boolean' },
signToActivityPubGet: { type: 'boolean' },
allowExternalApRedirect: { type: 'boolean' },
},
required: [],
} as const;
@@ -690,6 +698,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
}
if (ps.singleUserMode !== undefined) {
set.singleUserMode = ps.singleUserMode;
}
if (ps.ugcVisibilityForVisitor !== undefined) {
set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor;
}
if (ps.proxyRemoteFiles !== undefined) {
set.proxyRemoteFiles = ps.proxyRemoteFiles;
}
if (ps.signToActivityPubGet !== undefined) {
set.signToActivityPubGet = ps.signToActivityPubGet;
}
if (ps.allowExternalApRedirect !== undefined) {
set.allowExternalApRedirect = ps.allowExternalApRedirect;
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 },
},
required: ['noteIds'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private noteEntityService: NoteEntityService,
) {
super(meta, paramDef, async (ps, me) => {
return await this.noteEntityService.fetchDiffs(ps.noteIds);
});
}
}
@@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -46,6 +48,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
) {
@@ -59,6 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
return await this.noteEntityService.pack(note, me, {
detail: true,
});
@@ -5,7 +5,7 @@
import { In, IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@@ -82,6 +82,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -92,6 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apiLoggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
let user;
const isModerator = await this.roleService.isModerator(me);
@@ -123,6 +130,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else {
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser);
@@ -139,6 +150,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
if (user.host == null) {
if (me == null && ip != null) {
this.perUserPvChart.commitByVisitor(user, ip);
@@ -513,7 +513,12 @@ export class ClientServerService {
vary(reply.raw, 'Accept');
if (user != null) {
if (
user != null && (
this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && user.host == null)
)
) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const me = profile.fields
? profile.fields
@@ -577,7 +582,13 @@ export class ClientServerService {
relations: ['user'],
});
if (note && !note.user!.requireSigninToViewContents) {
if (
note &&
!note.user!.requireSigninToViewContents &&
(this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && note.userHost == null)
)
) {
const _note = await this.noteEntityService.pack(note);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
reply.header('Cache-Control', 'public, max-age=15');
@@ -17,8 +17,6 @@ proxyBypassHosts:
- www.recaptcha.net
- hcaptcha.com
- challenges.cloudflare.com
proxyRemoteFiles: true
signToActivityPubGet: true
allowedPrivateNetworks:
- 127.0.0.1/32
- 172.20.0.0/16
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
target="_blank"
rel="noopener"
>
<ImgWithBlurhash
<EmImgWithBlurhash
:hash="image.blurhash"
:src="hide ? null : url"
:forceBlurhash="hide"
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
import EmImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+66 -70
View File
@@ -79,39 +79,6 @@ export async function mainBoot() {
}
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins();
try {
@@ -169,8 +136,6 @@ export async function mainBoot() {
}
}
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) {
alert({
type: 'warning',
@@ -348,50 +313,81 @@ export async function mainBoot() {
}
}
const main = markRaw(stream.useChannel('main', null, 'System'));
if (store.s.realtimeMode) {
const stream = useStream();
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
stream.on('announcementCreated', onAnnouncementCreated);
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
const main = markRaw(stream.useChannel('main', null, 'System'));
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
signout();
});
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
}
}
// shortcut
@@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/composables/use-pagination.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {
+1 -1
View File
@@ -51,7 +51,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import date from '@/filters/date.js';
@@ -154,6 +154,10 @@ onUnmounted(() => {
&.naked {
background: transparent !important;
box-shadow: none !important;
> .content {
background: transparent !important;
}
}
&.scrollable {
@@ -7,8 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
import type { PropType } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import * as os from '@/os.js';
@@ -19,7 +17,7 @@ import { getDateText } from '@/utility/timeline-date-separate.js';
export default defineComponent({
props: {
items: {
type: Array as PropType<MisskeyEntity[]>,
type: Array,
required: true,
},
direction: {
@@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.large]: large,
}]"
>
<ImgWithBlurhash
v-if="isThumbnailAvailable"
<MkImgWithBlurhash
v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders"
:hash="file.blurhash"
:src="file.thumbnailUrl"
:alt="file.name"
:title="file.name"
:class="$style.thumbnail"
:cover="fit !== 'contain'"
:forceBlurhash="forceBlurhash"
/>
<img
v-else-if="isThumbnailAvailable"
:src="file.thumbnailUrl"
:alt="file.name"
:title="file.name"
:class="$style.thumbnail"
:style="{ objectFit: fit }"
/>
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
@@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
file: Misskey.entities.DriveFile;
@@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => {
.large .icon {
font-size: 40px;
}
.thumbnail {
width: 100%;
}
</style>
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@@ -53,7 +53,7 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = ref<InstanceType<typeof MkModalWindow>>();
const dialog = useTemplateRef('dialog');
const username = ref('');
const email = ref('');
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
<div class="thumbnail">
<Transition>
<ImgWithBlurhash
<MkImgWithBlurhash
class="img layered"
:transition="safe ? null : {
duration: 500,
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';
@@ -89,7 +89,7 @@ import { Chart } from 'chart.js';
import type { HeatmapSource } from '@/components/MkHeatmap.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { $i } from '@/i.js';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
@@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { instanceName as localInstanceName } from '@@/js/config.js';
import type { CSSProperties } from 'vue';
import { instance as localInstance } from '@/instance.js';
@@ -43,10 +44,33 @@ const faviconUrl = computed(() => {
return getProxiedImageUrlNullable(imageSrc);
});
type ITickerColors = {
readonly bg: string;
readonly fg: string;
};
const TICKER_YUV_THRESHOLD = 191 as const;
const TICKER_FG_COLOR_LIGHT = '#ffffff' as const;
const TICKER_FG_COLOR_DARK = '#2f2f2fcc' as const;
function getTickerColors(bgHex: string): ITickerColors {
const tinycolorInstance = tinycolor(bgHex);
const { r, g, b } = tinycolorInstance.toRgb();
const yuv = 0.299 * r + 0.587 * g + 0.114 * b;
const fgHex = yuv > TICKER_YUV_THRESHOLD ? TICKER_FG_COLOR_DARK : TICKER_FG_COLOR_LIGHT;
return {
fg: fgHex,
bg: bgHex,
} as const satisfies ITickerColors;
}
const themeColorStyle = computed<CSSProperties>(() => {
const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
const colors = getTickerColors(themeColor);
return {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
background: `linear-gradient(90deg, ${colors.bg}, ${colors.bg}00)`,
color: colors.fg,
};
});
</script>
@@ -60,7 +84,6 @@ $height: 2ex;
height: $height;
border-radius: 4px 0 0 4px;
overflow: clip;
color: #fff;
// text-shadow使
+1 -1
View File
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@@/js/config.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';
import type { MkABehavior } from '@/components/global/MkA.vue';
@@ -1,112 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<script lang="ts">
import { h, onMounted, onUnmounted, ref, watch } from 'vue';
export default {
name: 'MarqueeText',
props: {
duration: {
type: Number,
default: 15,
},
repeat: {
type: Number,
default: 2,
},
paused: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
setup(props) {
const contentEl = ref<HTMLElement>();
function calc() {
if (contentEl.value == null) return;
const eachLength = contentEl.value.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
contentEl.value.style.animationDuration = `${duration}s`;
}
watch(() => props.duration, calc);
onMounted(() => {
calc();
});
onUnmounted(() => {
});
return {
contentEl,
};
},
render({
$slots, $style, $props: {
duration, repeat, paused, reverse,
},
}) {
return h('div', { class: [$style.wrap] }, [
h('span', {
ref: 'contentEl',
class: [
paused
? $style.paused
: undefined,
$style.content,
],
}, Array(repeat).fill(
h('span', {
class: $style.text,
style: {
animationDirection: reverse
? 'reverse'
: undefined,
},
}, $slots.default()),
)),
]);
},
};
</script>
<style lang="scss" module>
.wrap {
overflow: clip;
animation-play-state: running;
&:hover {
animation-play-state: paused;
}
}
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;
}
@keyframes marquee {
0% { transform:translateX(0); }
100% { transform:translateX(-100%); }
}
</style>
@@ -0,0 +1,89 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrap">
<span
ref="contentEl"
:class="[$style.content, {
[$style.paused]: paused,
[$style.reverse]: reverse,
}]"
>
<span v-for="key in repeat" :key="key" :class="$style.text">
<slot></slot>
</span>
</span>
</div>
</template>
<script lang="ts" setup>
import { onMounted, useTemplateRef, watch } from 'vue';
const props = withDefaults(defineProps<{
duration?: number;
repeat?: number;
paused?: boolean;
reverse?: boolean;
}>(), {
duration: 15,
repeat: 2,
paused: false,
reverse: false,
});
const contentEl = useTemplateRef('contentEl');
function calcDuration() {
if (contentEl.value == null) return;
const eachLength = contentEl.value.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
contentEl.value.style.animationDuration = `${duration}s`;
}
watch(() => props.duration, calcDuration);
onMounted(calcDuration);
</script>
<style lang="scss" module>
.wrap {
overflow: clip;
animation-play-state: running;
&:hover {
animation-play-state: paused;
}
}
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;
}
.reverse .text {
animation-direction: reverse;
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
</style>
@@ -17,7 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
style: 'cursor: zoom-in;'
}"
>
<ImgWithBlurhash
<MkImgWithBlurhash
v-if="prefer.s.enableHighQualityImagePlaceholders"
:hash="image.blurhash"
:src="(prefer.s.dataSaver.media && hide) ? null : url"
:forceBlurhash="hide"
@@ -27,6 +28,20 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="image.properties.width"
:height="image.properties.height"
:style="hide ? 'filter: brightness(0.7);' : null"
:class="$style.image"
/>
<div
v-else-if="prefer.s.dataSaver.media || hide"
:title="image.comment || image.name"
:style="hide ? 'background: #888;' : null"
:class="$style.image"
></div>
<img
v-else
:src="url"
:alt="image.comment || image.name"
:title="image.comment || image.name"
:class="$style.image"
/>
</component>
<template v-if="hide">
@@ -57,7 +72,7 @@ import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { $i, iAmModerator } from '@/i.js';
@@ -300,4 +315,12 @@ html[data-color-scheme=light] .visible {
font-size: 0.8em;
padding: 2px 5px;
}
.image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
</style>
+118 -74
View File
@@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!hardMuted && muted === false"
v-show="!isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'"
tabindex="0"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@@ -84,10 +83,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
<MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
</div>
@@ -101,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
>
<template #more>
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
@@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
@@ -210,9 +227,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
@@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -245,29 +263,33 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
const note = ref(deepClone(props.note));
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) {
console.error(err);
}
}
note.value = result as Misskey.entities.Note;
note = result as Misskey.entities.Note;
});
}
const isRenote = Misskey.note.isPureRenote(note.value);
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);
const $appearNote = reactive({
reactions: appearNote.reactions,
reactionCount: appearNote.reactionCount,
reactionEmojis: appearNote.reactionEmojis,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
});
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
@@ -275,32 +297,30 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const muted = ref(checkMute(appearNote, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref(
prefer.s.collapseRenotes && isRenote && (
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
(appearNote.value.myReaction != null)
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
($appearNote.myReaction != null)
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
url: `https://${host}/notes/${appearNote.id}`,
}));
/* Overload FunctionLint
@@ -357,7 +377,7 @@ const keymap = {
'v|enter': () => {
if (renoteCollapsed.value) {
renoteCollapsed.value = false;
} else if (appearNote.value.cw != null) {
} else if (appearNote.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
collapsed.value = !collapsed.value;
@@ -380,28 +400,31 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
if (props.mock) {
watch(() => props.note, (to) => {
note.value = deepClone(to);
}, { deep: true });
} else {
useNoteCapture({
rootEl: rootEl,
let subscribeManuallyToNoteCapture: () => void = () => { };
if (!props.mock) {
const { subscribe } = useNoteCapture({
note: appearNote,
pureNote: note,
isDeletedRef: isDeleted,
parentNote: note,
$note: $appearNote,
});
subscribeManuallyToNoteCapture = subscribe;
}
if (!props.mock) {
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 11,
});
@@ -412,19 +435,19 @@ if (!props.mock) {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@@ -435,7 +458,7 @@ if (!props.mock) {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@@ -448,10 +471,12 @@ function renote(viaKeyboard = false) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
subscribeManuallyToNoteCapture();
}
function reply(): void {
@@ -460,8 +485,8 @@ function reply(): void {
return;
}
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
@@ -470,7 +495,7 @@ function reply(): void {
function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
if (props.mock) {
@@ -478,8 +503,13 @@ function react(): void {
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@@ -492,7 +522,7 @@ function react(): void {
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
@@ -506,14 +536,23 @@ function react(): void {
if (props.mock) {
emit('reaction', reaction);
$appearNote.reactions[reaction] = 1;
$appearNote.reactionCount++;
$appearNote.myReaction = reaction;
return;
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -522,8 +561,8 @@ function react(): void {
}
}
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
function undoReact(): void {
const oldReaction = $appearNote.myReaction;
if (!oldReaction) return;
if (props.mock) {
@@ -532,15 +571,20 @@ function undoReact(targetNote: Misskey.entities.Note): void {
}
misskeyApi('notes/reactions/delete', {
noteId: targetNote.id,
noteId: appearNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if ($appearNote.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
undoReact();
}
}
@@ -556,7 +600,7 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} else {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
@@ -566,7 +610,7 @@ function showMenu(): void {
return;
}
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
@@ -575,7 +619,7 @@ async function clip(): Promise<void> {
return;
}
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(): void {
@@ -590,9 +634,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.value.id,
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
isDeleted.value = true;
},
};
}
@@ -601,23 +646,23 @@ function showRenoteMenu(): void {
type: 'link',
text: i18n.ts.renoteDetails,
icon: 'ti ti-info-circle',
to: notePage(note.value),
to: notePage(note),
};
if (isMyRenote) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
os.popupMenu([
renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value);
} else {
os.popupMenu([
renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value);
}
@@ -641,9 +686,8 @@ function focusAfter() {
function readPromo() {
misskeyApi('promo/read', {
noteId: appearNote.value.id,
noteId: appearNote.id,
});
isDeleted.value = true;
}
function emitUpdReaction(emoji: string, delta: number) {
@@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!muted"
v-show="!isDeleted"
v-if="!muted && !isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
tabindex="0"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
@@ -110,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
@@ -124,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/>
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
@@ -143,11 +160,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@@ -182,9 +199,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true">
@@ -199,7 +216,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<div v-else-if="muted" class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
@@ -211,13 +228,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { Keymap } from '@/utility/hotkey.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
@@ -242,9 +258,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
@@ -257,6 +273,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -267,29 +284,33 @@ const props = withDefaults(defineProps<{
const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) {
console.error(err);
}
}
note.value = result as Misskey.entities.Note;
note = result as Misskey.entities.Note;
});
}
const isRenote = Misskey.note.isPureRenote(note.value);
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);
const $appearNote = reactive({
reactions: appearNote.reactions,
reactionCount: appearNote.reactionCount,
reactionEmojis: appearNote.reactionEmojis,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
});
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
@@ -297,24 +318,29 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === note.id || noteId === appearNote.id) {
isDeleted.value = true;
}
});
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
url: `https://${host}/notes/${appearNote.id}`,
}));
const keymap = {
@@ -328,7 +354,7 @@ const keymap = {
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
if (appearNote.cw != null) {
showContent.value = !showContent.value;
}
},
@@ -341,41 +367,45 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
const renotesPagination = computed(() => ({
endpoint: 'notes/renotes',
limit: 10,
params: {
noteId: appearNote.value.id,
noteId: appearNote.id,
},
}));
const reactionsPagination = computed<Paging>(() => ({
const reactionsPagination = computed(() => ({
endpoint: 'notes/reactions',
limit: 10,
params: {
noteId: appearNote.value.id,
noteId: appearNote.id,
type: reactionTabType.value,
},
}));
useNoteCapture({
rootEl: rootEl,
const { subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
note: appearNote,
pureNote: note,
isDeletedRef: isDeleted,
parentNote: note,
$note: $appearNote,
});
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 11,
});
@@ -386,19 +416,19 @@ useTooltip(renoteButton, async (showing) => {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@@ -409,7 +439,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@@ -421,16 +451,19 @@ function renote() {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
const { menu } = getRenoteMenu({ note: note, renoteButton });
os.popupMenu(menu, renoteButton.value);
//
subscribeManuallyToNoteCapture();
}
function reply(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
@@ -439,12 +472,17 @@ function reply(): void {
function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@@ -457,7 +495,7 @@ function react(): void {
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
@@ -470,10 +508,15 @@ function react(): void {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -487,14 +530,19 @@ function undoReact(targetNote: Misskey.entities.Note): void {
if (!oldReaction) return;
misskeyApi('notes/reactions/delete', {
noteId: targetNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if (appearNote.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
undoReact(appearNote);
}
}
@@ -506,18 +554,18 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} else {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
}
function showRenoteMenu(): void {
@@ -529,9 +577,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.value.id,
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
isDeleted.value = true;
},
}], renoteTime.value);
}
@@ -549,7 +598,7 @@ const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
misskeyApi('notes/children', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 30,
}).then(res => {
replies.value = res;
@@ -560,9 +609,9 @@ const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
if (appearNote.value.replyId == null) return;
if (appearNote.replyId == null) return;
misskeyApi('notes/conversation', {
noteId: appearNote.value.replyId,
noteId: appearNote.replyId,
}).then(res => {
conversation.value = res.reverse();
});
@@ -4,13 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]">
<template v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<div v-if="i > 0 && isSeparatorNeeded(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
<div :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
</div>
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
</div>
<div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
@@ -25,30 +33,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/composables/use-pagination.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = defineProps<{
pagination: Paging;
const props = withDefaults(defineProps<{
pagination: PagingCtx;
noGap?: boolean;
disableAutoLoad?: boolean;
}>();
pullToRefresh?: boolean;
}>(), {
pullToRefresh: true,
});
const pagingComponent = useTemplateRef('pagingComponent');
useGlobalEvent('noteDeleted', (noteId) => {
pagingComponent.value?.paginator.removeItem(noteId);
});
function reload() {
return pagingComponent.value?.paginator.reload();
}
defineExpose({
pagingComponent,
reload,
});
</script>
<style lang="scss" module>
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
container-type: inline-size;
@@ -77,6 +93,18 @@ defineExpose({
}
}
.date {
display: flex;
font-size: 85%;
align-items: center;
justify-content: center;
gap: 1em;
opacity: 0.75;
padding: 8px 8px;
margin: 0 auto;
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.ad:empty {
display: none;
}
@@ -1,142 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template>
<template #default="{ items: notifications }">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass=" $style.transition_x_move"
tag="div"
>
<template v-for="(notification, i) in notifications" :key="notification.id">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template>
</component>
</template>
</MkPagination>
</component>
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
import * as Misskey from 'misskey-js';
import type { notificationTypes } from '@@/js/const.js';
import MkPagination from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
}>();
const pagingComponent = useTemplateRef('pagingComponent');
const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
endpoint: 'i/notifications-grouped' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
} : {
endpoint: 'i/notifications' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
});
function onNotification(notification) {
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
if (isMuted || window.document.visibilityState === 'visible') {
useStream().send('readNotification');
}
if (!isMuted) {
pagingComponent.value?.prepend(notification);
}
}
function reload() {
return new Promise<void>((res) => {
pagingComponent.value?.reload().then(() => {
res();
});
});
}
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
onMounted(() => {
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onUnmounted(() => {
if (connection) connection.dispose();
});
defineExpose({
reload,
});
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.item,
.item {
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notifications {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
</style>
+45 -454
View File
@@ -4,483 +4,74 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="fetching"/>
<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()">
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
:css="prefer.s.animation"
mode="out-in"
>
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="error" @retry="init()"/>
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="empty" key="_empty_">
<slot name="empty"><MkResult type="empty"/></slot>
</div>
<div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && more" key="_more_">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty"/></slot>
</div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
<div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
<slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
<div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
</div>
</div>
</Transition>
</Transition>
</component>
</template>
<script lang="ts">
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js';
import type { ComputedRef } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import { misskeyApi } from '@/utility/misskey-api.js';
<script lang="ts" setup>
import type { PagingCtx } from '@/composables/use-pagination.js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const SECOND_FETCH_LIMIT = 30;
const TOLERANCE = 16;
const APPEAR_MINIMUM_INTERVAL = 600;
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
endpoint: E;
limit: number;
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
/**
* 検索APIのようなページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/
noPaging?: boolean;
/**
* items 配列の中身を逆順にする(新しい方が最後)
*/
reversed?: boolean;
offsetMode?: boolean;
};
type MisskeyEntityMap = Map<string, MisskeyEntity>;
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
return entities.map(en => [en.id, en]);
}
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
return new Map([...map, ...arrayToEntries(entities)]);
}
</script>
<script lang="ts" setup>
import MkButton from '@/components/MkButton.vue';
import { usePagination } from '@/composables/use-pagination.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
disableAutoLoad?: boolean;
displayLimit?: number;
pullToRefresh?: boolean;
}>(), {
displayLimit: 20,
pullToRefresh: true,
});
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(ev: 'status', error: boolean): void;
}>();
const rootEl = useTemplateRef('rootEl');
//
const backed = ref(false);
const scrollRemove = ref<(() => void) | null>(null);
/**
* 表示するアイテムのソース
* 最新が0番目
*/
const items = ref<MisskeyEntityMap>(new Map());
/**
* タブが非アクティブなどの場合に更新を貯めておく
* 最新が0番目
*/
const queue = ref<MisskeyEntityMap>(new Map());
/**
* 初期化中かどうかtrueならMkLoadingで全て隠す
*/
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const preventAppearFetchMore = ref(false);
const preventAppearFetchMoreTimer = ref<number | null>(null);
const isBackTop = ref(false);
const empty = computed(() => items.value.size === 0);
const error = ref(false);
const {
enableInfiniteScroll,
} = prefer.r;
const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
const visibility = useDocumentVisibility();
let isPausingUpdate = false;
let timerForSetPause: number | null = null;
const BACKGROUND_PAUSE_WAIT_SEC = 10;
//
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
const scrollObserver = ref<IntersectionObserver>();
watch([() => props.pagination.reversed, scrollableElement], () => {
if (scrollObserver.value) scrollObserver.value.disconnect();
scrollObserver.value = new IntersectionObserver(entries => {
backed.value = entries[0].isIntersecting;
}, {
root: scrollableElement.value,
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
threshold: 0.01,
});
}, { immediate: true });
watch(rootEl, () => {
scrollObserver.value?.disconnect();
nextTick(() => {
if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
});
const paginator = usePagination({
ctx: props.pagination,
});
watch([backed, rootEl], () => {
if (!backed.value) {
if (!rootEl.value) return;
scrollRemove.value = props.pagination.reversed
? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
: onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
}
});
// ID
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
watch(queue, (a, b) => {
if (a.size === 0 && b.size === 0) return;
emit('queue', queue.value.size);
}, { deep: true });
watch(error, (n, o) => {
if (n === o) return;
emit('status', n);
});
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();
fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: props.pagination.limit ?? 10,
allowPartial: true,
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (res.length === 0 || props.pagination.noPaging) {
concatItems(res);
more.value = false;
} else {
if (props.pagination.reversed) moreFetching.value = true;
concatItems(res);
more.value = true;
}
error.value = false;
fetching.value = false;
}, err => {
error.value = true;
fetching.value = false;
});
function appearFetchMoreAhead() {
paginator.fetchNewer();
}
const reload = (): Promise<void> => {
return init();
};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: items.value.size,
} : {
untilId: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = _res => {
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
items.value = concatMapWithArray(items.value, _res);
return nextTick(() => {
if (scrollableElement.value) {
scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
});
};
if (res.length === 0) {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = false;
moreFetching.value = false;
}
} else {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
moreFetching.value = false;
}
}
}, err => {
moreFetching.value = false;
});
};
const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: items.value.size,
} : {
sinceId: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
if (res.length === 0) {
items.value = concatMapWithArray(items.value, res);
more.value = false;
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
}
moreFetching.value = false;
}, err => {
moreFetching.value = false;
});
};
/**
* AppearIntersectionObserverによってfetchMoreが呼ばれる場合
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
*/
const fetchMoreApperTimeoutFn = (): void => {
preventAppearFetchMore.value = false;
preventAppearFetchMoreTimer.value = null;
};
const fetchMoreAppearTimeout = (): void => {
preventAppearFetchMore.value = true;
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
};
const appearFetchMore = async (): Promise<void> => {
if (preventAppearFetchMore.value) return;
await fetchMore();
fetchMoreAppearTimeout();
};
const appearFetchMoreAhead = async (): Promise<void> => {
if (preventAppearFetchMore.value) return;
await fetchMoreAhead();
fetchMoreAppearTimeout();
};
const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
timerForSetPause = window.setTimeout(() => {
isPausingUpdate = true;
timerForSetPause = null;
},
BACKGROUND_PAUSE_WAIT_SEC * 1000);
} else { // 'visible'
if (timerForSetPause) {
window.clearTimeout(timerForSetPause);
timerForSetPause = null;
} else {
isPausingUpdate = false;
if (isHead()) {
executeQueue();
}
}
}
});
/**
* 最新のものとして1つだけアイテムを追加する
* ストリーミングから降ってきたアイテムはこれで追加する
* @param item アイテム
*/
function prepend(item: MisskeyEntity): void {
if (items.value.size === 0) {
items.value.set(item.id, item);
fetching.value = false;
return;
}
if (_DEV_) console.log(isHead(), isPausingUpdate);
if (isHead() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
function appearFetchMore() {
paginator.fetchOlder();
}
/**
* 新着アイテムをitemsの先頭に追加しdisplayLimitを適用する
* @param newItems 新しいアイテムの配列
*/
function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.size;
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
if (length >= props.displayLimit) more.value = true;
}
/**
* 古いアイテムをitemsの末尾に追加しdisplayLimitを適用する
* @param oldItems 古いアイテムの配列
*/
function concatItems(oldItems: MisskeyEntity[]) {
const length = oldItems.length + items.value.size;
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
if (length >= props.displayLimit) more.value = true;
}
function executeQueue() {
unshiftItems(Array.from(queue.value.values()));
queue.value = new Map();
}
function prependQueue(newItem: MisskeyEntity) {
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
}
/*
* アイテムを末尾に追加する使うの
*/
const appendItem = (item: MisskeyEntity): void => {
items.value.set(item.id, item);
};
const removeItem = (id: string) => {
items.value.delete(id);
queue.value.delete(id);
};
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
const item = items.value.get(id);
if (item) items.value.set(id, replacer(item));
const queueItem = queue.value.get(id);
if (queueItem) queue.value.set(id, replacer(queueItem));
};
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
scrollToBottom(rootEl.value!);
}
onBeforeMount(() => {
init().then(() => {
if (props.pagination.reversed) {
nextTick(() => {
window.setTimeout(toBottom, 800);
// scrollToBottommoreFetching
// more = true
window.setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
}
});
});
onBeforeUnmount(() => {
if (timerForSetPause) {
window.clearTimeout(timerForSetPause);
timerForSetPause = null;
}
if (preventAppearFetchMoreTimer.value) {
window.clearTimeout(preventAppearFetchMoreTimer.value);
preventAppearFetchMoreTimer.value = null;
}
scrollObserver.value?.disconnect();
});
defineExpose({
items,
queue,
backed: backed.value,
more,
reload,
prepend,
append: appendItem,
removeItem,
updateItem,
paginator: paginator,
});
</script>
+10 -8
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="{ [$style.done]: closed || isVoted }">
<ul :class="$style.choices">
<li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)">
<li v-for="(choice, i) in choices" :key="i" :class="$style.choice" @click="vote(i)">
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
@@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js';
const props = defineProps<{
noteId: string;
poll: NonNullable<Misskey.entities.Note['poll']>;
multiple: NonNullable<Misskey.entities.Note['poll']>['multiple'];
expiresAt: NonNullable<Misskey.entities.Note['poll']>['expiresAt'];
choices: NonNullable<Misskey.entities.Note['poll']>['choices'];
readOnly?: boolean;
emojiUrls?: Record<string, string>;
author?: Misskey.entities.UserLite;
@@ -48,9 +50,9 @@ const props = defineProps<{
const remaining = ref(-1);
const total = computed(() => sum(props.poll.choices.map(x => x.votes)));
const total = computed(() => sum(props.choices.map(x => x.votes)));
const closed = computed(() => remaining.value === 0);
const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted));
const isVoted = computed(() => !props.multiple && props.choices.some(c => c.isVoted));
const timer = computed(() => i18n.tsx._poll[
remaining.value >= 86400 ? 'remainingDays' :
remaining.value >= 3600 ? 'remainingHours' :
@@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
}));
//
if (props.poll.expiresAt) {
if (props.expiresAt) {
const tick = () => {
remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000);
remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000);
if (remaining.value === 0) {
showResult.value = true;
}
@@ -91,7 +93,7 @@ const vote = async (id) => {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }),
});
if (canceled) return;
@@ -99,7 +101,7 @@ const vote = async (id) => {
noteId: props.noteId,
choice: id,
});
if (!showResult.value) showResult.value = !props.poll.multiple;
if (!showResult.value) showResult.value = !props.multiple;
};
</script>
@@ -137,6 +137,7 @@ import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const $i = ensureSignin();
@@ -883,12 +884,15 @@ async function post(ev?: MouseEvent) {
}
posting.value = true;
misskeyApi('notes/create', postData, token).then(() => {
misskeyApi('notes/create', postData, token).then((res) => {
if (props.freezeAfterPosted) {
posted.value = true;
} else {
clear();
}
globalEvents.emit('notePosted', res.createdNote);
nextTick(() => {
deleteDraft();
emit('posted');
+5 -3
View File
@@ -48,7 +48,8 @@ function toggle(): void {
<style lang="scss" module>
.root {
position: relative;
display: inline-block;
display: inline-flex;
align-items: center;
text-align: left;
cursor: pointer;
padding: 7px 10px;
@@ -102,7 +103,8 @@ function toggle(): void {
}
.button {
position: absolute;
position: relative;
display: inline-block;
width: 14px;
height: 14px;
background: none;
@@ -126,7 +128,7 @@ function toggle(): void {
}
.label {
margin-left: 28px;
margin-left: 8px;
display: block;
line-height: 20px;
cursor: pointer;
+16 -3
View File
@@ -5,14 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { defineComponent, h, ref, watch } from 'vue';
import type { VNode } from 'vue';
import MkRadio from './MkRadio.vue';
import type { VNode } from 'vue';
export default defineComponent({
props: {
modelValue: {
required: false,
},
vertical: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const value = ref(props.modelValue);
@@ -34,7 +38,10 @@ export default defineComponent({
options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if'));
return () => h('div', {
class: 'novjtcto',
class: [
'novjtcto',
...(props.vertical ? ['vertical'] : []),
],
}, [
...(label ? [h('div', {
class: 'label',
@@ -71,7 +78,7 @@ export default defineComponent({
> .body {
display: flex;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
@@ -84,5 +91,11 @@ export default defineComponent({
display: none;
}
}
&.vertical {
> .body {
flex-direction: column;
}
}
}
</style>
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="label"></slot>
</div>
<div v-adaptive-border class="body">
<slot name="prefix"></slot>
<div ref="containerEl" class="container">
<div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
@@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchstart="onMousedown"
></div>
</div>
<slot name="suffix"></slot>
</div>
<div class="caption">
<slot name="caption"></slot>
@@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
$thumbWidth: 20px;
> .body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 12px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
> .container {
flex: 1;
position: relative;
height: $thumbHeight;
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, useTemplateRef } from 'vue';
import { useTooltip } from '@/use/use-tooltip.js';
import { useTooltip } from '@/composables/use-tooltip.js';
import * as os from '@/os.js';
const props = defineProps<{

Some files were not shown because too many files have changed in this diff Show More