merge: merge upstream for 2024.2.1 (!446)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/446

Closes #434

Approved-by: Amelia Yukii <amelia.yukii@shourai.de>
Approved-by: Marie <marie@kaifa.ch>
This commit is contained in:
Amelia Yukii 2024-03-02 16:36:49 +00:00
commit 46c664348e
137 changed files with 4524 additions and 2933 deletions

View file

@ -11,10 +11,33 @@
- -
--> -->
## 202x.x.x (unreleased)
## 2024.3.1
### General ### General
-
### Client
- Fix: 絵文字関係の不具合を修正 (#13485)
- 履歴に残っている or ピン留めされた絵文字がコントロールパネルより削除されていた際にリアクションデッキが表示できなくなる
- Unicode絵文字が履歴に残っている or ピン留めされているとリアクションデッキが表示できなくなる
- Fix: カスタム絵文字の画像読み込みに失敗した際はテキストではなくダミー画像を表示 #13487
### Server
-
## 2024.3.0
### General
- Enhance: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように
* デフォルトのメンション上限は20アカウントに設定されます。管理者はベースロールの設定で変更可能です。
* 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。
- Enhance: 通知がミュート、凍結を考慮するようになりました
- Enhance: サーバーごとにモデレーションノートを残せるように - Enhance: サーバーごとにモデレーションノートを残せるように
- Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加
- Enhance: 通知の受信設定に「フォロー中またはフォロワー」を追加
- Enhance: 通知の履歴をリセットできるように
- Fix: ダイレクトなノートに対してはダイレクトでしか返信できないように
### Client ### Client
- Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整 - Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整
@ -22,12 +45,24 @@
- Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正 - Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正
- Fix: チャートのラベルが消えている問題を修正 - Fix: チャートのラベルが消えている問題を修正
- Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正 - Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正
- Fix: 設定のバックアップ作成時に名前を入力しなかった場合、ローカライゼーションがおかしくなる問題を修正
- Fix: ページ`/admin/emojis`の絵文字編集ダイアログで「リアクションとして使えるロール」を追加する際に何も選択せずOKを押下すると画面が固まる問題を修正
- Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正 - Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正
- Fix: ユーザの情報のポップアップが消えなくなることがある問題を修正
### Server ### Server
- Enhance: エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました
- Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正 - Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正
- エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました - Fix: 破損した通知をクライアントに送信しないように
* 通知欄が無限にリロードされる問題が改善する可能性があります
- Fix: 禁止キーワードを含むートがDelayed Queueに追加されて再処理される問題を修正 - Fix: 禁止キーワードを含むートがDelayed Queueに追加されて再処理される問題を修正
- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正
- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正
- Fix: エンドポイント`admin/emoji/update`の各種修正
- 必須パラメータを`id`または`name`のいずれかのみに
- `id`の代わりに`name`で絵文字を指定可能に(`id`・`name`両指定時は従来通り`name`を変更する挙動)
- `category`および`licence`が指定なしの時勝手にnullに上書きされる挙動を修正
- Fix: 通知の受信設定で「相互フォロー」が正しく動作しない問題を修正
## 2024.2.0 ## 2024.2.0
@ -100,7 +135,6 @@
- Fix: エラー画像URLを設定した後解除するとデフォルトの画像が表示されない問題の修正 - Fix: エラー画像URLを設定した後解除するとデフォルトの画像が表示されない問題の修正
- Fix: MkCodeEditorで行がずれていってしまう問題の修正 - Fix: MkCodeEditorで行がずれていってしまう問題の修正
- Fix: Summaly proxy利用時にプレイヤーが動作しないことがあるのを修正 #13196 - Fix: Summaly proxy利用時にプレイヤーが動作しないことがあるのを修正 #13196
- Fix: ユーザの情報のポップアップが消えなくなることがある問題を修正
### Server ### Server
- Enhance: 連合先のレートリミットを超過した際にリトライするようになりました - Enhance: 連合先のレートリミットを超過した際にリトライするようになりました

View file

@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<a href="https://joinsharkey.org/"> <a href="https://joinsharkey.org/">
<img src="https://activitypub.software/TransFem-org/Sharkey/-/raw/develop/packages/frontend/assets/sharkey.svg" alt="Sharkey logo" style="border-radius:50%" width="400"/> <img src="https://activitypub.software/TransFem-org/Sharkey/-/raw/develop/packages/frontend/assets/sharkey.svg" alt="Sharkey logo" style="border-radius:50%" width="300"/>
</a> </a>
**🌎 **[Sharkey](https://joinsharkey.org/)** is an open source, decentralized social media platform that's free forever! 🚀** **🌎 **[Sharkey](https://joinsharkey.org/)** is an open source, decentralized social media platform that's free forever! 🚀**
@ -22,35 +22,24 @@
<a href="https://ko-fi.com/transfem"> <a href="https://ko-fi.com/transfem">
<img src="https://custom-icon-badges.herokuapp.com/badge/donate-F96854?logoColor=F96854&style=for-the-badge&logo=kofi&labelColor=363B40" alt="donate"/></a> <img src="https://custom-icon-badges.herokuapp.com/badge/donate-F96854?logoColor=F96854&style=for-the-badge&logo=kofi&labelColor=363B40" alt="donate"/></a>
---
</div>
<div>
<a href="https://joinsharkey.org/"><img src="https://cdn.shonk.social/files/b671c81c-58cf-4f13-bc96-af0b0c96c667.webp" align="right" height="520px"/></a> <a href="https://joinsharkey.org/"><img src="https://cdn.shonk.social/files/b671c81c-58cf-4f13-bc96-af0b0c96c667.webp" align="right" height="520px"/></a>
## ✨ Features
- **ActivityPub support**\
Not on Sharkey? No problem! Not only can Sharkey instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed!
- **Federated Backgrounds and Music status**\
You can add a background to your profile as well as a music status via ListenBrainz, show everyone what music you are currently listening too
- **Mastodon API**\
Sharkey implements the Mastodon API unlike normal Misskey
- **UI/UX Improvements**\
Sharkey makes some UI/UX improvements to make it easier to navigate
- **Sign-Up Approval**\
With Sharkey, you can enable sign-ups, subject to manual moderator approval and mandatory user-provided reasons for joining.
- **Rich Web UI**\
Sharkey has a rich and easy to use Web UI!
It is highly customizable, from changing the layout and adding widgets to making custom themes.
Furthermore, plugins can be created using AiScript, an original programming language.
- And much more...
</div> </div>
<div style="clear: both;"></div> ## Thanks
## Documentation <a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" height="30" alt="Chromatic" /></a>
Sharkey Documentation can be found at [Sharkey Documentation](https://docs.joinsharkey.org/docs/install/fresh/) Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
<a href="https://about.codecov.io/for/open-source/"><img src="https://about.codecov.io/wp-content/themes/codecov/assets/brand/sentry-cobranding/logos/codecov-by-sentry-logo.svg" height="30" alt="Codecov" /></a>
Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage.
<a href="https://crowdin.com/"><img src="https://user-images.githubusercontent.com/20679825/230709597-1299a011-171a-4294-a91e-355a9b37c672.svg" height="30" alt="Crowdin" /></a>
Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages.
<a href="https://hub.docker.com/"><img src="https://user-images.githubusercontent.com/20679825/230148221-f8e73a32-a49b-47c3-9029-9a15c3824f92.png" height="30" alt="Docker" /></a>
Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production.

View file

@ -1014,6 +1014,7 @@ renotes: "أعد النشر"
sourceCode: "الشفرة المصدرية" sourceCode: "الشفرة المصدرية"
flip: "اقلب" flip: "اقلب"
lastNDays: "آخر {n} أيام" lastNDays: "آخر {n} أيام"
surrender: "ألغِ"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "نجح إنشاء حسابك!" accountCreated: "نجح إنشاء حسابك!"
letsStartAccountSetup: "إذا كنت جديدًا لنعدّ حسابك الشخصي." letsStartAccountSetup: "إذا كنت جديدًا لنعدّ حسابك الشخصي."

View file

@ -1210,6 +1210,7 @@ hemisphere: "Geolocalització"
withSensitive: "Incloure notes amb fitxers sensibles" withSensitive: "Incloure notes amb fitxers sensibles"
userSaysSomethingSensitive: "La publicació de {name} conte material sensible" userSaysSomethingSensitive: "La publicació de {name} conte material sensible"
enableHorizontalSwipe: "Lliscar per canviar de pestanya" enableHorizontalSwipe: "Lliscar per canviar de pestanya"
surrender: "Cancel·lar "
_bubbleGame: _bubbleGame:
howToPlay: "Com es juga" howToPlay: "Com es juga"
_howToPlay: _howToPlay:

View file

@ -1098,6 +1098,7 @@ renotes: "Přeposlat"
sourceCode: "Zdrojový kód" sourceCode: "Zdrojový kód"
flip: "Otočit" flip: "Otočit"
lastNDays: "Posledních {n} dnů" lastNDays: "Posledních {n} dnů"
surrender: "Zrušit"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Váš účet byl úspěšně vytvořen!" accountCreated: "Váš účet byl úspěšně vytvořen!"
letsStartAccountSetup: "Pro začátek si nastavte svůj profil." letsStartAccountSetup: "Pro začátek si nastavte svůj profil."

View file

@ -1187,6 +1187,7 @@ decorate: "Dekorieren"
addMfmFunction: "MFM hinzufügen" addMfmFunction: "MFM hinzufügen"
sfx: "Soundeffekte" sfx: "Soundeffekte"
lastNDays: "Letzten {n} Tage" lastNDays: "Letzten {n} Tage"
surrender: "Abbrechen"
_announcement: _announcement:
forExistingUsers: "Nur für existierende Nutzer" forExistingUsers: "Nur für existierende Nutzer"
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."

View file

@ -1026,7 +1026,7 @@ remindMeLater: "Maybe later"
didYouLikeMisskey: "Have you taken a liking to Sharkey?" didYouLikeMisskey: "Have you taken a liking to Sharkey?"
pleaseDonate: "{host} uses the free software, Sharkey. We would highly appreciate your donations so development of Sharkey can continue!" pleaseDonate: "{host} uses the free software, Sharkey. We would highly appreciate your donations so development of Sharkey can continue!"
pleaseDonateInstance: "You can also support {host} directly by donating to your instance administration." pleaseDonateInstance: "You can also support {host} directly by donating to your instance administration."
correspondingSourceIsAvailable: "The corresponding source code is available from {anchor}." correspondingSourceIsAvailable: "The corresponding source code is available at {anchor}"
roles: "Roles" roles: "Roles"
role: "Role" role: "Role"
noRole: "Role not found" noRole: "Role not found"
@ -1083,6 +1083,8 @@ resetPasswordConfirm: "Really reset your password?"
sensitiveWords: "Sensitive words" sensitiveWords: "Sensitive words"
sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks." sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks."
sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression." sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
prohibitedWords: "Prohibited words"
prohibitedWordsDescription: "Enables an error when attempting to post a note containing the set word(s). Multiple words can be set, separated by a new line."
prohibitedWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression." prohibitedWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
hiddenTags: "Hidden hashtags" hiddenTags: "Hidden hashtags"
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines." hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
@ -1206,6 +1208,7 @@ showRenotes: "Show boosts"
edited: "Edited" edited: "Edited"
notificationRecieveConfig: "Notification Settings" notificationRecieveConfig: "Notification Settings"
mutualFollow: "Mutual follow" mutualFollow: "Mutual follow"
followingOrFollower: "Following or follower"
fileAttachedOnly: "Only notes with files" fileAttachedOnly: "Only notes with files"
showRepliesToOthersInTimeline: "Show replies to others in timeline" showRepliesToOthersInTimeline: "Show replies to others in timeline"
hideRepliesToOthersInTimeline: "Hide replies to others from timeline" hideRepliesToOthersInTimeline: "Hide replies to others from timeline"
@ -1215,10 +1218,10 @@ confirmShowRepliesAll: "This operation is irreversible. Would you really like to
confirmHideRepliesAll: "This operation is irreversible. Would you really like to hide replies to others from everyone you follow in your timeline?" confirmHideRepliesAll: "This operation is irreversible. Would you really like to hide replies to others from everyone you follow in your timeline?"
externalServices: "External Services" externalServices: "External Services"
sourceCode: "Source code" sourceCode: "Source code"
sourceCodeIsNotYetProvided: "The source code is not yet available. Please contact your administrator to fix this issue." sourceCodeIsNotYetProvided: "The source code is not yet available. Please contact your administrator to fix this problem."
repositoryUrl: "Repository URL" repositoryUrl: "Repository URL"
repositoryUrlDescription: "If there is a repository where the source code is publicly available, enter its URL. If you are using Sharkey as-is (without any changes to the source code), enter https://activitypub.software/TransFem-org/Sharkey/." repositoryUrlDescription: "If there is a repository where the source code is publicly available, enter its URL. If you are using Sharkey as-is (without any changes to the source code), enter https://activitypub.software/TransFem-org/Sharkey/."
repositoryUrlOrTarballRequired: "If you don't have a public repository, you'll need to provide a tarball instead. See .config/example.yml for details." repositoryUrlOrTarballRequired: "If you have not published a repository, you must provide a tarball instead. See .config/example.yml for more information."
feedback: "Feedback" feedback: "Feedback"
feedbackUrl: "Feedback URL" feedbackUrl: "Feedback URL"
impressum: "Impressum" impressum: "Impressum"
@ -1258,6 +1261,8 @@ soundWillBePlayed: "Sound will be played"
showReplay: "View Replay" showReplay: "View Replay"
replay: "Replay" replay: "Replay"
replaying: "Showing replay" replaying: "Showing replay"
endReplay: "Exit Replay"
copyReplayData: "Copy replay data"
ranking: "Ranking" ranking: "Ranking"
lastNDays: "Last {n} days" lastNDays: "Last {n} days"
backToTitle: "Go back to title" backToTitle: "Go back to title"
@ -1265,8 +1270,20 @@ hemisphere: "Where are you located"
withSensitive: "Include notes with sensitive files" withSensitive: "Include notes with sensitive files"
userSaysSomethingSensitive: "Post by {name} contains sensitive content" userSaysSomethingSensitive: "Post by {name} contains sensitive content"
enableHorizontalSwipe: "Swipe to switch tabs" enableHorizontalSwipe: "Swipe to switch tabs"
loading: "Loading"
surrender: "Cancel"
gameRetry: "Retry"
_bubbleGame: _bubbleGame:
howToPlay: "How to play" howToPlay: "How to play"
hold: "Hold"
_score:
score: "Score"
scoreYen: "Amount of money earned"
highScore: "High score"
maxChain: "Maximum number of chains"
yen: "{yen} Yen"
estimatedQty: "{qty} Pieces"
scoreSweets: "{onigiriQtyWithUnit} Onigiri"
_howToPlay: _howToPlay:
section1: "Adjust the position and drop the object into the box." section1: "Adjust the position and drop the object into the box."
section2: "When two objects of the same type touch each other, they will change into a different object and you score points." section2: "When two objects of the same type touch each other, they will change into a different object and you score points."
@ -1690,6 +1707,7 @@ _role:
ltlAvailable: "Can view the local timeline" ltlAvailable: "Can view the local timeline"
canPublicNote: "Can send public notes" canPublicNote: "Can send public notes"
canImportNotes: "Can import notes" canImportNotes: "Can import notes"
mentionMax: "Maximum number of mentions in a note"
canInvite: "Can create instance invite codes" canInvite: "Can create instance invite codes"
inviteLimit: "Invite limit" inviteLimit: "Invite limit"
inviteLimitCycle: "Invite limit cooldown" inviteLimitCycle: "Invite limit cooldown"
@ -1713,6 +1731,7 @@ _role:
canUseTranslator: "Translator usage" canUseTranslator: "Translator usage"
avatarDecorationLimit: "Maximum number of avatar decorations that can be applied" avatarDecorationLimit: "Maximum number of avatar decorations that can be applied"
_condition: _condition:
roleAssignedTo: "Assigned to manual roles"
isLocal: "Local user" isLocal: "Local user"
isRemote: "Remote user" isRemote: "Remote user"
createdLessThan: "Less than X has passed since account creation" createdLessThan: "Less than X has passed since account creation"
@ -1817,7 +1836,7 @@ _aboutMisskey:
source: "Source code" source: "Source code"
original: "Misskey original" original: "Misskey original"
original_sharkey: "Sharkey original" original_sharkey: "Sharkey original"
thisIsModifiedVersion: "{name} uses a modified version of the original Sharkey" thisIsModifiedVersion: "{name} uses a modified version of the original Sharkey."
translation: "Translate Sharkey" translation: "Translate Sharkey"
donate: "Donate to Misskey" donate: "Donate to Misskey"
donate_sharkey: "Donate to Sharkey" donate_sharkey: "Donate to Sharkey"
@ -2345,6 +2364,7 @@ _notification:
reactedBySomeUsers: "{n} users reacted" reactedBySomeUsers: "{n} users reacted"
renotedBySomeUsers: "Boosted by {n} users" renotedBySomeUsers: "Boosted by {n} users"
followedBySomeUsers: "Followed by {n} users" followedBySomeUsers: "Followed by {n} users"
flushNotification: "Clear notifications"
edited: "Note got edited" edited: "Note got edited"
_types: _types:
all: "All" all: "All"
@ -2444,6 +2464,7 @@ _moderationLogTypes:
resetPassword: "Password reset" resetPassword: "Password reset"
suspendRemoteInstance: "Remote instance suspended" suspendRemoteInstance: "Remote instance suspended"
unsuspendRemoteInstance: "Remote instance unsuspended" unsuspendRemoteInstance: "Remote instance unsuspended"
updateRemoteInstanceNote: "Moderation note updated for remote instance."
markSensitiveDriveFile: "File marked as sensitive" markSensitiveDriveFile: "File marked as sensitive"
unmarkSensitiveDriveFile: "File unmarked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive"
resolveAbuseReport: "Report resolved" resolveAbuseReport: "Report resolved"
@ -2651,7 +2672,8 @@ _reversi:
opponentHasSettingsChanged: "The opponent has changed their settings." opponentHasSettingsChanged: "The opponent has changed their settings."
allowIrregularRules: "Irregular rules (completely free)" allowIrregularRules: "Irregular rules (completely free)"
disallowIrregularRules: "No irregular rules" disallowIrregularRules: "No irregular rules"
showBoardLabels: "Display row and column numbering on the board"
useAvatarAsStone: "Turn stones into user avatars"
_offlineScreen: _offlineScreen:
title: "Offline - cannot connect to the server" title: "Offline - cannot connect to the server"
header: "Unable to connect to the server" header: "Unable to connect to the server"

View file

@ -1209,6 +1209,7 @@ hemisphere: "Región"
withSensitive: "Mostrar notas que contengan material sensible" withSensitive: "Mostrar notas que contengan material sensible"
userSaysSomethingSensitive: "La publicación de {name} contiene material sensible" userSaysSomethingSensitive: "La publicación de {name} contiene material sensible"
enableHorizontalSwipe: "Deslice para cambiar de pestaña" enableHorizontalSwipe: "Deslice para cambiar de pestaña"
surrender: "detener"
_bubbleGame: _bubbleGame:
howToPlay: "Cómo jugar" howToPlay: "Cómo jugar"
_howToPlay: _howToPlay:

View file

@ -380,8 +380,11 @@ hcaptcha: "hCaptcha"
enableHcaptcha: "Activer hCaptcha" enableHcaptcha: "Activer hCaptcha"
hcaptchaSiteKey: "Clé du site" hcaptchaSiteKey: "Clé du site"
hcaptchaSecretKey: "Clé secrète" hcaptchaSecretKey: "Clé secrète"
mcaptcha: "mCaptcha"
enableMcaptcha: "Activer mCaptcha"
mcaptchaSiteKey: "Clé du site" mcaptchaSiteKey: "Clé du site"
mcaptchaSecretKey: "Clé secrète" mcaptchaSecretKey: "Clé secrète"
mcaptchaInstanceUrl: "URL de l'instance de mCaptcha"
recaptcha: "reCAPTCHA" recaptcha: "reCAPTCHA"
enableRecaptcha: "Activer reCAPTCHA" enableRecaptcha: "Activer reCAPTCHA"
recaptchaSiteKey: "Clé du site" recaptchaSiteKey: "Clé du site"
@ -523,7 +526,7 @@ hideThisNote: "Masquer cette note"
showFeaturedNotesInTimeline: "Afficher les notes des Tendances dans le fil d'actualité" showFeaturedNotesInTimeline: "Afficher les notes des Tendances dans le fil d'actualité"
objectStorage: "Stockage d'objets" objectStorage: "Stockage d'objets"
useObjectStorage: "Utiliser le stockage d'objets" useObjectStorage: "Utiliser le stockage d'objets"
objectStorageBaseUrl: "Base URL" objectStorageBaseUrl: "URL de base"
objectStorageBaseUrlDesc: "Préfixe dURL utilisé pour construire lURL vers le référencement dobjet (média). Spécifiez son URL si vous utilisez un CDN ou un proxy, sinon spécifiez ladresse accessible au public selon le guide de service que vous allez utiliser. P.ex. 'https://<bucket>.s3.amazonaws.com' pour AWS S3 et 'https://storage.googleapis.com/<bucket>' pour GCS." objectStorageBaseUrlDesc: "Préfixe dURL utilisé pour construire lURL vers le référencement dobjet (média). Spécifiez son URL si vous utilisez un CDN ou un proxy, sinon spécifiez ladresse accessible au public selon le guide de service que vous allez utiliser. P.ex. 'https://<bucket>.s3.amazonaws.com' pour AWS S3 et 'https://storage.googleapis.com/<bucket>' pour GCS."
objectStorageBucket: "Bucket" objectStorageBucket: "Bucket"
objectStorageBucketDesc: "Veuillez spécifier le nom du compartiment utilisé sur le service configuré." objectStorageBucketDesc: "Veuillez spécifier le nom du compartiment utilisé sur le service configuré."
@ -628,6 +631,7 @@ medium: "Moyen"
small: "Petit" small: "Petit"
generateAccessToken: "Générer un jeton d'accès" generateAccessToken: "Générer un jeton d'accès"
permission: "Autorisations " permission: "Autorisations "
adminPermission: "Droits de l'administrateur"
enableAll: "Tout activer" enableAll: "Tout activer"
disableAll: "Tout désactiver" disableAll: "Tout désactiver"
tokenRequested: "Autoriser l'accès au compte" tokenRequested: "Autoriser l'accès au compte"
@ -1031,12 +1035,18 @@ nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non sensibles seulement (mentions j'
rolesAssignedToMe: "Rôles attribués à moi" rolesAssignedToMe: "Rôles attribués à moi"
resetPasswordConfirm: "Souhaitez-vous réinitialiser votre mot de passe ?" resetPasswordConfirm: "Souhaitez-vous réinitialiser votre mot de passe ?"
sensitiveWords: "Mots sensibles" sensitiveWords: "Mots sensibles"
sensitiveWordsDescription2: "Séparer par une espace pour créer une expression AND ; entourer de barres obliques pour créer une expression régulière."
prohibitedWords: "Mots interdits"
prohibitedWordsDescription2: "Séparer par une espace pour créer une expression AND ; entourer de barres obliques pour créer une expression régulière."
hiddenTags: "Hashtags cachés" hiddenTags: "Hashtags cachés"
hiddenTagsDescription: "Les hashtags définis ne s'afficheront pas dans les tendances. Vous pouvez définir plusieurs hashtags en faisant un saut de ligne." hiddenTagsDescription: "Les hashtags définis ne s'afficheront pas dans les tendances. Vous pouvez définir plusieurs hashtags en faisant un saut de ligne."
notesSearchNotAvailable: "La recherche de notes n'est pas disponible." notesSearchNotAvailable: "La recherche de notes n'est pas disponible."
license: "Licence" license: "Licence"
unfavoriteConfirm: "Vraiment supprimer des favoris ?"
myClips: "Mes clips" myClips: "Mes clips"
drivecleaner: "Nettoyeur du Disque" drivecleaner: "Nettoyeur du Disque"
retryAllQueuesNow: "Réessayer tous les fils d'attente immédiatement"
retryAllQueuesConfirmTitle: "Vraiment réessayer ?"
retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur." retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur."
enableChartsForRemoteUser: "Générer les graphiques pour les utilisateurs distants" enableChartsForRemoteUser: "Générer les graphiques pour les utilisateurs distants"
enableChartsForFederatedInstances: "Générer les graphiques pour les instances distantes" enableChartsForFederatedInstances: "Générer les graphiques pour les instances distantes"
@ -1046,6 +1056,8 @@ limitWidthOfReaction: "Limiter la largeur maximale des réactions et les affiche
noteIdOrUrl: "Identifiant de la note ou URL" noteIdOrUrl: "Identifiant de la note ou URL"
video: "Vidéo" video: "Vidéo"
videos: "Vidéos" videos: "Vidéos"
audio: "Audio"
audioFiles: "Fichiers audio"
dataSaver: "Économiseur de données" dataSaver: "Économiseur de données"
accountMigration: "Migration de compte" accountMigration: "Migration de compte"
accountMoved: "Cet·te utilisateur·rice a migré son compte vers :" accountMoved: "Cet·te utilisateur·rice a migré son compte vers :"
@ -1084,7 +1096,10 @@ specifyUser: "Spécifier l'utilisateur·rice"
failedToPreviewUrl: "Aperçu d'URL échoué" failedToPreviewUrl: "Aperçu d'URL échoué"
update: "Mettre à jour" update: "Mettre à jour"
rolesThatCanBeUsedThisEmojiAsReaction: "Rôles qui peuvent utiliser cet émoji comme réaction" rolesThatCanBeUsedThisEmojiAsReaction: "Rôles qui peuvent utiliser cet émoji comme réaction"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Si aucun rôle n'est spécifié, tout le monde peut utiliser cet émoji comme réaction."
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Il faut un rôle public."
cancelReactionConfirm: "Supprimez la réaction ?" cancelReactionConfirm: "Supprimez la réaction ?"
changeReactionConfirm: "Changer la réaction ?"
later: "Plus tard" later: "Plus tard"
goToMisskey: "Retour vers Misskey" goToMisskey: "Retour vers Misskey"
additionalEmojiDictionary: "Dictionnaires d'émojis additionnels" additionalEmojiDictionary: "Dictionnaires d'émojis additionnels"
@ -1110,11 +1125,13 @@ used: "Utilisé"
expired: "Expiré" expired: "Expiré"
doYouAgree: "Êtes-vous daccord ?" doYouAgree: "Êtes-vous daccord ?"
beSureToReadThisAsItIsImportant: "Assurez-vous de le lire; c'est important." beSureToReadThisAsItIsImportant: "Assurez-vous de le lire; c'est important."
iHaveReadXCarefullyAndAgree: "J'ai lu le contenu de « {x} » et donne mon accord."
dialog: "Dialogue" dialog: "Dialogue"
icon: "Avatar" icon: "Avatar"
forYou: "Pour vous" forYou: "Pour vous"
currentAnnouncements: "Annonces actuelles" currentAnnouncements: "Annonces actuelles"
pastAnnouncements: "Annonces passées" pastAnnouncements: "Annonces passées"
youHaveUnreadAnnouncements: "Il y a des annonces non lues."
replies: "Réponses" replies: "Réponses"
renotes: "Renotes" renotes: "Renotes"
loadReplies: "Inclure les réponses" loadReplies: "Inclure les réponses"
@ -1129,6 +1146,7 @@ showRenotes: "Afficher les renotes"
edited: "Modifié" edited: "Modifié"
notificationRecieveConfig: "Paramètres des notifications" notificationRecieveConfig: "Paramètres des notifications"
mutualFollow: "Abonnement mutuel" mutualFollow: "Abonnement mutuel"
fileAttachedOnly: "Avec fichiers joints seulement"
showRepliesToOthersInTimeline: "Afficher les réponses aux autres dans le fil" showRepliesToOthersInTimeline: "Afficher les réponses aux autres dans le fil"
hideRepliesToOthersInTimeline: "Masquer les réponses aux autres dans le fil" hideRepliesToOthersInTimeline: "Masquer les réponses aux autres dans le fil"
showRepliesToOthersInTimelineAll: "Afficher les réponses de toutes les personnes que vous suivez dans le fil" showRepliesToOthersInTimelineAll: "Afficher les réponses de toutes les personnes que vous suivez dans le fil"
@ -1137,6 +1155,11 @@ confirmShowRepliesAll: "Cette opération est irréversible. Voulez-vous vraiment
confirmHideRepliesAll: "Cette opération est irréversible. Voulez-vous vraiment masquer les réponses de toutes les personnes que vous suivez dans le fil ?" confirmHideRepliesAll: "Cette opération est irréversible. Voulez-vous vraiment masquer les réponses de toutes les personnes que vous suivez dans le fil ?"
externalServices: "Services externes" externalServices: "Services externes"
sourceCode: "Code source" sourceCode: "Code source"
sourceCodeIsNotYetProvided: "Le code source n'est pas encore disponible. Veuillez signaler ce problème aux administrateurs."
repositoryUrl: "URL du dépôt"
repositoryUrlDescription: "Entrez l'URL du dépôt où se trouve le code source ici. Si vous utilisez Misskey tel quel (sans changer le code source), entrez https://github.com/misskey-dev/misskey"
feedback: "Commentaires"
feedbackUrl: "URL pour les commentaires"
impressum: "Impressum" impressum: "Impressum"
impressumUrl: "URL de l'impressum" impressumUrl: "URL de l'impressum"
impressumDescription: "Dans certains pays comme l'Allemagne, il est obligatoire d'afficher les informations sur l'opérateur d'un site (un impressum)." impressumDescription: "Dans certains pays comme l'Allemagne, il est obligatoire d'afficher les informations sur l'opérateur d'un site (un impressum)."
@ -1164,7 +1187,32 @@ remainingN: "Restants : {n}"
overwriteContentConfirm: "Voulez-vous remplacer le contenu actuel ?" overwriteContentConfirm: "Voulez-vous remplacer le contenu actuel ?"
seasonalScreenEffect: "Effet d'écran saisonnier" seasonalScreenEffect: "Effet d'écran saisonnier"
decorate: "Décorer" decorate: "Décorer"
addMfmFunction: "Insérer MFM"
enableQuickAddMfmFunction: "Afficher le sélecteur de MFM avancé"
bubbleGame: "Jeu de bulles"
sfx: "Effets sonores"
soundWillBePlayed: "Le son sera joué"
showReplay: "Voir le replay"
replay: "Rediffusion"
replaying: "En cours de rediffusion"
endReplay: "Arrêter la rediffusion"
copyReplayData: "Copier les données de la rediffusion"
ranking: "Classement"
lastNDays: "Derniers {n} jours" lastNDays: "Derniers {n} jours"
backToTitle: "Retourner au titre"
hemisphere: "Votre région"
enableHorizontalSwipe: "Glisser pour changer d'onglet"
loading: "Chargement en cours"
surrender: "Annuler"
gameRetry: "Réessayer"
_bubbleGame:
howToPlay: "Comment jouer"
hold: "Réserver"
_score:
score: "Score"
scoreYen: "Montant gagné"
highScore: "Meilleur score"
yen: "{yen} yens"
_announcement: _announcement:
forExistingUsers: "Pour les utilisateurs existants seulement" forExistingUsers: "Pour les utilisateurs existants seulement"
readConfirmTitle: "Marquer comme lu ?" readConfirmTitle: "Marquer comme lu ?"
@ -1302,10 +1350,13 @@ _achievements:
title: "Régulier III" title: "Régulier III"
description: "Se connecter pour un total de 400 jours" description: "Se connecter pour un total de 400 jours"
_login500: _login500:
title: "Expert I"
description: "Se connecter pour un total de 500 jours" description: "Se connecter pour un total de 500 jours"
_login600: _login600:
title: "Expert II"
description: "Se connecter pour un total de 600 jours" description: "Se connecter pour un total de 600 jours"
_login700: _login700:
title: "Expert III"
description: "Se connecter pour un total de 700 jours" description: "Se connecter pour un total de 700 jours"
_login800: _login800:
description: "Se connecter pour un total de 800 jours" description: "Se connecter pour un total de 800 jours"
@ -1400,9 +1451,12 @@ _role:
description: "Description du rôle" description: "Description du rôle"
permission: "Rôle et autorisations" permission: "Rôle et autorisations"
assignTarget: "Attribuer" assignTarget: "Attribuer"
manual: "Manuel"
manualRoles: "Rôles manuels" manualRoles: "Rôles manuels"
conditional: "Conditionnel"
conditionalRoles: "Rôles conditionnels" conditionalRoles: "Rôles conditionnels"
condition: "Condition" condition: "Condition"
isConditionalRole: "Ceci est un rôle conditionnel."
isPublic: "Rôle public" isPublic: "Rôle public"
options: "Options" options: "Options"
policies: "Stratégies" policies: "Stratégies"

View file

@ -1209,6 +1209,7 @@ hemisphere: "Letak kamu tinggal"
withSensitive: "Lampirkan catatan dengan berkas sensitif" withSensitive: "Lampirkan catatan dengan berkas sensitif"
userSaysSomethingSensitive: "Postingan oleh {name} mengandung konten sensitif" userSaysSomethingSensitive: "Postingan oleh {name} mengandung konten sensitif"
enableHorizontalSwipe: "Geser untuk mengganti tab" enableHorizontalSwipe: "Geser untuk mengganti tab"
surrender: "Batalkan"
_bubbleGame: _bubbleGame:
howToPlay: "Cara bermain" howToPlay: "Cara bermain"
_howToPlay: _howToPlay:

16
locales/index.d.ts vendored
View file

@ -4845,6 +4845,10 @@ export interface Locale extends ILocale {
* *
*/ */
"mutualFollow": string; "mutualFollow": string;
/**
*
*/
"followingOrFollower": string;
/** /**
* *
*/ */
@ -6643,6 +6647,10 @@ export interface Locale extends ILocale {
* *
*/ */
"canImportNotes": string; "canImportNotes": string;
/**
*
*/
"mentionMax": string;
/** /**
* *
*/ */
@ -6733,6 +6741,10 @@ export interface Locale extends ILocale {
"avatarDecorationLimit": string; "avatarDecorationLimit": string;
}; };
"_condition": { "_condition": {
/**
*
*/
"roleAssignedTo": string;
/** /**
* *
*/ */
@ -9162,6 +9174,10 @@ export interface Locale extends ILocale {
* {n} * {n}
*/ */
"followedBySomeUsers": ParameterizedString<"n">; "followedBySomeUsers": ParameterizedString<"n">;
/**
*
*/
"flushNotification": string;
"_types": { "_types": {
/** /**
* *

View file

@ -995,6 +995,7 @@ neverShow: "Non mostrare più"
remindMeLater: "Rimanda" remindMeLater: "Rimanda"
didYouLikeMisskey: "Ti piace Misskey?" didYouLikeMisskey: "Ti piace Misskey?"
pleaseDonate: "Misskey è il software libero utilizzato su {host}. Offrendo una donazione è più facile continuare a svilupparlo!" pleaseDonate: "Misskey è il software libero utilizzato su {host}. Offrendo una donazione è più facile continuare a svilupparlo!"
correspondingSourceIsAvailable: ""
roles: "Ruoli" roles: "Ruoli"
role: "Ruolo" role: "Ruolo"
noRole: "Ruolo non trovato" noRole: "Ruolo non trovato"
@ -1172,6 +1173,12 @@ confirmShowRepliesAll: "Questa è una attività irreversibile. Vuoi davvero incl
confirmHideRepliesAll: "Questa è una attività irreversibile. Vuoi davvero escludere tutte le risposte dei following in TL?" confirmHideRepliesAll: "Questa è una attività irreversibile. Vuoi davvero escludere tutte le risposte dei following in TL?"
externalServices: "Servizi esterni" externalServices: "Servizi esterni"
sourceCode: "Codice sorgente" sourceCode: "Codice sorgente"
sourceCodeIsNotYetProvided: ""
repositoryUrl: "URL della repository"
repositoryUrlDescription: "Se esiste un repository il cui il codice sorgente è disponibile pubblicamente, inserisci il suo URL. Se stai utilizzando Misskey così com'è (senza alcuna modifica al codice sorgente), inserisci https://github.com/misskey-dev/misskey."
repositoryUrlOrTarballRequired: "Se non disponi di un repository pubblico, dovrai fornire un file tarball (tar). Vedere .config/example.yml per i dettagli."
feedback: "Feedback"
feedbackUrl: "URL di feedback"
impressum: "Dichiarazione di proprietà" impressum: "Dichiarazione di proprietà"
impressumUrl: "URL della dichiarazione di proprietà" impressumUrl: "URL della dichiarazione di proprietà"
impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)." impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)."
@ -1203,7 +1210,7 @@ addMfmFunction: "Aggiungi decorazioni"
enableQuickAddMfmFunction: "Attiva il selettore di funzioni MFM" enableQuickAddMfmFunction: "Attiva il selettore di funzioni MFM"
bubbleGame: "Bubble Game" bubbleGame: "Bubble Game"
sfx: "Effetti sonori" sfx: "Effetti sonori"
soundWillBePlayed: "Verrà riprodotto il suono" soundWillBePlayed: "Con musica ed effetti sonori"
showReplay: "Vedi i replay" showReplay: "Vedi i replay"
replay: "Replay" replay: "Replay"
replaying: "Replay in corso" replaying: "Replay in corso"
@ -1214,12 +1221,13 @@ hemisphere: "Geolocalizzazione"
withSensitive: "Mostra le Note con allegati espliciti" withSensitive: "Mostra le Note con allegati espliciti"
userSaysSomethingSensitive: "Note da {name} con allegati espliciti" userSaysSomethingSensitive: "Note da {name} con allegati espliciti"
enableHorizontalSwipe: "Trascina per invertire i tab" enableHorizontalSwipe: "Trascina per invertire i tab"
surrender: "Annulla"
_bubbleGame: _bubbleGame:
howToPlay: "Come giocare" howToPlay: "Come giocare"
_howToPlay: _howToPlay:
section1: "Regola la posizione e rilascia l'oggetto nella casella." section1: "Scegli la posizione e rilascia l'oggetto nel contenitore."
section2: "Ottieni un punteggio, quando due oggetti dello stesso tipo si toccano e si trasformano in un oggetto diverso." section2: "Se due oggetti dello stesso tipo si toccano, si trasformano in un oggetto diverso, aumentando il punteggio."
section3: "Se gli oggetti traboccano dalla scatola, il gioco finisce. Cerca di ottenere un punteggio elevato fondendo gli oggetti, evitando che escano dalla scatola!" section3: "Se gli oggetti escono dal limite superiore del contenitore, il gioco finisce. Cerca di ottenere un punteggio elevato fondendo gli oggetti, evitando che escano dal contenitore!"
_announcement: _announcement:
forExistingUsers: "Solo ai profili attuali" forExistingUsers: "Solo ai profili attuali"
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
@ -1760,6 +1768,8 @@ _aboutMisskey:
contributors: "Principali sostenitori" contributors: "Principali sostenitori"
allContributors: "Tutti i sostenitori" allContributors: "Tutti i sostenitori"
source: "Codice sorgente" source: "Codice sorgente"
original: "Originale"
thisIsModifiedVersion: "{name} sta usando una versione modificata diversa da Misskey originale."
translation: "Tradurre Misskey" translation: "Tradurre Misskey"
donate: "Sostieni Misskey" donate: "Sostieni Misskey"
morePatrons: "Apprezziamo sinceramente il supporto di tante altre persone. Grazie mille! 🥰" morePatrons: "Apprezziamo sinceramente il supporto di tante altre persone. Grazie mille! 🥰"

View file

@ -1207,6 +1207,7 @@ showRenotes: "ブーストを表示"
edited: "編集済み" edited: "編集済み"
notificationRecieveConfig: "通知の受信設定" notificationRecieveConfig: "通知の受信設定"
mutualFollow: "相互フォロー" mutualFollow: "相互フォロー"
followingOrFollower: "フォロー中またはフォロワー"
fileAttachedOnly: "ファイル付きのみ" fileAttachedOnly: "ファイル付きのみ"
showRepliesToOthersInTimeline: "TLに他の人への返信を含める" showRepliesToOthersInTimeline: "TLに他の人への返信を含める"
hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない"
@ -1715,6 +1716,7 @@ _role:
ltlAvailable: "ローカルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可" canPublicNote: "パブリック投稿の許可"
canImportNotes: "ノートのインポートが可能" canImportNotes: "ノートのインポートが可能"
mentionMax: "ノート内の最大メンション数"
canInvite: "サーバー招待コードの発行" canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数" inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔" inviteLimitCycle: "招待コードの発行間隔"
@ -1738,6 +1740,7 @@ _role:
canUseTranslator: "翻訳機能の利用" canUseTranslator: "翻訳機能の利用"
avatarDecorationLimit: "アイコンデコレーションの最大取付個数" avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"
isRemote: "リモートユーザー" isRemote: "リモートユーザー"
createdLessThan: "アカウント作成から~以内" createdLessThan: "アカウント作成から~以内"
@ -2418,6 +2421,7 @@ _notification:
reactedBySomeUsers: "{n}人がリアクションしました" reactedBySomeUsers: "{n}人がリアクションしました"
renotedBySomeUsers: "{n}人がブーストしました" renotedBySomeUsers: "{n}人がブーストしました"
followedBySomeUsers: "{n}人にフォローされました" followedBySomeUsers: "{n}人にフォローされました"
flushNotification: "通知の履歴をリセットする"
_types: _types:
all: "すべて" all: "すべて"
@ -2670,4 +2674,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "オフライン - サーバーに接続できません" title: "オフライン - サーバーに接続できません"
header: "サーバーに接続できません" header: "サーバーに接続できません"

View file

@ -993,6 +993,7 @@ neverShow: "今後表示しない"
remindMeLater: "また後で" remindMeLater: "また後で"
didYouLikeMisskey: "Sharkey気に入ってくれた" didYouLikeMisskey: "Sharkey気に入ってくれた"
pleaseDonate: "Sharkeyは{host}が使うとる無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。" pleaseDonate: "Sharkeyは{host}が使うとる無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。"
correspondingSourceIsAvailable: "{anchor}"
roles: "ロール" roles: "ロール"
role: "ロール" role: "ロール"
noRole: "ロールはありまへん" noRole: "ロールはありまへん"
@ -1210,6 +1211,7 @@ hemisphere: "住んでる地域"
withSensitive: "センシティブなファイルを含むノートを表示" withSensitive: "センシティブなファイルを含むノートを表示"
userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿" userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿"
enableHorizontalSwipe: "スワイプしてタブを切り替える" enableHorizontalSwipe: "スワイプしてタブを切り替える"
surrender: "やめとく"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"
_howToPlay: _howToPlay:
@ -2494,4 +2496,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "オフライン - サーバーに接続できひんで" title: "オフライン - サーバーに接続できひんで"
header: "サーバーに接続できへんわ" header: "サーバーに接続できへんわ"

View file

@ -640,6 +640,7 @@ icon: "아바타"
replies: "답하기" replies: "답하기"
renotes: "리노트" renotes: "리노트"
attach: "옇기" attach: "옇기"
surrender: "아이예"
_initialAccountSetting: _initialAccountSetting:
startTutorial: "길라잡이 하기" startTutorial: "길라잡이 하기"
_initialTutorial: _initialTutorial:

View file

@ -991,6 +991,7 @@ neverShow: "다시 보지 않기"
remindMeLater: "나중에 알림" remindMeLater: "나중에 알림"
didYouLikeMisskey: "Misskey가 마음에 드시나요?" didYouLikeMisskey: "Misskey가 마음에 드시나요?"
pleaseDonate: "Misskey는 {host} 서버의 무료 소프트웨어입니다. 앞으로도 개발을 이어 나가려면 후원이 절실히 필요합니다!" pleaseDonate: "Misskey는 {host} 서버의 무료 소프트웨어입니다. 앞으로도 개발을 이어 나가려면 후원이 절실히 필요합니다!"
correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 받아보실 수 있습니다."
roles: "역할" roles: "역할"
role: "역할" role: "역할"
noRole: "역할이 없습니다" noRole: "역할이 없습니다"
@ -1168,6 +1169,12 @@ confirmShowRepliesAll: "이 조작은 되돌릴 수 없습니다. 정말로 타
confirmHideRepliesAll: "이 조작은 되돌릴 수 없습니다. 정말로 타임라인에 현재 팔로우 중인 사람 전원의 답글이 나오지 않게 하시겠습니까?" confirmHideRepliesAll: "이 조작은 되돌릴 수 없습니다. 정말로 타임라인에 현재 팔로우 중인 사람 전원의 답글이 나오지 않게 하시겠습니까?"
externalServices: "외부 서비스" externalServices: "외부 서비스"
sourceCode: "소스 코드" sourceCode: "소스 코드"
sourceCodeIsNotYetProvided: "소스 코드를 아직 제공하지 않습니다. 이 문제를 해결하려면 관리자에게 문의해 주세요."
repositoryUrl: "저장소 URL"
repositoryUrlDescription: "소스 코드를 공개한 저장소가 있는 경우, 그 URL을 적습니다. Misskey를 원본 그대로 (소스 코드를 어떤 식으로도 변경하지 않고) 쓰고 있는 경우 https://github.com/misskey-dev/misskey 라고 적습니다."
repositoryUrlOrTarballRequired: "저장소를 공개하지 않은 경우 대신 tarball을 제공할 필요가 있습니다. 세부사항은 .config/example.yml을 참조해 주세요."
feedback: "피드백"
feedbackUrl: "피드백 URL"
impressum: "운영자 정보" impressum: "운영자 정보"
impressumUrl: "운영자 정보 URL" impressumUrl: "운영자 정보 URL"
impressumDescription: "독일 등의 일부 나라와 지역에서는 꼭 표시해야 합니다(Impressum)." impressumDescription: "독일 등의 일부 나라와 지역에서는 꼭 표시해야 합니다(Impressum)."
@ -1210,6 +1217,7 @@ hemisphere: "거주 지역"
withSensitive: "민감한 파일이 포함된 노트 보기" withSensitive: "민감한 파일이 포함된 노트 보기"
userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물" userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물"
enableHorizontalSwipe: "스와이프하여 탭 전환" enableHorizontalSwipe: "스와이프하여 탭 전환"
surrender: "그만두기"
_bubbleGame: _bubbleGame:
howToPlay: "설명" howToPlay: "설명"
_howToPlay: _howToPlay:
@ -1756,6 +1764,8 @@ _aboutMisskey:
contributors: "주요 기여자" contributors: "주요 기여자"
allContributors: "모든 기여자" allContributors: "모든 기여자"
source: "소스 코드" source: "소스 코드"
original: "원본"
thisIsModifiedVersion: "{name}에서는 원본 미스키를 수정한 버전을 사용하고 있습니다."
translation: "Misskey를 번역하기" translation: "Misskey를 번역하기"
donate: "Misskey에 기부하기" donate: "Misskey에 기부하기"
morePatrons: "이 외에도 다른 많은 분들이 도움을 주시고 계십니다. 감사합니다🥰" morePatrons: "이 외에도 다른 많은 분들이 도움을 주시고 계십니다. 감사합니다🥰"
@ -2371,6 +2381,7 @@ _moderationLogTypes:
resetPassword: "비밀번호 재설정" resetPassword: "비밀번호 재설정"
suspendRemoteInstance: "리모트 서버를 정지" suspendRemoteInstance: "리모트 서버를 정지"
unsuspendRemoteInstance: "리모트 서버의 정지를 해제" unsuspendRemoteInstance: "리모트 서버의 정지를 해제"
updateRemoteInstanceNote: "리모트 서버의 조정 기록 갱신"
markSensitiveDriveFile: "파일에 열람주의를 설정" markSensitiveDriveFile: "파일에 열람주의를 설정"
unmarkSensitiveDriveFile: "파일에 열람주의를 해제" unmarkSensitiveDriveFile: "파일에 열람주의를 해제"
resolveAbuseReport: "신고 처리" resolveAbuseReport: "신고 처리"

View file

@ -463,6 +463,7 @@ options: "Alternativ"
icon: "Avatar" icon: "Avatar"
replies: "Svar" replies: "Svar"
renotes: "Renote" renotes: "Renote"
surrender: "Avbryt"
_initialAccountSetting: _initialAccountSetting:
theseSettingsCanEditLater: "Du kan endre disse innstillingene senere." theseSettingsCanEditLater: "Du kan endre disse innstillingene senere."
_achievements: _achievements:

View file

@ -1011,6 +1011,7 @@ renotes: "Repostagens"
keepScreenOn: "Manter a tela do dispositivo sempre ligada" keepScreenOn: "Manter a tela do dispositivo sempre ligada"
flip: "Inversão" flip: "Inversão"
lastNDays: "Últimos {n} dias" lastNDays: "Últimos {n} dias"
surrender: "Cancelar"
_initialAccountSetting: _initialAccountSetting:
followUsers: "Siga usuários que lhe interessam para criar a sua linha do tempo." followUsers: "Siga usuários que lhe interessam para criar a sua linha do tempo."
_serverSettings: _serverSettings:

View file

@ -1085,6 +1085,7 @@ loadReplies: "Показать ответы"
sourceCode: "Исходный код" sourceCode: "Исходный код"
flip: "Переворот" flip: "Переворот"
lastNDays: "Последние {n} сут" lastNDays: "Последние {n} сут"
surrender: "Этот пост не может быть отменен."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Аккаунт успешно создан!" accountCreated: "Аккаунт успешно создан!"
letsStartAccountSetup: "Давайте настроим вашу учётную запись." letsStartAccountSetup: "Давайте настроим вашу учётную запись."

View file

@ -8,12 +8,12 @@ search: "ค้นหา"
notifications: "การเเจ้งเตือน" notifications: "การเเจ้งเตือน"
username: "ชื่อผู้ใช้" username: "ชื่อผู้ใช้"
password: "รหัสผ่าน" password: "รหัสผ่าน"
forgotPassword: "ลืมรหัสผ่านใช่ไหม" forgotPassword: "ลืมรหัสผ่าน"
fetchingAsApObject: "กำลังดึงข้อมูลจากสหพันธ์..." fetchingAsApObject: "กำลังดึงข้อมูลจากสหพันธ์..."
ok: "ตกลง" ok: "ตกลง"
gotIt: "เข้าใจแล้ว !" gotIt: "เข้าใจแล้ว !"
cancel: "ยกเลิก" cancel: "ยกเลิก"
noThankYou: "ไม่เป็นไร" noThankYou: "ไม่เอาดีกว่า"
enterUsername: "กรอกชื่อผู้ใช้" enterUsername: "กรอกชื่อผู้ใช้"
renotedBy: "รีโน้ตโดย {user}" renotedBy: "รีโน้ตโดย {user}"
noNotes: "ไม่มีโน้ต" noNotes: "ไม่มีโน้ต"
@ -31,16 +31,16 @@ login: "เข้าสู่ระบบ"
loggingIn: "กำลังเข้าสู่ระบบ" loggingIn: "กำลังเข้าสู่ระบบ"
logout: "ออกจากระบบ" logout: "ออกจากระบบ"
signup: "สร้างบัญชีผู้ใช้" signup: "สร้างบัญชีผู้ใช้"
uploading: "กำลังอัพโหลด..." uploading: "กำลังอัปโหลด"
save: "บันทึก" save: "บันทึก"
users: "ผู้ใช้งาน" users: "ผู้ใช้งาน"
addUser: "เพิ่มผู้ใช้" addUser: "เพิ่มผู้ใช้"
favorite: "รายการโปรด" favorite: "รายการโปรด"
favorites: "รายการโปรด" favorites: "รายการโปรด"
unfavorite: "ลบออกจากรายการโปรด" unfavorite: "ลบออกจากรายการโปรด"
favorited: "เพิ่มแล้วในรายการโปรด" favorited: "เพิ่มลงรายการโปรดแล้ว"
alreadyFavorited: "เพิ่มในรายการโปรดอยู่แล้ว" alreadyFavorited: "เพิ่มลงรายการโปรดอยู่แล้ว"
cantFavorite: "ไม่สามารถเพิ่มในรายการโปรดได้" cantFavorite: "ไม่สามารถเพิ่มลงรายการโปรดได้"
pin: "ปักหมุด" pin: "ปักหมุด"
unpin: "เลิกปักหมุด" unpin: "เลิกปักหมุด"
copyContent: "คัดลอกเนื้อหา" copyContent: "คัดลอกเนื้อหา"
@ -65,18 +65,18 @@ loadMore: "แสดงเพิ่มเติม"
showMore: "แสดงเพิ่มเติม" showMore: "แสดงเพิ่มเติม"
showLess: "ปิด" showLess: "ปิด"
youGotNewFollower: "ได้ติดตามคุณ" youGotNewFollower: "ได้ติดตามคุณ"
receiveFollowRequest: "คำขอผู้ติดตามที่ได้รับ" receiveFollowRequest: "มีคำขอติดตามส่งมาหา"
followRequestAccepted: "อนุมัติการติดตามแล้ว" followRequestAccepted: "การติดตามได้รับการอนุมัติแล้ว"
mention: "กล่าวถึง" mention: "กล่าวถึง"
mentions: "พูดถึง" mentions: "พูดถึง"
directNotes: "ไดเร็คโน้ต" directNotes: "โพสต์แบบไดเร็กต์"
importAndExport: "นำเข้า / ส่งออก" importAndExport: "นำเข้า / ส่งออก"
import: "นำเข้า" import: "นำเข้า"
export: "ส่งออก" export: "ส่งออก"
files: "ไฟล์" files: "ไฟล์"
download: "ดาวน์โหลด" download: "ดาวน์โหลด"
driveFileDeleteConfirm: "ต้องการลบไฟล์ “{name}” ใช่หรือไม่? โน้ตที่แนบมากับไฟล์นี้ก็จะถูกลบไปด้วย" driveFileDeleteConfirm: "ต้องการลบไฟล์ “{name}” ใช่ไหม? โน้ตที่แนบมากับไฟล์นี้ก็จะถูกลบไปด้วย"
unfollowConfirm: "ต้องการเลิกติดตาม {name}?" unfollowConfirm: "ต้องการเลิกติดตาม {name} ใช่ไหม?"
exportRequested: "คุณได้ร้องขอการส่งออก อาจใช้เวลาสักครู่ และจะถูกเพิ่มในไดรฟ์ของคุณเมื่อเสร็จสิ้นแล้ว" exportRequested: "คุณได้ร้องขอการส่งออก อาจใช้เวลาสักครู่ และจะถูกเพิ่มในไดรฟ์ของคุณเมื่อเสร็จสิ้นแล้ว"
importRequested: "คุณได้ร้องขอการนำเข้า การดำเนินการนี้อาจใช้เวลาสักครู่" importRequested: "คุณได้ร้องขอการนำเข้า การดำเนินการนี้อาจใช้เวลาสักครู่"
lists: "รายชื่อ" lists: "รายชื่อ"
@ -128,9 +128,9 @@ emojiPickerDisplay: "แสดงตัวจิ้มเอโมจิ"
overwriteFromPinnedEmojisForReaction: "เขียนทับการตั้งค่ารีแอคชั่น" overwriteFromPinnedEmojisForReaction: "เขียนทับการตั้งค่ารีแอคชั่น"
overwriteFromPinnedEmojis: "เขียนทับการตั้งค่าทั่วไป" overwriteFromPinnedEmojis: "เขียนทับการตั้งค่าทั่วไป"
reactionSettingDescription2: "ลากเพื่อจัดลำดับใหม่ คลิกที่เอโมจินั้นเพื่อลบ กด “+” เพื่อเพิ่ม" reactionSettingDescription2: "ลากเพื่อจัดลำดับใหม่ คลิกที่เอโมจินั้นเพื่อลบ กด “+” เพื่อเพิ่ม"
rememberNoteVisibility: "จดจำการตั้งค่าการมองเห็นตัวโน้ต" rememberNoteVisibility: "จำการตั้งค่าการมองเห็นโน้ต"
attachCancel: "ลบไฟล์ออกที่แนบมา" attachCancel: "ยกเลิกแนบไฟล์"
deleteFile: "ลบไฟล์ออกแล้ว" deleteFile: "ลบไฟล์ออก"
markAsSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน" markAsSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน"
unmarkAsSensitive: "ยกเลิกทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน" unmarkAsSensitive: "ยกเลิกทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน"
enterFileName: "พิมพ์ชื่อไฟล์" enterFileName: "พิมพ์ชื่อไฟล์"
@ -138,14 +138,14 @@ mute: "ปิดเสียง"
unmute: "ยกเลิกการปิดเสียง" unmute: "ยกเลิกการปิดเสียง"
renoteMute: "ปิดเสียงรีโน้ต" renoteMute: "ปิดเสียงรีโน้ต"
renoteUnmute: "เปิดเสียง รีโน้ต" renoteUnmute: "เปิดเสียง รีโน้ต"
block: "บล็อ" block: "บล็อ"
unblock: "เลิกปิดกั้น" unblock: "เลิกบล็อก"
suspend: "ถูกระงับ" suspend: "ระงับ"
unsuspend: "ยกเลิกระงับ" unsuspend: "เลิกระงับ"
blockConfirm: "ต้องการบล็อกบัญชีนี้?" blockConfirm: "ต้องการบล็อกบัญชีนี้ใช่ไหม?"
unblockConfirm: "ต้องการปลดบล็อคบัญชีนี้?" unblockConfirm: "ต้องการเลิกบล็อกบัญชีนี้ใช่ไหม?"
suspendConfirm: "ต้องการระงับบัญชีนี้?" suspendConfirm: "ต้องการระงับบัญชีนี้ใช่ไหม?"
unsuspendConfirm: "ต้องการยกเลิกการระงับบัญชีนี้?" unsuspendConfirm: "ต้องการยกเลิกการระงับบัญชีนี้ใช่ไหม?"
selectList: "เลือกรายชื่อ" selectList: "เลือกรายชื่อ"
editList: "แก้ไขรายชื่อ" editList: "แก้ไขรายชื่อ"
selectChannel: "เลือกช่อง" selectChannel: "เลือกช่อง"
@ -162,13 +162,13 @@ emojiUrl: "URL ของเอโมจิ"
addEmoji: "แทรกเอโมจิ" addEmoji: "แทรกเอโมจิ"
settingGuide: "การตั้งค่าที่แนะนำ" settingGuide: "การตั้งค่าที่แนะนำ"
cacheRemoteFiles: "แคชไฟล์ระยะไกล" cacheRemoteFiles: "แคชไฟล์ระยะไกล"
cacheRemoteFilesDescription: "เมื่อปิดใช้งานการตั้งค่านี้ ไฟล์ระยะไกลนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกล แต่กรณีการปิดใช้งานนี้จะช่วยลดปริมาณการใช้พื้นที่จัดเก็บข้อมูล แต่เพิ่มปริมาณการใช้งาน เพราะเนื่องจากจะไม่มีการสร้างภาพขนาดย่อ" cacheRemoteFilesDescription: "หากเปิดใช้งาน ไฟล์ระยะไกลจะถูกแคชไว้ ทำให้แสดงภาพเร็วขึ้น แต่ก็ใช้พื้นที่เก็บข้อมูลของเซิร์ฟเวอร์มากขึ้นเช่นกัน สำหรับขีดจำกัดที่ผู้ใช้ระยะไกลถูกแคชไว้จะขึ้นอยู่กับความจุไดรฟ์ตามบทบาทของเขา เมื่อเกินแล้วไฟล์เก่าจะถูกลบออกและเก็บเป็นลิงก์แทน หากปิดใช้งาน ไฟล์ระยะไกลจะถูกเก็บเป็นลิงก์ตั้งแต่ต้น เราแนะนำให้ตั้งค่า proxyRemoteFiles ใน default.yml เป็น true เพื่อสร้างธัมบ์เนลและปกป้องความเป็นส่วนตัวของผู้ใช้"
youCanCleanRemoteFilesCache: "คุณสามารถล้างแคชได้โดยคลิกที่ปุ่ม 🗑️ ในมุมมองการจัดการไฟล์" youCanCleanRemoteFilesCache: "คุณสามารถล้างแคชได้โดยคลิกที่ปุ่ม 🗑️ ในมุมมองการจัดการไฟล์"
cacheRemoteSensitiveFiles: "แคชไฟล์ระยะไกลที่มีเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน" cacheRemoteSensitiveFiles: "แคชไฟล์ระยะไกลที่มีเนื้อหาละเอียดอ่อน"
cacheRemoteSensitiveFilesDescription: "เมื่อปิดการใช้งานการตั้งค่านี้ ไฟล์ระยะไกลที่มีเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกลโดยที่ไม่มีการแคช" cacheRemoteSensitiveFilesDescription: "เมื่อปิดการใช้งานการตั้งค่านี้ ไฟล์ระยะไกลที่มีเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกลโดยที่ไม่มีการแคช"
flagAsBot: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นบอ" flagAsBot: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นบอ"
flagAsBotDescription: "การเปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยนักเขียนโปรแกรม หรือ ถ้าหากเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นๆ และเพื่อป้องกันการโต้ตอบแบบไม่มีที่สิ้นสุดกับบอทตัวอื่นๆ และยังสามารถปรับเปลี่ยนระบบภายในของ Misskey เพื่อปฏิบัติต่อบัญชีนี้เป็นบอท" flagAsBotDescription: "การเปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยนักเขียนโปรแกรม หรือ ถ้าหากเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นๆ และเพื่อป้องกันการโต้ตอบแบบไม่มีที่สิ้นสุดกับบอทตัวอื่นๆ และยังสามารถปรับเปลี่ยนระบบภายในของ Misskey เพื่อปฏิบัติต่อบัญชีนี้เป็นบอท"
flagAsCat: "เมี้ยววววววว!!!!!!!!!!! (ทำเครื่องหมายว่าบัญชีนี้เป็นแมว)" flagAsCat: "เมี้ยววววววววววววววว!!!!!!!!!!!"
flagAsCatDescription: "เหมียวเหมียวเมี้ยว??" flagAsCatDescription: "เหมียวเหมียวเมี้ยว??"
flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ไลน์" flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ไลน์"
flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้"
@ -180,7 +180,7 @@ showOnRemote: "ดูบนอินสแตนซ์ระยะไกล"
general: "ทั่วไป" general: "ทั่วไป"
wallpaper: "ภาพพื้นหลัง" wallpaper: "ภาพพื้นหลัง"
setWallpaper: "ตั้งค่าภาพพื้นหลัง" setWallpaper: "ตั้งค่าภาพพื้นหลัง"
removeWallpaper: "นำภาพพื้นหลังออก" removeWallpaper: "นำภาพพื้นหลังออก"
searchWith: "ค้นหา: {q}" searchWith: "ค้นหา: {q}"
youHaveNoLists: "คุณไม่มีรายชื่อใดๆ " youHaveNoLists: "คุณไม่มีรายชื่อใดๆ "
followConfirm: "ต้องการติดตาม {name} ใช่ไหม?" followConfirm: "ต้องการติดตาม {name} ใช่ไหม?"
@ -189,11 +189,11 @@ proxyAccountDescription: "บัญชีพร็อกซี่ คือ บ
host: "โฮสต์" host: "โฮสต์"
selectUser: "เลือกผู้ใช้งาน" selectUser: "เลือกผู้ใช้งาน"
recipient: "ผู้รับ" recipient: "ผู้รับ"
annotation: "ความคิดเห็น" annotation: "หมายเหตุประกอบ"
federation: "สหพันธ์" federation: "สหพันธ์"
instances: "อินสแตนซ์" instances: "อินสแตนซ์"
registeredAt: "จดทะเบียนที่" registeredAt: "วันที่ลงทะเบียน"
latestRequestReceivedAt: "ได้รับคำขอล่าสุดไปแล้ว" latestRequestReceivedAt: "คำขอล่าสุดที่ได้รับ"
latestStatus: "สถานะล่าสุด" latestStatus: "สถานะล่าสุด"
storageUsage: "พื้นที่จัดเก็บข้อมูลที่ใช้ไป" storageUsage: "พื้นที่จัดเก็บข้อมูลที่ใช้ไป"
charts: "โดดเด่น" charts: "โดดเด่น"
@ -215,10 +215,10 @@ disk: "ดิสก์"
instanceInfo: "ข้อมูลอินสแตนซ์" instanceInfo: "ข้อมูลอินสแตนซ์"
statistics: "สถิติการใช้งาน" statistics: "สถิติการใช้งาน"
clearQueue: "ล้างคิว" clearQueue: "ล้างคิว"
clearQueueConfirmTitle: "คุณแน่ใจแล้วหรอว่าต้องการที่จะล้างคิว?" clearQueueConfirmTitle: "ต้องการล้างคิวใช่ไหม?"
clearQueueConfirmText: "โพสต์ที่ยังค้างในคิวจะไม่ถูกจัดส่งอีกต่อไป โดยปกติแล้วการดำเนินการนี้ไม่จำเป็น" clearQueueConfirmText: "โพสต์ที่ยังค้างในคิวจะไม่ถูกจัดส่งอีกต่อไป โดยปกติแล้วการดำเนินการนี้ไม่จำเป็น"
clearCachedFiles: "ล้างแคช" clearCachedFiles: "ล้างแคช"
clearCachedFilesConfirm: "ต้องการลบไฟล์ระยะไกลที่แคชไว้ทั้งหมด?" clearCachedFilesConfirm: "ต้องการลบไฟล์ระยะไกลที่แคชไว้ทั้งหมดใช่ไหม?"
blockedInstances: "อินสแตนซ์ที่ถูกบล็อก" blockedInstances: "อินสแตนซ์ที่ถูกบล็อก"
blockedInstancesDescription: "ระบุชื่อโฮสต์ของอินสแตนซ์ที่คุณต้องการบล็อก อินสแตนซ์ที่อยู่ในรายการนั้นจะไม่สามารถพูดคุยกับอินสแตนซ์นี้ได้อีกต่อไป" blockedInstancesDescription: "ระบุชื่อโฮสต์ของอินสแตนซ์ที่คุณต้องการบล็อก อินสแตนซ์ที่อยู่ในรายการนั้นจะไม่สามารถพูดคุยกับอินสแตนซ์นี้ได้อีกต่อไป"
silencedInstances: "ปิดปากอินสแตนซ์นี้แล้ว" silencedInstances: "ปิดปากอินสแตนซ์นี้แล้ว"
@ -228,7 +228,7 @@ mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง"
blockedUsers: "ผู้ใช้ที่ถูกบล็อก" blockedUsers: "ผู้ใช้ที่ถูกบล็อก"
noUsers: "ไม่พบผู้ใช้งาน" noUsers: "ไม่พบผู้ใช้งาน"
editProfile: "แก้ไขโปรไฟล์" editProfile: "แก้ไขโปรไฟล์"
noteDeleteConfirm: "ต้องการลบโน้ตนี้?" noteDeleteConfirm: "ต้องการลบโน้ตนี้ใช่ไหม?"
pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก" pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก"
intro: "การติดตั้ง Misskey เสร็จสิ้นแล้วนะ! โปรดสร้างผู้ใช้งานที่เป็นผู้ดูแลระบบ" intro: "การติดตั้ง Misskey เสร็จสิ้นแล้วนะ! โปรดสร้างผู้ใช้งานที่เป็นผู้ดูแลระบบ"
done: "เสร็จสิ้น" done: "เสร็จสิ้น"
@ -237,7 +237,7 @@ preview: "แสดงตัวอย่าง"
default: "ค่าเริ่มต้น" default: "ค่าเริ่มต้น"
defaultValueIs: "ค่าเริ่มต้น: {value}" defaultValueIs: "ค่าเริ่มต้น: {value}"
noCustomEmojis: "ไม่มีเอโมจิ" noCustomEmojis: "ไม่มีเอโมจิ"
noJobs: "ไม่มีชิ้นงาน" noJobs: "ไม่มีงาน"
federating: "สหพันธ์" federating: "สหพันธ์"
blocked: "ถูกบล็อก" blocked: "ถูกบล็อก"
suspended: "ถูกระงับ" suspended: "ถูกระงับ"
@ -261,11 +261,11 @@ usernameOrUserId: "ชื่อผู้ใช้หรือรหัสผู
noSuchUser: "ไม่พบผู้ใช้" noSuchUser: "ไม่พบผู้ใช้"
lookup: "การค้นหา" lookup: "การค้นหา"
announcements: "ประกาศ" announcements: "ประกาศ"
imageUrl: "url รูปภาพ" imageUrl: "URL รูปภาพ"
remove: "ลบ" remove: "ลบ"
removed: "ถูกลบไปแล้ว" removed: "ถูกลบไปแล้ว"
removeAreYouSure: "ต้องการที่จะลบ “{x}” ออก?" removeAreYouSure: "ต้องการลบ “{x}” ใช่ไหม?"
deleteAreYouSure: "ต้องการลบ {x} หรือไม่คะ?" deleteAreYouSure: "ต้องการลบ “{x}” ใช่ไหม?"
resetAreYouSure: "รีเซ็ตเลยไหม?" resetAreYouSure: "รีเซ็ตเลยไหม?"
areYouSure: "แน่ใจแล้วใช่ไหมคะ?" areYouSure: "แน่ใจแล้วใช่ไหมคะ?"
saved: "บันทึกแล้ว" saved: "บันทึกแล้ว"
@ -275,7 +275,7 @@ keepOriginalUploading: "เก็บภาพต้นฉบับ"
keepOriginalUploadingDescription: "เก็บภาพต้นฉบับไว้เมื่ออัปโหลดภาพ หากปิด รูปภาพสำหรับการเผยแพร่ทางเว็บจะถูกสร้างขึ้นในเบราว์เซอร์เมื่อทำการอัปโหลด" keepOriginalUploadingDescription: "เก็บภาพต้นฉบับไว้เมื่ออัปโหลดภาพ หากปิด รูปภาพสำหรับการเผยแพร่ทางเว็บจะถูกสร้างขึ้นในเบราว์เซอร์เมื่อทำการอัปโหลด"
fromDrive: "จากไดรฟ์" fromDrive: "จากไดรฟ์"
fromUrl: "จาก URL" fromUrl: "จาก URL"
uploadFromUrl: "อัโหลดจาก URL" uploadFromUrl: "อัโหลดจาก URL"
uploadFromUrlDescription: "URL ของไฟล์ที่คุณต้องการอัปโหลด" uploadFromUrlDescription: "URL ของไฟล์ที่คุณต้องการอัปโหลด"
uploadFromUrlRequested: "ร้องขอการอัปโหลดแล้ว" uploadFromUrlRequested: "ร้องขอการอัปโหลดแล้ว"
uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์" uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์"
@ -289,7 +289,7 @@ agree: "ยอมรับ"
agreeBelow: "ฉันยอมรับถึงด้านล่าง" agreeBelow: "ฉันยอมรับถึงด้านล่าง"
basicNotesBeforeCreateAccount: "หมายเหตุสำคัญ" basicNotesBeforeCreateAccount: "หมายเหตุสำคัญ"
termsOfService: "เงื่อนไขการให้บริการ" termsOfService: "เงื่อนไขการให้บริการ"
start: "เริ่มต้น​ใช้งาน​" start: "เริ่ม"
home: "หน้าแรก" home: "หน้าแรก"
remoteUserCaution: "ข้อมูลอาจไม่สมบูรณ์เนื่องจากผู้ใช้รายนี้มาจากอินสแตนซ์ระยะไกล" remoteUserCaution: "ข้อมูลอาจไม่สมบูรณ์เนื่องจากผู้ใช้รายนี้มาจากอินสแตนซ์ระยะไกล"
activity: "กิจกรรม" activity: "กิจกรรม"
@ -333,11 +333,11 @@ rename: "เปลี่ยนชื่อ"
avatar: "ไอคอน" avatar: "ไอคอน"
banner: "แบนเนอร์" banner: "แบนเนอร์"
displayOfSensitiveMedia: "แสดงสื่อที่มีเนื้อหาละเอียดอ่อน" displayOfSensitiveMedia: "แสดงสื่อที่มีเนื้อหาละเอียดอ่อน"
whenServerDisconnected: "สูญเสียการเชื่อมต่อกับเซิร์ฟเวอร์" whenServerDisconnected: "เมื่อสูญเสียการเชื่อมต่อกับเซิร์ฟเวอร์"
disconnectedFromServer: "ถูกตัดการเชื่อมต่อออกจากเซิร์ฟเวอร์" disconnectedFromServer: "การเชื่อมต่อเซิร์ฟเวอร์ถูกตัด"
reload: "รีโหลด" reload: "รีโหลด"
doNothing: "เมิน" doNothing: "เมิน"
reloadConfirm: "นายต้องการรีเฟรชไทม์ไลน์หรือป่าว?" reloadConfirm: "รีโหลดเลยไหม?"
watch: "ดู" watch: "ดู"
unwatch: "หยุดดู" unwatch: "หยุดดู"
accept: "ยอมรับ" accept: "ยอมรับ"
@ -347,7 +347,7 @@ instanceName: "ชื่ออินสแตนซ์"
instanceDescription: "คำอธิบายอินสแตนซ์" instanceDescription: "คำอธิบายอินสแตนซ์"
maintainerName: "ผู้ดูแล" maintainerName: "ผู้ดูแล"
maintainerEmail: "อีเมลผู้ดูแลระบบ" maintainerEmail: "อีเมลผู้ดูแลระบบ"
tosUrl: "เงื่อนไขการให้บริการ URL" tosUrl: "URL เงื่อนไขการให้บริการ"
thisYear: "ปีนี้" thisYear: "ปีนี้"
thisMonth: "เดือนนี้" thisMonth: "เดือนนี้"
today: "วันนี้" today: "วันนี้"
@ -370,7 +370,7 @@ inMb: "เป็นเมกะไบต์"
bannerUrl: "URL รูปภาพแบนเนอร์" bannerUrl: "URL รูปภาพแบนเนอร์"
backgroundImageUrl: "URL ภาพพื้นหลัง" backgroundImageUrl: "URL ภาพพื้นหลัง"
basicInfo: "ข้อมูลเบื้องต้น" basicInfo: "ข้อมูลเบื้องต้น"
pinnedUsers: "ผู้ใช้งานที่ได้รับการปักหมุด" pinnedUsers: "ผู้ใช้ที่ถูกปักหมุด"
pinnedUsersDescription: "ป้อนชื่อผู้ใช้ที่คุณต้องการปักหมุดในหน้า “ค้นพบ” ฯลฯ คั่นด้วยการขึ้นบรรทัดใหม่" pinnedUsersDescription: "ป้อนชื่อผู้ใช้ที่คุณต้องการปักหมุดในหน้า “ค้นพบ” ฯลฯ คั่นด้วยการขึ้นบรรทัดใหม่"
pinnedPages: "หน้าเพจที่ปักหมุด" pinnedPages: "หน้าเพจที่ปักหมุด"
pinnedPagesDescription: "ป้อนเส้นทางของหน้าเพจที่คุณต้องการปักหมุดไว้ที่หน้าแรกของอินสแตนซ์นี้ คั่นด้วยขึ้นบรรทัดใหม่" pinnedPagesDescription: "ป้อนเส้นทางของหน้าเพจที่คุณต้องการปักหมุดไว้ที่หน้าแรกของอินสแตนซ์นี้ คั่นด้วยขึ้นบรรทัดใหม่"
@ -409,16 +409,16 @@ caseSensitive: "อักษรพิมพ์ใหญ่-พิมพ์เล
withReplies: "รวมตอบกลับ" withReplies: "รวมตอบกลับ"
connectedTo: "บัญชีดังต่อไปนี้มีการเชื่อมต่อกัน" connectedTo: "บัญชีดังต่อไปนี้มีการเชื่อมต่อกัน"
notesAndReplies: "โพสต์และการตอบกลับ" notesAndReplies: "โพสต์และการตอบกลับ"
withFiles: "รวบรวมไฟล์" withFiles: "ไฟล์"
silence: "ถูกปิดปาก" silence: "ถูกปิดปาก"
silenceConfirm: "ต้องการที่จะ ปิดปาก ผู้ใช้รายนี้?" silenceConfirm: "ต้องการปิดปากผู้ใช้รายนี้ใช่ไหม?"
unsilence: "ยกเลิกการปิดปาก" unsilence: "ยกเลิกการปิดปาก"
unsilenceConfirm: "ต้องการยกเลิกปิดปากผู้ใช้รายนี้?" unsilenceConfirm: "ต้องการเลิกปิดปากผู้ใช้รายนี้ใช่ไหม?"
popularUsers: "ผู้ใช้ที่เป็นที่นิยม" popularUsers: "ผู้ใช้ที่เป็นที่นิยม"
recentlyUpdatedUsers: "ผู้ใช้ที่เพิ่งใช้งานล่าสุด" recentlyUpdatedUsers: "ผู้ใช้ที่เพิ่งใช้งานล่าสุด"
recentlyRegisteredUsers: "ผู้ใช้ที่เข้าร่วมใหม่" recentlyRegisteredUsers: "ผู้ใช้ที่เข้าร่วมใหม่"
recentlyDiscoveredUsers: "ผู้ใช้ที่เพิ่งค้นพบใหม่" recentlyDiscoveredUsers: "ผู้ใช้ที่เพิ่งค้นพบใหม่"
exploreUsersCount: "มีผู้ใช้ {จำนวน} ราย" exploreUsersCount: "มีผู้ใช้ {count} ราย"
exploreFediverse: "สำรวจสหพันธ์" exploreFediverse: "สำรวจสหพันธ์"
popularTags: "แท็กยอดนิยม" popularTags: "แท็กยอดนิยม"
userList: "ลิสต์" userList: "ลิสต์"
@ -435,7 +435,7 @@ moderation: "การกลั่นกรอง"
moderationNote: "โน้ตการกลั่นกรอง" moderationNote: "โน้ตการกลั่นกรอง"
addModerationNote: "เพิ่มโน้ตการกลั่นกรอง" addModerationNote: "เพิ่มโน้ตการกลั่นกรอง"
moderationLogs: "ปูมการแก้ไข" moderationLogs: "ปูมการแก้ไข"
nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย"
securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน" securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน"
securityKey: "กุญแจความปลอดภัย" securityKey: "กุญแจความปลอดภัย"
lastUsed: "ใช้ล่าสุด" lastUsed: "ใช้ล่าสุด"
@ -449,7 +449,7 @@ reduceUiAnimation: "ลดภาพเคลื่อนไหว UI"
share: "แบ่งปัน" share: "แบ่งปัน"
notFound: "ไม่พบหน้าที่ต้องการ" notFound: "ไม่พบหน้าที่ต้องการ"
notFoundDescription: "ไม่พบหน้าตาม URL ที่ระบุ" notFoundDescription: "ไม่พบหน้าตาม URL ที่ระบุ"
uploadFolder: "โฟลเดอร์เริ่มต้นสำหรับอัโหลด" uploadFolder: "โฟลเดอร์เริ่มต้นสำหรับอัโหลด"
markAsReadAllNotifications: "ทำเครื่องหมายการแจ้งเตือนทั้งหมดว่าอ่านแล้ว" markAsReadAllNotifications: "ทำเครื่องหมายการแจ้งเตือนทั้งหมดว่าอ่านแล้ว"
markAsReadAllUnreadNotes: "ทำเครื่องหมายโน้ตทั้งหมดว่าอ่านแล้ว" markAsReadAllUnreadNotes: "ทำเครื่องหมายโน้ตทั้งหมดว่าอ่านแล้ว"
markAsReadAllTalkMessages: "ทำเครื่องหมายข้อความทั้งหมดว่าอ่านแล้ว" markAsReadAllTalkMessages: "ทำเครื่องหมายข้อความทั้งหมดว่าอ่านแล้ว"
@ -464,7 +464,7 @@ text: "ข้อความ"
enable: "เปิดใช้งาน" enable: "เปิดใช้งาน"
next: "ถัด​ไป" next: "ถัด​ไป"
retype: "พิมพ์รหัสอีกครั้ง" retype: "พิมพ์รหัสอีกครั้ง"
noteOf: "โน้ต โดย {user}" noteOf: "โน้ตของ {user}"
quoteAttached: "อ้างอิง" quoteAttached: "อ้างอิง"
quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?" quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?"
noMessagesYet: "ยังไม่มีข้อความ" noMessagesYet: "ยังไม่มีข้อความ"
@ -472,7 +472,7 @@ newMessageExists: "คุณมีข้อความใหม่"
onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ" onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ"
signinRequired: "กรุณาลงทะเบียนหรือลงชื่อเข้าใช้ก่อนดำเนินการต่อ" signinRequired: "กรุณาลงทะเบียนหรือลงชื่อเข้าใช้ก่อนดำเนินการต่อ"
invitations: "คำเชิญ" invitations: "คำเชิญ"
invitationCode: "รหัสคำเชิญ" invitationCode: "รหัสเชิญ"
checking: "Checking" checking: "Checking"
available: "พร้อมใช้งาน" available: "พร้อมใช้งาน"
unavailable: "ไม่พร้อมใช้" unavailable: "ไม่พร้อมใช้"
@ -557,7 +557,7 @@ popout: "ป๊อปเอาต์"
volume: "ระดับเสียง" volume: "ระดับเสียง"
masterVolume: "ระดับเสียงหลัก" masterVolume: "ระดับเสียงหลัก"
notUseSound: "ไม่ใช้เสียง" notUseSound: "ไม่ใช้เสียง"
useSoundOnlyWhenActive: "มีเสียงออกเฉพาะเมื่อ Misskey ทำงานอยู่" useSoundOnlyWhenActive: "มีเสียงออกเฉพาะตอนกำลังใช้ Misskey อยู่เท่านั้น"
details: "รายละเอียด" details: "รายละเอียด"
chooseEmoji: "เลือกเอโมจิ" chooseEmoji: "เลือกเอโมจิ"
unableToProcess: "ไม่สามารถดำเนินการให้เสร็จสิ้นได้" unableToProcess: "ไม่สามารถดำเนินการให้เสร็จสิ้นได้"
@ -570,8 +570,8 @@ installedDate: "วันที่ติดตั้ง"
lastUsedDate: "ใช้งานครั้งล่าสุด" lastUsedDate: "ใช้งานครั้งล่าสุด"
state: "สถานะ" state: "สถานะ"
sort: "เรียงลำดับ" sort: "เรียงลำดับ"
ascendingOrder: "เรียงจากน้อยไปมาก" ascendingOrder: "เรียงลำดับขึ้น"
descendingOrder: "เรียงจากมากไปน้อย" descendingOrder: "เรียงลำดับลง"
scratchpad: "Scratchpad" scratchpad: "Scratchpad"
scratchpadDescription: "Scratchpad เป็นการจัดเตรียมสภาพแวดล้อมสำหรับการทดลอง AiScript แต่คุณสามารถเขียน ดำเนินการ และตรวจสอบผลลัพธ์ของการโต้ตอบกับ Misskey มันได้ด้วยนะ" scratchpadDescription: "Scratchpad เป็นการจัดเตรียมสภาพแวดล้อมสำหรับการทดลอง AiScript แต่คุณสามารถเขียน ดำเนินการ และตรวจสอบผลลัพธ์ของการโต้ตอบกับ Misskey มันได้ด้วยนะ"
output: "เอาท์พุต" output: "เอาท์พุต"
@ -579,15 +579,15 @@ script: "สคริปต์"
disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ" disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ"
updateRemoteUser: "อัปเดตข้อมูลผู้ใช้งานระยะไกล" updateRemoteUser: "อัปเดตข้อมูลผู้ใช้งานระยะไกล"
unsetUserAvatar: "เลิกตั้งอวตาร" unsetUserAvatar: "เลิกตั้งอวตาร"
unsetUserAvatarConfirm: "ต้องการเลิกตั้งอวตาร?" unsetUserAvatarConfirm: "ต้องการเลิกตั้งอวตารใข่ไหม?"
unsetUserBanner: "เลิกตั้งแบนเนอร์" unsetUserBanner: "เลิกตั้งแบนเนอร์"
unsetUserBannerConfirm: "ต้องการเลิกตั้งแบนเนอร์?" unsetUserBannerConfirm: "ต้องการเลิกตั้งแบนเนอร์?"
deleteAllFiles: "ลบไฟล์ทั้งหมด" deleteAllFiles: "ลบไฟล์ทั้งหมด"
deleteAllFilesConfirm: "ต้องการลบไฟล์ทั้งหมดหรือไม่?" deleteAllFilesConfirm: "ต้องการลบไฟล์ทั้งหมดใช่ไหม?"
removeAllFollowing: "เลิกติดตามผู้ใช้ที่ติดตามทั้งหมด" removeAllFollowing: "เลิกติดตามผู้ใช้ที่ติดตามทั้งหมด"
removeAllFollowingDescription: "เลิกติดตามทั้งหมดจาก {host} โปรดเรียกใช้สิ่งนี้เมื่ออินสแตนซ์ดังกล่าวได้สูญหายตายจากไปแล้ว" removeAllFollowingDescription: "เลิกติดตามทั้งหมดจาก {host} โปรดเรียกใช้สิ่งนี้เมื่ออินสแตนซ์ดังกล่าวได้สูญหายตายจากไปแล้ว"
userSuspended: "ผู้ใช้รายนี้ถูกระงับการใช้งาน" userSuspended: "ผู้ใช้รายนี้ถูกระงับการใช้งาน"
userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" userSilenced: "ผู้ใช้รายนี้ถูกปิดปากอยู่"
yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ"
yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่"
tokenRevoked: "โทเค็นไม่ถูกต้อง" tokenRevoked: "โทเค็นไม่ถูกต้อง"
@ -600,7 +600,7 @@ addItem: "เพิ่มรายการ"
rearrange: "จัดใหม่" rearrange: "จัดใหม่"
relays: "รีเลย์" relays: "รีเลย์"
addRelay: "เพิ่มรีเลย์" addRelay: "เพิ่มรีเลย์"
inboxUrl: "อินบ็อกซ์ URL" inboxUrl: "URL ของอินบ็อกซ์"
addedRelays: "เพิ่มรีเลย์แล้ว" addedRelays: "เพิ่มรีเลย์แล้ว"
serviceworkerInfo: "ต้องเปิดใช้งานสำหรับการแจ้งเตือนแบบพุช" serviceworkerInfo: "ต้องเปิดใช้งานสำหรับการแจ้งเตือนแบบพุช"
deletedNote: "โน้ตที่ถูกลบ" deletedNote: "โน้ตที่ถูกลบ"
@ -617,7 +617,7 @@ description: "รายละเอียด"
describeFile: "เพิ่มแคปชั่น" describeFile: "เพิ่มแคปชั่น"
enterFileDescription: "ใส่แคปชั่น" enterFileDescription: "ใส่แคปชั่น"
author: "ผู้เขียน" author: "ผู้เขียน"
leaveConfirm: "คุณมีการเปลี่ยนแปลงที่ไม่ได้บันทึกนะ นายต้องการทิ้งการเปลี่ยนแปลงเหล่านั้นหรอ?" leaveConfirm: "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก ต้องการละทิ้งมันใช่ไหม?"
manage: "การจัดการ" manage: "การจัดการ"
plugins: "ปลั๊กอิน" plugins: "ปลั๊กอิน"
preferencesBackups: "ตั้งค่าการสำรองข้อมูล" preferencesBackups: "ตั้งค่าการสำรองข้อมูล"
@ -664,7 +664,7 @@ display: "แสดงผล"
copy: "คัดลอก" copy: "คัดลอก"
metrics: "เมตริก" metrics: "เมตริก"
overview: "ภาพรวม" overview: "ภาพรวม"
logs: "บันทึกข้อมูลระบบ" logs: "ปูม"
delayed: "ดีเลย์" delayed: "ดีเลย์"
database: "ฐานข้อมูล" database: "ฐานข้อมูล"
channel: "ช่อง" channel: "ช่อง"
@ -672,26 +672,26 @@ create: "สร้าง"
notificationSetting: "ตั้งค่าการแจ้งเตือน" notificationSetting: "ตั้งค่าการแจ้งเตือน"
notificationSettingDesc: "เลือกประเภทการแจ้งเตือนที่ต้องการจะแสดง" notificationSettingDesc: "เลือกประเภทการแจ้งเตือนที่ต้องการจะแสดง"
useGlobalSetting: "ใช้การตั้งค่าส่วนกลาง" useGlobalSetting: "ใช้การตั้งค่าส่วนกลาง"
useGlobalSettingDesc: "หากเปิดไว้ ระบบจะใช้การตั้งค่าการแจ้งเตือนของบัญชีของคุณ หากปิดอยู่ สามารถทำการกำหนดค่าแต่ละรายการได้นะ" useGlobalSettingDesc: "เมื่อเปิดใช้งาน ใช้การตั้งค่าการแจ้งเตือนจากบัญชีคุณ เมื่อปิดใช้งาน สามารถตั้งค่าได้อย่างอิสระ"
other: "อื่น ๆ" other: "อื่น ๆ"
regenerateLoginToken: "สร้างโทเค็นการเข้าสู่ระบบอีกครั้ง" regenerateLoginToken: "สร้างโทเค็นการเข้าสู่ระบบอีกครั้ง"
regenerateLoginTokenDescription: "สร้างโทเค็นใหม่ที่ใช้ภายในระหว่างการเข้าสู่ระบบ โดยตามหลักปกติแล้วการดำเนินการนี้ไม่จำเป็น หากสร้างใหม่ อุปกรณ์ทั้งหมดจะถูกออกจากระบบนะ" regenerateLoginTokenDescription: "สร้างโทเค็นใหม่ที่ใช้ภายในระหว่างการเข้าสู่ระบบ โดยตามหลักปกติแล้วการดำเนินการนี้ไม่จำเป็น หากสร้างใหม่ อุปกรณ์ทั้งหมดจะถูกออกจากระบบนะ"
theKeywordWhenSearchingForCustomEmoji: "คีย์เวิร์ดสำหรับใช้ค้นหาโมจิที่กำหนดเอง" theKeywordWhenSearchingForCustomEmoji: "คีย์เวิร์ดสำหรับใช้ค้นหาอโมจิที่กำหนดเอง"
setMultipleBySeparatingWithSpace: "คั่นหลายรายการด้วยช่องว่าง" setMultipleBySeparatingWithSpace: "คั่นหลายรายการด้วยช่องว่าง"
fileIdOrUrl: "ไฟล์ ID หรือ URL" fileIdOrUrl: "ID ของไฟล์ หรือ URL"
behavior: "พฤติกรรม" behavior: "พฤติกรรม"
sample: "ตัวอย่าง" sample: "ตัวอย่าง"
abuseReports: "รายงาน" abuseReports: "รายงาน"
reportAbuse: "รายงาน" reportAbuse: "รายงาน"
reportAbuseRenote: "รายงานรีโน้ต" reportAbuseRenote: "รายงานรีโน้ต"
reportAbuseOf: "รายงาน {ชื่อ}" reportAbuseOf: "รายงาน {name}"
fillAbuseReportDescription: "กรุณากรอกรายละเอียดเกี่ยวกับรายงานนี้ หากเป็นเรื่องเกี่ยวกับโน้ตโดยเฉพาะ ได้โปรดระบุ URL" fillAbuseReportDescription: "กรุณากรอกรายละเอียดเกี่ยวกับรายงานนี้ หากเป็นเรื่องเกี่ยวกับโน้ตโดยเฉพาะ ได้โปรดระบุ URL"
abuseReported: "เราได้ส่งรายงานของคุณไปแล้ว ขอบคุณมากๆนะ" abuseReported: "เราได้ส่งรายงานของคุณไปแล้ว ขอบคุณมากๆนะ"
reporter: "นักข่าว" reporter: "ผู้รายงาน"
reporteeOrigin: "รายงานต้นทาง" reporteeOrigin: "รายงานต้นทาง"
reporterOrigin: "นักข่าวต้นทาง" reporterOrigin: "แหล่งผู้รายงาน"
forwardReport: "ส่งต่อรายงานไปยังอินสแตนซ์ระยะไกล" forwardReport: "ส่งต่อรายงานไปยังอินสแตนซ์ระยะไกล"
forwardReportIsAnonymous: "แทนที่จะเป็นบัญชีของคุณ บัญชีระบบที่ไม่ระบุตัวตนจะแสดงเป็นนักข่าวที่อินสแตนซ์ระยะไกล" forwardReportIsAnonymous: "ข้อมูลของคุณจะไม่ปรากฏบนอินสแตนซ์ระยะไกลและปรากฏเป็นบัญชีระบบที่ไม่ระบุชื่อ"
send: "ส่ง" send: "ส่ง"
abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว" abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว"
openInNewTab: "เปิดในแท็บใหม่" openInNewTab: "เปิดในแท็บใหม่"
@ -699,7 +699,7 @@ openInSideView: "เปิดในมุมมองด้านข้าง"
defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น" defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น"
editTheseSettingsMayBreakAccount: "การแก้ไขการตั้งค่าเหล่านี้อาจทำให้บัญชีของคุณเสียหายนะ" editTheseSettingsMayBreakAccount: "การแก้ไขการตั้งค่าเหล่านี้อาจทำให้บัญชีของคุณเสียหายนะ"
instanceTicker: "ข้อมูลอินสแตนซ์ของโน้ต" instanceTicker: "ข้อมูลอินสแตนซ์ของโน้ต"
waitingFor: "กำลังรอคอย {x}" waitingFor: "กำลังรอ {x}"
random: "สุ่มค่า" random: "สุ่มค่า"
system: "ระบบ" system: "ระบบ"
switchUi: "สลับ UI" switchUi: "สลับ UI"
@ -709,7 +709,7 @@ createNew: "สร้างใหม่"
optional: "ไม่บังคับ" optional: "ไม่บังคับ"
createNewClip: "สร้างคลิปใหม่" createNewClip: "สร้างคลิปใหม่"
unclip: "ลบคลิป" unclip: "ลบคลิป"
confirmToUnclipAlreadyClippedNote: "โน้ตนี้เป็นส่วนหนึ่งของคลิป \"{name}\" แล้ว คุณต้องการลบออกจากคลิปนี้แทนอย่างงั้นหรอ?" confirmToUnclipAlreadyClippedNote: "โน้ตนี้เป็นส่วนหนึ่งของคลิป “{name}” อยู่แล้ว ต้องการนำมันออกจากคลิปใช่ไหม?"
public: "สาธารณะ" public: "สาธารณะ"
private: "ส่วนตัว" private: "ส่วนตัว"
i18nInfo: "Misskey กำลังได้รับการแปลเป็นภาษาต่างๆ โดยอาสาสมัคร คุณสามารถช่วยเหลือได้ที่ {link}" i18nInfo: "Misskey กำลังได้รับการแปลเป็นภาษาต่างๆ โดยอาสาสมัคร คุณสามารถช่วยเหลือได้ที่ {link}"
@ -732,7 +732,7 @@ driveFilesCount: "จำนวนไฟล์ไดรฟ์"
driveUsage: "การใช้พื้นที่ไดรฟ์" driveUsage: "การใช้พื้นที่ไดรฟ์"
noCrawle: "ปฏิเสธการจัดทำดัชนีของโปรแกรมรวบรวมข้อมูล" noCrawle: "ปฏิเสธการจัดทำดัชนีของโปรแกรมรวบรวมข้อมูล"
noCrawleDescription: "ขอให้เครื่องมือค้นหาไม่จัดทำดัชนีหน้าโปรไฟล์ โน้ต หน้าเพจ ฯลฯ" noCrawleDescription: "ขอให้เครื่องมือค้นหาไม่จัดทำดัชนีหน้าโปรไฟล์ โน้ต หน้าเพจ ฯลฯ"
lockedAccountInfo: "เว้นแต่ว่าคุณจะต้องตั้งค่าการเปิดเผยโน้ตเป็น \"ผู้ติดตามเท่านั้น\" โน้ตย่อของคุณจะปรากฏแก่ทุกคน ถึงแม้ว่าคุณจะเป็นกำหนดให้ผู้ติดตามต้องได้รับการอนุมัติด้วยตนเองก็ตาม" lockedAccountInfo: "แม้ว่าการอนุมัติการติดตามถูกเปิดใช้งานอยู่ทุกคนก็ยังคงสามารถเห็นโน้ตของคุณได้ เว้นแต่ว่าคุณจะเปลี่ยนการเปิดเผยโน้ตของคุณเป็น “เฉพาะผู้ติดตาม”"
alwaysMarkSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนเป็นค่าเริ่มต้น" alwaysMarkSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนเป็นค่าเริ่มต้น"
loadRawImages: "โหลดภาพต้นฉบับแทนการแสดงภาพขนาดย่อ" loadRawImages: "โหลดภาพต้นฉบับแทนการแสดงภาพขนาดย่อ"
disableShowingAnimatedImages: "ไม่ต้องเล่นภาพเคลื่อนไหว" disableShowingAnimatedImages: "ไม่ต้องเล่นภาพเคลื่อนไหว"
@ -768,29 +768,29 @@ nNotes: "{n} โน้ต"
sendErrorReports: "ส่งรายงานว่าข้อผิดพลาด" sendErrorReports: "ส่งรายงานว่าข้อผิดพลาด"
sendErrorReportsDescription: "เมื่อเปิดใช้งาน ข้อมูลข้อผิดพลาดโดยรายละเอียดนั้นจะถูกแชร์ให้กับ Misskey เมื่อเกิดปัญหา ซึ่งช่วยปรับปรุงคุณภาพของ Misskey\nซึ่งจะรวมถึงข้อมูล เช่น เวอร์ชั่นของระบบปฏิบัติการ เบราว์เซอร์ที่คุณใช้ กิจกรรมของคุณใน Misskey เป็นต้น" sendErrorReportsDescription: "เมื่อเปิดใช้งาน ข้อมูลข้อผิดพลาดโดยรายละเอียดนั้นจะถูกแชร์ให้กับ Misskey เมื่อเกิดปัญหา ซึ่งช่วยปรับปรุงคุณภาพของ Misskey\nซึ่งจะรวมถึงข้อมูล เช่น เวอร์ชั่นของระบบปฏิบัติการ เบราว์เซอร์ที่คุณใช้ กิจกรรมของคุณใน Misskey เป็นต้น"
myTheme: "ธีมของฉัน" myTheme: "ธีมของฉัน"
backgroundColor: "ภาพพื้นหลัง" backgroundColor: "สีพื้นหลัง"
accentColor: "รูปแบบสี" accentColor: "สีหลัก"
textColor: "สีข้อความ" textColor: "สีข้อความ"
saveAs: "บันทึกเป็น..." saveAs: "บันทึกเป็น..."
advanced: "ขั้นสูง" advanced: "ขั้นสูง"
advancedSettings: "การตั้งค่าขั้นสูง" advancedSettings: "การตั้งค่าขั้นสูง"
value: "ค่า" value: "ค่า"
createdAt: "สร้างเมื่อ" createdAt: "สร้างเมื่อ"
updatedAt: "อัพเดทล่าสุด" updatedAt: "อัปเดตล่าสุด"
saveConfirm: "บันทึกเปลี่ยนแปลงมั้ย?" saveConfirm: "บันทึกเปลี่ยนแปลงมั้ย?"
deleteConfirm: "ลบจริงๆเหรอ?" deleteConfirm: "ลบจริงๆเหรอ?"
invalidValue: "ค่านี้ไม่ถูกต้อง" invalidValue: "ค่านี้ไม่ถูกต้อง"
registry: "ทะเบียน" registry: "ทะเบียน"
closeAccount: "ปิด บัญชี" closeAccount: "ปิด บัญชี"
currentVersion: "เวอร์ชั่นปัจจุบัน" currentVersion: "เวอร์ชั่นปัจจุบัน"
latestVersion: "รุ่นปัจจุบัน" latestVersion: "เวอร์ชั่นล่าสุด"
youAreRunningUpToDateClient: "คุณกำลังใช้ไคลเอ็นต์เวอร์ชันใหม่ล่าสุดนะ" youAreRunningUpToDateClient: "คุณกำลังใช้ไคลเอ็นต์เวอร์ชันใหม่ล่าสุดนะ"
newVersionOfClientAvailable: "มีไคลเอ็นต์เวอร์ชันใหม่กว่าของคุณพร้อมใช้งานนะ" newVersionOfClientAvailable: "มีไคลเอ็นต์เวอร์ชันใหม่กว่าของคุณพร้อมใช้งานนะ"
usageAmount: "การใช้งาน" usageAmount: "การใช้งาน"
capacity: "ความจุ" capacity: "ความจุ"
inUse: "ใช้แล้ว" inUse: "ใช้แล้ว"
editCode: "แก้ไขโค้ด" editCode: "แก้ไขโค้ด"
apply: "ตกลง" apply: "นำไปใช้"
receiveAnnouncementFromInstance: "รับการแจ้งเตือนจากอินสแตนซ์นี้" receiveAnnouncementFromInstance: "รับการแจ้งเตือนจากอินสแตนซ์นี้"
emailNotification: "การแจ้งเตือนทางอีเมล" emailNotification: "การแจ้งเตือนทางอีเมล"
publish: "เผยแพร่" publish: "เผยแพร่"
@ -802,7 +802,7 @@ showingPastTimeline: "กำลังแสดงผลไทม์ไลน์
clear: "ล้าง" clear: "ล้าง"
markAllAsRead: "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว" markAllAsRead: "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว"
goBack: "ย้อนกลับ" goBack: "ย้อนกลับ"
unlikeConfirm: "เลิกถูกใจจริงๆ หรือ?" unlikeConfirm: "ต้องการเลิกถูกใจใช่ไหม?"
fullView: "มุมมองแบบเต็ม" fullView: "มุมมองแบบเต็ม"
quitFullView: "ออกจากมุมมองแบบเต็ม" quitFullView: "ออกจากมุมมองแบบเต็ม"
addDescription: "เพิ่มคำอธิบาย" addDescription: "เพิ่มคำอธิบาย"
@ -813,12 +813,12 @@ userInfo: "ข้อมูลผู้ใช้"
unknown: "ไม่ทราบสถานะ" unknown: "ไม่ทราบสถานะ"
onlineStatus: "สถานะออนไลน์" onlineStatus: "สถานะออนไลน์"
hideOnlineStatus: "ซ่อนสถานะออนไลน์" hideOnlineStatus: "ซ่อนสถานะออนไลน์"
hideOnlineStatusDescription: "การซ่อนสถานะออนไลน์ของคุณช่วยลดความสะดวกของคุณสมบัติบางอย่าง เช่น การค้นหา อ่ะนะ" hideOnlineStatusDescription: "การซ่อนสถานะออนไลน์อาจทำให้ฟังก์ชันบางอย่าง เช่น การค้นหา สะดวกน้อยลง"
online: "ออนไลน์" online: "ออนไลน์"
active: "ใช้งานอยู่" active: "ใช้งานอยู่"
offline: "ออฟไลน์" offline: "ออฟไลน์"
notRecommended: "ไม่แนะนำ" notRecommended: "ไม่แนะนำ"
botProtection: "การป้องกัน Bot (or AI)" botProtection: "การป้องกัน Bot"
instanceBlocking: "อินสแตนซ์ที่ถูกบล็อก" instanceBlocking: "อินสแตนซ์ที่ถูกบล็อก"
selectAccount: "เลือกบัญชี" selectAccount: "เลือกบัญชี"
switchAccount: "สลับบัญชีผู้ใช้" switchAccount: "สลับบัญชีผู้ใช้"
@ -880,7 +880,7 @@ itsOff: "ปิดใช้งาน"
on: "เปิด" on: "เปิด"
off: "ปิด" off: "ปิด"
emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร" emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร"
unread: "ไม่ได้อ่าน" unread: "ยังไม่ได้อ่าน"
filter: "กรอง" filter: "กรอง"
controlPanel: "แผงควบคุม" controlPanel: "แผงควบคุม"
manageAccounts: "จัดการบัญชี" manageAccounts: "จัดการบัญชี"
@ -888,13 +888,13 @@ makeReactionsPublic: "ตั้งค่าประวัติการรี
makeReactionsPublicDescription: "การทำเช่นนี้จะทำให้รายการรีแอคชั่นของคุณที่ผ่านมาทั้งหมดปรากฏต่อสาธารณะ" makeReactionsPublicDescription: "การทำเช่นนี้จะทำให้รายการรีแอคชั่นของคุณที่ผ่านมาทั้งหมดปรากฏต่อสาธารณะ"
classic: "คลาสสิค" classic: "คลาสสิค"
muteThread: "ปิดเสียงเธรด" muteThread: "ปิดเสียงเธรด"
unmuteThread: "เปิดเสียงเธรด" unmuteThread: "เลิกปิดเสียงเธรด"
followingVisibility: "การมองเห็นที่เรากำลังติดตาม" followingVisibility: "การมองเห็นที่เรากำลังติดตาม"
followersVisibility: "การมองเห็นผู้ที่กำลังติดตามเรา" followersVisibility: "การมองเห็นผู้ที่กำลังติดตามเรา"
continueThread: "ดูความต่อเนื่องเธรด" continueThread: "ดูความต่อเนื่องเธรด"
deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?" deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?"
incorrectPassword: "รหัสผ่านไม่ถูกต้อง" incorrectPassword: "รหัสผ่านไม่ถูกต้อง"
voteConfirm: "ยืนยันการโหวต “{choice}” ไหม?" voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?"
hide: "ซ่อน" hide: "ซ่อน"
useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ" useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ"
welcomeBackWithName: "ยินดีต้อนรับการกลับมานะคะ, คุณ{name}" welcomeBackWithName: "ยินดีต้อนรับการกลับมานะคะ, คุณ{name}"
@ -941,13 +941,13 @@ deleteAccount: "ลบบัญชี"
document: "เอกสาร" document: "เอกสาร"
numberOfPageCache: "จำนวนหน้าเพจที่แคช" numberOfPageCache: "จำนวนหน้าเพจที่แคช"
numberOfPageCacheDescription: "การเพิ่มจำนวนนี้จะช่วยเพิ่มความสะดวกให้กับผู้ใช้งาน แต่จะทำให้เซิร์ฟเวอร์โหลดมากขึ้นและต้องใช้หน่วยความจำมากขึ้นอีกด้วย" numberOfPageCacheDescription: "การเพิ่มจำนวนนี้จะช่วยเพิ่มความสะดวกให้กับผู้ใช้งาน แต่จะทำให้เซิร์ฟเวอร์โหลดมากขึ้นและต้องใช้หน่วยความจำมากขึ้นอีกด้วย"
logoutConfirm: "ต้องการออกจากระบบ?" logoutConfirm: "ต้องการออกจากระบบใช่ไหม?"
lastActiveDate: "ใช้งานล่าสุดที่" lastActiveDate: "ใช้งานล่าสุดเมื่อ"
statusbar: "แถบสถานะ" statusbar: "แถบสถานะ"
pleaseSelect: "ตัวเลือก" pleaseSelect: "ตัวเลือก"
reverse: "ย้อนกลับ" reverse: "พลิก"
colored: "สี" colored: "สี"
refreshInterval: "รอบการอัพเดต" refreshInterval: "ความถี่ในการอัปเดต"
label: "ป้ายชื่อ" label: "ป้ายชื่อ"
type: "รูปแบบ" type: "รูปแบบ"
speed: "ความเร็ว" speed: "ความเร็ว"
@ -974,8 +974,8 @@ unsubscribePushNotification: "ปิดการแจ้งเตือนแ
pushNotificationAlreadySubscribed: "การแจ้งเตือนแบบพุชได้เปิดใช้งานแล้ว" pushNotificationAlreadySubscribed: "การแจ้งเตือนแบบพุชได้เปิดใช้งานแล้ว"
pushNotificationNotSupported: "เบราว์เซอร์หรืออินสแตนซ์ของคุณนั้นไม่รองรับการแจ้งเตือนแบบพุช" pushNotificationNotSupported: "เบราว์เซอร์หรืออินสแตนซ์ของคุณนั้นไม่รองรับการแจ้งเตือนแบบพุช"
sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว"
sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ" sendPushNotificationReadMessageCaption: "อาจทำให้อุปกรณ์ของคุณใช้พลังงานมากขึ้น"
windowMaximize: "ขยายใหญ่สุดแล้ว" windowMaximize: "ขยายใหญ่สุด"
windowMinimize: "ย่อเล็กที่สุด" windowMinimize: "ย่อเล็กที่สุด"
windowRestore: "เลิกทำ" windowRestore: "เลิกทำ"
caption: "คำอธิบาย" caption: "คำอธิบาย"
@ -991,6 +991,7 @@ neverShow: "ไม่ต้องแสดงข้อความนี้อ
remindMeLater: "ไว้ครั้งหน้าแล้วกัน" remindMeLater: "ไว้ครั้งหน้าแล้วกัน"
didYouLikeMisskey: "คุณชอบ Misskey ไหม?" didYouLikeMisskey: "คุณชอบ Misskey ไหม?"
pleaseDonate: "Misskey เป็นซอฟต์แวร์ฟรีที่ใช้งานโดย {host} เราขอขอบคุณการสนับสนุนของคุณอย่างสูงเพื่อให้การพัฒนา Misskey สามารถดำเนินต่อไปได้!" pleaseDonate: "Misskey เป็นซอฟต์แวร์ฟรีที่ใช้งานโดย {host} เราขอขอบคุณการสนับสนุนของคุณอย่างสูงเพื่อให้การพัฒนา Misskey สามารถดำเนินต่อไปได้!"
correspondingSourceIsAvailable: "ซอร์สโค้ดที่เกี่ยวข้องมีอยู่ที่ {anchor}"
roles: "บทบาท" roles: "บทบาท"
role: "บทบาท" role: "บทบาท"
noRole: "ไม่พบบทบาท" noRole: "ไม่พบบทบาท"
@ -1059,7 +1060,7 @@ enableChartsForFederatedInstances: "สร้างแผนภูมิข้
showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต" showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต"
reactionsDisplaySize: "ขนาดของรีแอคชั่น" reactionsDisplaySize: "ขนาดของรีแอคชั่น"
limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง" limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง"
noteIdOrUrl: "โน้ต ID หรือ URL" noteIdOrUrl: "ID ของโน้ต หรือ URL"
video: "วีดีโอ" video: "วีดีโอ"
videos: "วีดีโอ" videos: "วีดีโอ"
audio: "เสียง" audio: "เสียง"
@ -1081,7 +1082,7 @@ leftBottom: "ล่างซ้าย"
rightBottom: "ล่างขวา" rightBottom: "ล่างขวา"
stackAxis: "ทิศทางการซ้อน" stackAxis: "ทิศทางการซ้อน"
vertical: "แนวตั้ง" vertical: "แนวตั้ง"
horizontal: "ด้านข้าง" horizontal: "แนวนอน"
position: "ตำแหน่ง" position: "ตำแหน่ง"
serverRules: "กฎของเซิร์ฟเวอร์" serverRules: "กฎของเซิร์ฟเวอร์"
pleaseConfirmBelowBeforeSignup: "โปรดยืนยันที่ด้านล่างก่อนสมัครใช้งาน" pleaseConfirmBelowBeforeSignup: "โปรดยืนยันที่ด้านล่างก่อนสมัครใช้งาน"
@ -1097,17 +1098,17 @@ thisChannelArchived: "ช่องนี้ถูกเก็บถาวรแ
displayOfNote: "การแสดงโน้ต" displayOfNote: "การแสดงโน้ต"
initialAccountSetting: "ตั้งค่าโปรไฟล์" initialAccountSetting: "ตั้งค่าโปรไฟล์"
youFollowing: "ติดตามแล้ว" youFollowing: "ติดตามแล้ว"
preventAiLearning: "ปฏิเสธการใช้งาน ในการเรียนรู้ของเครื่อง (Generative AI)" preventAiLearning: "ปฏิเสธการเรียนรู้ด้วย generative AI"
preventAiLearningDescription: "การส่งคำร้องขอโปรแกรมรวบรวมข้อมูลไม่ให้ใช้ข้อความที่โพสต์หรือรูปภาพ ฯลฯ ในชุดข้อมูลแมชชีนเลิร์นนิง (Predictive / Generative AI) สิ่งนี้นั้นทำได้โดยการเพิ่มแฟล็กการตอบสนอง \"noai\" HTML ให้กับเนื้อหาที่เกี่ยวข้อง แต่อย่างไรก็ตามแล้ว การป้องกันโดยสมบูรณ์นั้นไม่สามารถทำได้ผ่านแฟล็กนี้เนื่องจากอาจจะทำให้ถูกเพิกเฉยได้" preventAiLearningDescription: "ส่งคำร้องขอไม่ให้ใช้ ข้อความในโน้ตที่โพสต์, หรือเนื้อหารูปภาพ ฯลฯ ในการเรียนรู้ของเครื่อง(machine learning) / Predictive AI / Generative AI โดยการเพิ่มแฟล็ก “noai” ลง HTML-Response ให้กับเนื้อหาที่เกี่ยวข้อง แต่ทั้งนี้ ไม่ได้ป้องกัน AI จากการเรียนรู้ได้อย่างสมบูรณ์ เนื่องจากมี AI บางตัวเท่านั้นที่จะเคารพคำขอดังกล่าว"
options: "ตัวเลือกบทบาท" options: "ตัวเลือกบทบาท"
specifyUser: "ผู้ใช้เฉพาะ" specifyUser: "ผู้ใช้เฉพาะ"
failedToPreviewUrl: "ไม่สามารถดูตัวอย่างได้" failedToPreviewUrl: "ไม่สามารถดูตัวอย่างได้"
update: "อัปเดต" update: "อัปเดต"
rolesThatCanBeUsedThisEmojiAsReaction: "บทบาทที่สามารถใช้เอโมจินี้เป็นรีแอคชั่นได้" rolesThatCanBeUsedThisEmojiAsReaction: "บทบาทที่สามารถใช้เอโมจินี้เป็นรีแอคชั่นได้"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ถ้าหากไม่ได้ระบุบทบาท ทุกคนนั้นก็สามารถใช้เอโมจินี้เพื่อรีแอคชั่นได้นะ" rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "ถ้าหากไม่ได้ระบุบทบาท ใคร ๆ ก็สามารถใช้เอโมจินี้เพื่อรีแอคชั่นได้"
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "บทบาทเหล่านี้ต้องเป็นสาธารณะ" rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "บทบาทเหล่านี้ต้องเป็นสาธารณะ"
cancelReactionConfirm: "ต้องการลบรีแอคชั่นของคุณจริงๆหรอ?" cancelReactionConfirm: "ต้องการลบรีแอคชั่นใช่ไหม?"
changeReactionConfirm: "ต้องการเปลี่ยนรีแอคชั่นของคุณจริงๆหรอ?" changeReactionConfirm: "ต้องการเปลี่ยนรีแอคชั่นใช่ไหม?"
later: "ไว้ทีหลัง" later: "ไว้ทีหลัง"
goToMisskey: "ถึง Misskey" goToMisskey: "ถึง Misskey"
additionalEmojiDictionary: "พจนานุกรมเอโมจิเพิ่มเติม" additionalEmojiDictionary: "พจนานุกรมเอโมจิเพิ่มเติม"
@ -1116,20 +1117,20 @@ branding: "แบรนดิ้ง"
enableServerMachineStats: "เผยแพร่สถานะฮาร์ดแวร์ของเซิร์ฟเวอร์" enableServerMachineStats: "เผยแพร่สถานะฮาร์ดแวร์ของเซิร์ฟเวอร์"
enableIdenticonGeneration: "เปิดใช้งานผู้ใช้สร้างตัวระบุ" enableIdenticonGeneration: "เปิดใช้งานผู้ใช้สร้างตัวระบุ"
turnOffToImprovePerformance: "การปิดส่วนนี้สามารถเพิ่มประสิทธิภาพได้" turnOffToImprovePerformance: "การปิดส่วนนี้สามารถเพิ่มประสิทธิภาพได้"
createInviteCode: "สร้างคำเชิญ" createInviteCode: "สร้างรหัสเชิญ"
createWithOptions: "สร้างด้วยตัวเลือก" createWithOptions: "สร้างด้วยตัวเลือก"
createCount: "จำนวนการเชิญ" createCount: "จำนวนหัสเชิญ"
inviteCodeCreated: "สร้างคำเชิญแล้ว" inviteCodeCreated: "สร้างรหัสเชิญแล้ว"
inviteLimitExceeded: "คุณสร้างคำเชิญเกินถึงขีดจำกัดแล้วนะ" inviteLimitExceeded: "จำนวนรหัสเชิญที่สามารถสร้างได้ถึงขีดจำกัดแล้ว"
createLimitRemaining: "ขีดจำกัดการเชิญ: {limit} ที่เหลืออยู่" createLimitRemaining: "รหัสเชิญที่สามารถสร้างได้: เหลืออยู่ {limit} รหัส"
inviteLimitResetCycle: "ขีดจำกัดนี้จะถูกรีเซ็ตเป็น {limit} ที่ {time}." inviteLimitResetCycle: "สามารถสร้างรหัสเชิญได้อีกสูงสุด {limit} รหัส ภายใน {time}"
expirationDate: "วันที่หมดอายุ" expirationDate: "วันที่หมดอายุ"
noExpirationDate: "ไม่มีหมดอายุ" noExpirationDate: "ไม่มีหมดอายุ"
inviteCodeUsedAt: "รหัสคำเชิญใช้แล้วที่" inviteCodeUsedAt: "วันเวลาที่ใช้รหัสเชิญ"
registeredUserUsingInviteCode: "ใช้คำเชิญแล้วโดย" registeredUserUsingInviteCode: "ผู้ใช้ที่ใช้รหัสเชิญ"
waitingForMailAuth: "กำลังรอการยืนยันอีเมล" waitingForMailAuth: "กำลังรอการยืนยันอีเมล"
inviteCodeCreator: "สร้างการเชิญแล้วโดย" inviteCodeCreator: "ผู้ใช้ที่สร้างรหัสเชิญ"
usedAt: "ใช้แล้วที่" usedAt: "วันเวลาที่ถูกใช้"
unused: "ยังไม่ได้ใช้" unused: "ยังไม่ได้ใช้"
used: "ถูกใช้แล้ว" used: "ถูกใช้แล้ว"
expired: "หมดอายุแล้ว" expired: "หมดอายุแล้ว"
@ -1148,7 +1149,7 @@ renotes: "รีโน้ต"
loadReplies: "แสดงการตอบกลับ" loadReplies: "แสดงการตอบกลับ"
loadConversation: "แสดงบทสนทนา" loadConversation: "แสดงบทสนทนา"
pinnedList: "รายชื่อที่ปักหมุดไว้" pinnedList: "รายชื่อที่ปักหมุดไว้"
keepScreenOn: "เปิดหน้าจอไว้" keepScreenOn: "เปิดหน้าจออุปกรณ์ค้างไว้"
verifiedLink: "ความเป็นเจ้าของลิงก์ได้รับการยืนยันแล้ว" verifiedLink: "ความเป็นเจ้าของลิงก์ได้รับการยืนยันแล้ว"
notifyNotes: "แจ้งเตือนเกี่ยวกับโพสต์ใหม่" notifyNotes: "แจ้งเตือนเกี่ยวกับโพสต์ใหม่"
unnotifyNotes: "หยุดการแจ้งเตือนเกี่ยวกับโน้ตใหม่" unnotifyNotes: "หยุดการแจ้งเตือนเกี่ยวกับโน้ตใหม่"
@ -1159,6 +1160,7 @@ showRenotes: "แสดงรีโน้ต"
edited: "แก้ไขแล้ว" edited: "แก้ไขแล้ว"
notificationRecieveConfig: "การตั้งค่าการแจ้งเตือน" notificationRecieveConfig: "การตั้งค่าการแจ้งเตือน"
mutualFollow: "ติดตามซึ่งกันและกัน" mutualFollow: "ติดตามซึ่งกันและกัน"
followingOrFollower: "กำลังติดตามหรือผู้ติดตาม"
fileAttachedOnly: "เฉพาะโน้ตที่มีไฟล์เท่านั้น" fileAttachedOnly: "เฉพาะโน้ตที่มีไฟล์เท่านั้น"
showRepliesToOthersInTimeline: "แสดงการตอบกลับผู้อื่นลงในไทม์ไลน์" showRepliesToOthersInTimeline: "แสดงการตอบกลับผู้อื่นลงในไทม์ไลน์"
hideRepliesToOthersInTimeline: "ไม่แสดงการตอบกลับผู้อื่นลงในไทม์ไลน์" hideRepliesToOthersInTimeline: "ไม่แสดงการตอบกลับผู้อื่นลงในไทม์ไลน์"
@ -1168,6 +1170,12 @@ confirmShowRepliesAll: "การดำเนินการนี้ไม่
confirmHideRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการซ่อนการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ในไทม์ไลน์ของคุณหรือไม่?" confirmHideRepliesAll: "การดำเนินการนี้ไม่สามารถย้อนกลับได้ คุณต้องการซ่อนการตอบกลับผู้อื่นจากผู้ใช้ทุกคนที่คุณติดตามอยู่ในไทม์ไลน์ของคุณหรือไม่?"
externalServices: "บริการภายนอก" externalServices: "บริการภายนอก"
sourceCode: "ซอร์สโค้ด" sourceCode: "ซอร์สโค้ด"
sourceCodeIsNotYetProvided: "ซอร์สโค้ดยังไม่พร้อมใช้งาน โปรดติดต่อผู้ดูแลระบบของคุณเพื่อแก้ไขปัญหานี้"
repositoryUrl: "URL ของ repository"
repositoryUrlDescription: "หากมีที่เก็บซอร์สโค้ดที่เปิดเผยต่อสาธารณะ ให้ป้อน URL ที่เก็บซอร์สโค้ดนั้น แต่หากคุณใช้ Misskey ตามต้นฉบับ (ไม่มีการเปลี่ยนแปลงซอร์สโค้ด) ให้ป้อน https://github.com/misskey-dev/misskey"
repositoryUrlOrTarballRequired: "หากคุณไม่มี repository สาธารณะ คุณจะต้องจัดเตรียม tarball แทน ดู .config/example.yml สำหรับรายละเอียด"
feedback: "ฟีดแบ็ก"
feedbackUrl: "URLของฟีดแบ็ก"
impressum: "อิมเพรสชั่น" impressum: "อิมเพรสชั่น"
impressumUrl: "URL อิมเพรสชั่น" impressumUrl: "URL อิมเพรสชั่น"
impressumDescription: "การติดป้ายกำกับ (Impressum) มีผลบังคับใช้ในบางประเทศและภูมิภาค เช่น ประเทศเยอรมนี" impressumDescription: "การติดป้ายกำกับ (Impressum) มีผลบังคับใช้ในบางประเทศและภูมิภาค เช่น ประเทศเยอรมนี"
@ -1179,7 +1187,7 @@ attach: "แนบ"
detach: "นำออก" detach: "นำออก"
detachAll: "เอาออกทั้งหมด" detachAll: "เอาออกทั้งหมด"
angle: "แองเกิล" angle: "แองเกิล"
flip: "ย้อนกลับ" flip: "พลิก"
showAvatarDecorations: "แสดงตกแต่งอวตาร" showAvatarDecorations: "แสดงตกแต่งอวตาร"
releaseToRefresh: "ปล่อยเพื่อรีเฟรช" releaseToRefresh: "ปล่อยเพื่อรีเฟรช"
refreshing: "กำลังรีเฟรช..." refreshing: "กำลังรีเฟรช..."
@ -1203,15 +1211,29 @@ soundWillBePlayed: "จะมีการเล่นเอฟเฟกต์เ
showReplay: "ดูรีเพลย์" showReplay: "ดูรีเพลย์"
replay: "รีเพลย์" replay: "รีเพลย์"
replaying: "กำลังรีเพลย์" replaying: "กำลังรีเพลย์"
endReplay: "ออกจากรีเพลย์"
copyReplayData: "คัดลอกข้อมูลรีเพลย์"
ranking: "อันดับ" ranking: "อันดับ"
lastNDays: "ล่าสุด {n} วันที่แล้ว" lastNDays: "ล่าสุด {n} วันที่แล้ว"
backToTitle: "กลับไปหน้าไตเติ้ล" backToTitle: "กลับไปหน้าไตเติ้ล"
hemisphere: "พื้นที่ที่อาศัยอยู่" hemisphere: "พื้นที่ที่อาศัยอยู่"
withSensitive: "แสดงโน้ตที่มีไฟล์ที่ระบุว่ามีเนื้อหาละเอียดอ่อน" withSensitive: "แสดงโน้ตที่มีไฟล์เนื้อหาละเอียดอ่อน"
userSaysSomethingSensitive: "โพสต์ที่มีไฟล์เนื้อหาละเอียดอ่อนของ {name}" userSaysSomethingSensitive: "โพสต์ที่มีไฟล์เนื้อหาละเอียดอ่อนของ {name}"
enableHorizontalSwipe: "ปัดเพื่อสลับแท็บ" enableHorizontalSwipe: "ปัดเพื่อสลับแท็บ"
loading: "กำลังโหลด"
surrender: "ยอมแพ้"
gameRetry: "เริ่มเกมใหม่"
_bubbleGame: _bubbleGame:
howToPlay: "วิธีเล่น" howToPlay: "วิธีเล่น"
hold: "หยุดชั่วคราว"
_score:
score: "คะแนน"
scoreYen: "จำนวนเงินที่ได้รับ"
highScore: "คะแนนสูงสุด"
maxChain: "จำนวน chain สูงสุด"
yen: "{yen} เยน"
estimatedQty: "{qty} อัน"
scoreSweets: "โอนิงิริ {onigiriQtyWithUnit}"
_howToPlay: _howToPlay:
section1: "ขยับตำแหน่งและวางวัตถุลงในกล่อง" section1: "ขยับตำแหน่งและวางวัตถุลงในกล่อง"
section2: "เมื่อวัตถุประเภทเดียวกันมารวมกัน พวกมันจะกลายเป็นวัตถุใหม่และคุณจะได้รับคะแนน" section2: "เมื่อวัตถุประเภทเดียวกันมารวมกัน พวกมันจะกลายเป็นวัตถุใหม่และคุณจะได้รับคะแนน"
@ -1219,16 +1241,16 @@ _bubbleGame:
_announcement: _announcement:
forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น" forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น"
forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน" forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน"
needConfirmationToRead: "จำเป็นต้องยืนยันเพื่อทำเครื่องหมายบอกว่าอ่านแล้ว" needConfirmationToRead: "จำเป็นต้องยืนยันว่าอ่านแล้ว"
needConfirmationToReadDescription: "ข้อความแจ้งแยก ถ้าหากต้องการเพื่อยืนยันว่ากำลังทำเครื่องหมายประกาศนี้ว่าอ่านแล้วจะแสดงขึ้นถ้าหากเปิดใช้งาน การประกาศนั้นจะไม่รวมอยู่ในฟังก์ชั่นว่า \"ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว\"" needConfirmationToReadDescription: "กล่องโต้ตอบการยืนยันจะปรากฏขึ้นเมื่อจะทำเครื่องหมายว่าอ่านแล้ว นอกจากนี้ยังทำให้ประกาศนี้ยังไม่ถูกอ่านเมื่อใช้ฟังก์ชั่น “ทำเครื่องหมายฯ ทั้งหมดว่าอ่านแล้ว”"
end: "เก็บประกาศ" end: "เก็บประกาศ"
tooManyActiveAnnouncementDescription: "การมีประกาศที่ใช้งานมากเกินไปนั้นอาจจะทำให้ประสบการณ์ของผู้ใช้งานนั้นดูแย่ลง โปรดกรุณาพิจารณาการเก็บประกาศที่ล้าสมัยด้วยนะค่ะ" tooManyActiveAnnouncementDescription: "การมีประกาศที่ใช้งานมากเกินไปนั้นอาจจะทำให้ประสบการณ์ของผู้ใช้งานนั้นดูแย่ลง โปรดกรุณาพิจารณาการเก็บประกาศที่ล้าสมัยด้วยนะค่ะ"
readConfirmTitle: "ทำเครื่องหมายบอกว่าอ่านแล้วเลยมั้ย?" readConfirmTitle: "ทำเครื่องหมายว่าอ่านแล้วเลยไหม?"
readConfirmText: "การดำเนินการนี้จะทำเครื่องหมายเนื้อหาของ \"{title}\" บอกว่าอ่านแล้วนะ" readConfirmText: "จะทำเครื่องหมายใส่ “{title}” ว่าอ่านแล้ว"
shouldNotBeUsedToPresentPermanentInfo: "เราขอแนะนำให้ใช้ประกาศเพื่อโพสต์ข้อมูลแบบ flow มากกว่าข้อมูลแบบ stock เนื่องจากมีแนวโน้มที่จะส่งผลเสียต่อ UX โดยเฉพาะสำหรับผู้ใช้ใหม่" shouldNotBeUsedToPresentPermanentInfo: "เราขอแนะนำให้ใช้ประกาศเพื่อโพสต์ข้อมูลแบบ flow มากกว่าข้อมูลแบบ stock เนื่องจากมีแนวโน้มที่จะส่งผลเสียต่อ UX โดยเฉพาะสำหรับผู้ใช้ใหม่"
dialogAnnouncementUxWarn: "เราขอแนะนำให้ใช้ด้วยความระมัดระวัง เนื่องจากการแจ้งเตือนแบบกล่องโต้ตอบตั้งแต่ 2 รายการขึ้นไปพร้อมกันอาจส่งผลเสียต่อ UX ได้อย่างมาก" dialogAnnouncementUxWarn: "เราขอแนะนำให้ใช้ด้วยความระมัดระวัง เนื่องจากการแจ้งเตือนแบบกล่องโต้ตอบตั้งแต่ 2 รายการขึ้นไปพร้อมกันอาจส่งผลเสียต่อ UX ได้อย่างมาก"
silence: "ไม่มีการแจ้งเตือน" silence: "ไม่มีการแจ้งเตือน"
silenceDescription: "หากเปิดใช้งาน จะไม่ได้แจ้งเตือนประกาศนี้ และผู้ใช้จะไม่จำเป็นต้องอ่าน" silenceDescription: "หากเปิดใช้งาน จะไม่มีการแจ้งเตือนประกาศนี้ และผู้ใช้จะไม่จำเป็นต้องทำเครื่องหมายว่าอ่านแล้ว"
_initialAccountSetting: _initialAccountSetting:
accountCreated: "คุณได้สร้างบัญชีของคุณสำเร็จเรียบร้อยแล้ว!" accountCreated: "คุณได้สร้างบัญชีของคุณสำเร็จเรียบร้อยแล้ว!"
letsStartAccountSetup: "สำหรับผู้เริ่มต้นมาตั้งค่าโปรไฟล์ของคุณกันเถอะ" letsStartAccountSetup: "สำหรับผู้เริ่มต้นมาตั้งค่าโปรไฟล์ของคุณกันเถอะ"
@ -1315,7 +1337,7 @@ _timelineDescription:
_serverRules: _serverRules:
description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ"
_serverSettings: _serverSettings:
iconUrl: "ไอคอน URL" iconUrl: "URL ไอคอน"
appIconDescription: "ระบุไอคอนที่จะใช้เมื่อ {host} แสดงเป็นแอป" appIconDescription: "ระบุไอคอนที่จะใช้เมื่อ {host} แสดงเป็นแอป"
appIconUsageExample: "E.g. เป็น PWA หรือเมื่อแสดงผลเป็นบุ๊กมาร์กหน้าจอหลักบนโทรศัพท์" appIconUsageExample: "E.g. เป็น PWA หรือเมื่อแสดงผลเป็นบุ๊กมาร์กหน้าจอหลักบนโทรศัพท์"
appIconStyleRecommendation: "เนื่องจากไอคอนอาจถูกครอบตัดเป็นสี่เหลี่ยมจัตุรัสหรือวงกลม จึงแนะนำให้ใช้ไอคอนที่มีขอบสีรอบๆ เนื้อหา" appIconStyleRecommendation: "เนื่องจากไอคอนอาจถูกครอบตัดเป็นสี่เหลี่ยมจัตุรัสหรือวงกลม จึงแนะนำให้ใช้ไอคอนที่มีขอบสีรอบๆ เนื้อหา"
@ -1603,7 +1625,7 @@ _role:
assignTarget: "มอบหมาย" assignTarget: "มอบหมาย"
descriptionOfAssignTarget: "แบบ<b>ปรับเอง</b> เพิ่มถอนบทบาทนี้แก่ผู้ใช้ด้วยตัวเอง\nแบบ<b>มีเงื่อนไข</b> เพิ่มถอนบทบาทนี้แก่ผู้ใช้โดยอัตโนมัติหากเข้าเงื่อนไขใดต่อไปนี้" descriptionOfAssignTarget: "แบบ<b>ปรับเอง</b> เพิ่มถอนบทบาทนี้แก่ผู้ใช้ด้วยตัวเอง\nแบบ<b>มีเงื่อนไข</b> เพิ่มถอนบทบาทนี้แก่ผู้ใช้โดยอัตโนมัติหากเข้าเงื่อนไขใดต่อไปนี้"
manual: "ปรับเอง" manual: "ปรับเอง"
manualRoles: "บทบาทแบบทำเอง" manualRoles: "บทบาทแบบทำมือ"
conditional: "มีเงื่อนไข" conditional: "มีเงื่อนไข"
conditionalRoles: "บทบาทแบบมีเงื่อนไข" conditionalRoles: "บทบาทแบบมีเงื่อนไข"
condition: "เงื่อนไข" condition: "เงื่อนไข"
@ -1615,13 +1637,13 @@ _role:
baseRole: "เทมเพลตบทบาท" baseRole: "เทมเพลตบทบาท"
useBaseValue: "ใช้ตามเทมเพลตบทบาท" useBaseValue: "ใช้ตามเทมเพลตบทบาท"
chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด" chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด"
iconUrl: "ไอคอน URL" iconUrl: "URL ไอคอน"
asBadge: "แสดงเป็นตรา" asBadge: "แสดงเป็นตรา"
descriptionOfAsBadge: "เมื่อเปิดใช้งาน ไอคอนบทบาทจะปรากฏถัดจากชื่อผู้ใช้" descriptionOfAsBadge: "เมื่อเปิดใช้งาน ไอคอนบทบาทจะปรากฏถัดจากชื่อผู้ใช้"
isExplorable: "ค้นหาผู้ใช้ได้ง่ายขึ้นโดยดูจากบทบาท" isExplorable: "ค้นหาผู้ใช้ได้ง่ายขึ้นโดยดูจากบทบาท"
descriptionOfIsExplorable: "เมื่อเปิดใช้งาน ไทมไลน์บทบาทนี้และสมาชิกที่มีบทบาทนี้จะเปิดเผยเป็นสาธารณะ" descriptionOfIsExplorable: "เมื่อเปิดใช้งาน ไทมไลน์บทบาทนี้และสมาชิกที่มีบทบาทนี้จะเปิดเผยเป็นสาธารณะ"
displayOrder: "ตำแหน่ง" displayOrder: "ลำดับการแสดงผล"
descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ" descriptionOfDisplayOrder: "เลขที่สูงกว่าจะแสดงบน UI ก่อน"
canEditMembersByModerator: "อนุญาตให้ผู้ควบคุมแก้ไขสมาชิก" canEditMembersByModerator: "อนุญาตให้ผู้ควบคุมแก้ไขสมาชิก"
descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ นอกเหนือจากผู้ควบคุมและผู้ดูแลระบบแล้ว จะสามารถเพิ่มถอนบทบาทนี้แก่ผู้ใช้ได้ แต่เมื่อปิดใช้ จะมีเฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถดำเนินการได้" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ นอกเหนือจากผู้ควบคุมและผู้ดูแลระบบแล้ว จะสามารถเพิ่มถอนบทบาทนี้แก่ผู้ใช้ได้ แต่เมื่อปิดใช้ จะมีเฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถดำเนินการได้"
priority: "ลำดับความสำคัญ" priority: "ลำดับความสำคัญ"
@ -1633,6 +1655,7 @@ _role:
gtlAvailable: "การดูไทม์ไลน์ทั่วโลก" gtlAvailable: "การดูไทม์ไลน์ทั่วโลก"
ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น" ltlAvailable: "การดูไทม์ไลน์ในท้องถิ่น"
canPublicNote: "สามารถโพสต์แบบสาธารณะ" canPublicNote: "สามารถโพสต์แบบสาธารณะ"
mentionMax: "จำนวนการกล่าวถึงสูงสุดต่อโน้ต"
canInvite: "สร้างรหัสเชิญอินสแตนซ์" canInvite: "สร้างรหัสเชิญอินสแตนซ์"
inviteLimit: "จำกัดการเชิญ" inviteLimit: "จำกัดการเชิญ"
inviteLimitCycle: "คูลดาวน์ในการเชิญ" inviteLimitCycle: "คูลดาวน์ในการเชิญ"
@ -1656,6 +1679,7 @@ _role:
canUseTranslator: "การใช้งานแปล" canUseTranslator: "การใช้งานแปล"
avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
_condition: _condition:
roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ"
isLocal: "ผู้ใช้ในพื้นที่" isLocal: "ผู้ใช้ในพื้นที่"
isRemote: "ผู้ใช้ระยะไกล" isRemote: "ผู้ใช้ระยะไกล"
createdLessThan: "สร้างน้อยกว่า" createdLessThan: "สร้างน้อยกว่า"
@ -1685,13 +1709,13 @@ _emailUnavailable:
smtp: "เซิร์ฟเวอร์อีเมลนี้ไม่มีการตอบสนอง" smtp: "เซิร์ฟเวอร์อีเมลนี้ไม่มีการตอบสนอง"
banned: "คุณไม่สามารถลงทะเบียนด้วยที่อยู่อีเมลนี้ได้" banned: "คุณไม่สามารถลงทะเบียนด้วยที่อยู่อีเมลนี้ได้"
_ffVisibility: _ffVisibility:
public: "เผยแพร่" public: "สาธารณะ"
followers: "ปรากฏให้แก่ผู้ติดตามเท่านั้น" followers: "ปรากฏให้แก่ผู้ติดตามเท่านั้น"
private: "ส่วนตัว" private: "ส่วนตัว"
_signup: _signup:
almostThere: "เกือบจะเสร็จแล้ว" almostThere: "เกือบจะเสร็จแล้ว"
emailAddressInfo: "กรุณากรอกที่อยู่อีเมลที่คุณใช้ ที่อยู่อีเมลของคุณจะไม่ถูกเผยแพร่สู่สาธารณชน" emailAddressInfo: "กรุณากรอกที่อยู่อีเมลที่คุณใช้ ที่อยู่อีเมลของคุณจะไม่ถูกเผยแพร่สู่สาธารณชน"
emailSent: "เราได้ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้วนะ ({email}) โปรดคลิกลิงก์ที่รวมไว้เพื่อสร้างบัญชีให้เสร็จสิ้น" emailSent: "อีเมลยืนยันได้ถูกส่งไปยังที่อยู่อีเมลที่คุณป้อน ({email}) แล้ว กรุณาติดตามลิงก์ในอีเมลเพื่อสร้างบัญชีให้เสร็จสมบูรณ์ ลิงก์ที่ให้ไว้จะหมดอายุใน 30 นาที"
_accountDelete: _accountDelete:
accountDelete: "ลบบัญชีผู้ใช้" accountDelete: "ลบบัญชีผู้ใช้"
mayTakeTime: "เนื่องจากการลบบัญชีนี้จะเป็นกระบวนการที่ต้องใช้ทรัพยากรมาก จึงอาจจะต้องใช้เวลาสักครู่ถึงจะเสร็จสมบูรณ์ ทั้งนี้ขึ้นอยู่กับจำนวนเนื้อหาที่คุณสร้างและจำนวนไฟล์ที่คุณอัปโหลดนะ" mayTakeTime: "เนื่องจากการลบบัญชีนี้จะเป็นกระบวนการที่ต้องใช้ทรัพยากรมาก จึงอาจจะต้องใช้เวลาสักครู่ถึงจะเสร็จสมบูรณ์ ทั้งนี้ขึ้นอยู่กับจำนวนเนื้อหาที่คุณสร้างและจำนวนไฟล์ที่คุณอัปโหลดนะ"
@ -1729,7 +1753,7 @@ _plugin:
viewSource: "ดูต้นฉบับ" viewSource: "ดูต้นฉบับ"
_preferencesBackups: _preferencesBackups:
list: "สร้างการสำรองข้อมูล" list: "สร้างการสำรองข้อมูล"
saveNew: "บันทึกใหม่" saveNew: "บันทึกข้อมูลสำรองใหม่"
loadFile: "โหลดจากไฟล์" loadFile: "โหลดจากไฟล์"
apply: "นำไปใช้กับอุปกรณ์นี้" apply: "นำไปใช้กับอุปกรณ์นี้"
save: "บันทึก" save: "บันทึก"
@ -1739,8 +1763,8 @@ _preferencesBackups:
applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ" applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ"
saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?" saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?"
deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?"
renameConfirm: "เปลี่ยนชื่อข้อมูลสำรองนี้จาก \"{old}\" เป็น \"{new}\" หรือไม่?" renameConfirm: "ต้องการเปลี่ยนชื่อข้อมูลสำรองจาก “{old}” เป็น “{new}” ใช่ไหม?"
noBackups: "ไม่มีข้อมูลสำรองนะ คุณสามารถสำรองข้อมูลการตั้งค่าไคลเอนต์ของคุณบนเซิร์ฟเวอร์นี้โดยใช้ \"สร้างการสำรองข้อมูลใหม่\"ได้นะ" noBackups: "ไม่มีข้อมูลสำรอง สามารถบันทึกการตั้งค่าไคลเอนต์ปัจจุบันไปยังเซิร์ฟเวอร์ด้วย “บันทึกข้อมูลสำรองใหม่”"
createdAt: "สร้างเมื่อ: {date} {time}" createdAt: "สร้างเมื่อ: {date} {time}"
updatedAt: "อัปเดตเมื่อ: {date} {time}" updatedAt: "อัปเดตเมื่อ: {date} {time}"
cannotLoad: "การโหลดล้มเหลว" cannotLoad: "การโหลดล้มเหลว"
@ -1756,14 +1780,16 @@ _aboutMisskey:
contributors: "ผู้สนับสนุนหลัก" contributors: "ผู้สนับสนุนหลัก"
allContributors: "ผู้มีส่วนร่วมทั้งหมด" allContributors: "ผู้มีส่วนร่วมทั้งหมด"
source: "ซอร์สโค้ด" source: "ซอร์สโค้ด"
original: "ต้นฉบับ"
thisIsModifiedVersion: "{name} ใช้ Misskey เวอร์ชันดัดแปลง"
translation: "แปลภาษา Misskey" translation: "แปลภาษา Misskey"
donate: "บริจาคให้กับ Misskey" donate: "บริจาคให้กับ Misskey"
morePatrons: " ขอบคุณทุกท่านที่ร่วมกันช่วยเหลือตลอดมานะคะ 🥰" morePatrons: "และอีกหลายท่านที่ไม่ได้เอ่ยนาม ขอบคุณที่ร่วมช่วยเหลือตลอดมานะคะ 🥰"
patrons: "สมาชิกพันธมิตร" patrons: "ผู้อุปถัมภ์"
projectMembers: "สมาชิกในโครงการ" projectMembers: "สมาชิกในโครงการ"
_displayOfSensitiveMedia: _displayOfSensitiveMedia:
respect: "ซ่อนสื่อที่ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน" respect: "ซ่อนสื่อที่มีเนื้อหาละเอียดอ่อน"
ignore: "แสดงสื่อที่ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน" ignore: "แสดงสื่อที่มีเนื้อหาละเอียดอ่อน"
force: "ซ่อนสื่อทั้งหมด" force: "ซ่อนสื่อทั้งหมด"
_instanceTicker: _instanceTicker:
none: "ไม่ต้องแสดง" none: "ไม่ต้องแสดง"
@ -1831,8 +1857,8 @@ _theme:
importInfo: "ถ้าหากต้องการป้อนโค้ดที่นี่ คุณยังสามารถนำเข้าไปยังโปรแกรมแก้ไขธีมได้" importInfo: "ถ้าหากต้องการป้อนโค้ดที่นี่ คุณยังสามารถนำเข้าไปยังโปรแกรมแก้ไขธีมได้"
deleteConstantConfirm: "คุณต้องการลบค่าคงที่ {const} หรือป่าว?" deleteConstantConfirm: "คุณต้องการลบค่าคงที่ {const} หรือป่าว?"
keys: keys:
accent: "เน้น" accent: "สีหลัก"
bg: "ภาพพื้นหลัง" bg: "พื้นหลัง"
fg: "ข้อความ" fg: "ข้อความ"
focus: "โฟกัส" focus: "โฟกัส"
indicator: "ตัวบ่งชี้" indicator: "ตัวบ่งชี้"
@ -1868,11 +1894,11 @@ _theme:
wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ"
badge: "ตรา" badge: "ตรา"
messageBg: "พื้นหลังแชท" messageBg: "พื้นหลังแชท"
accentDarken: "เน้น (มืด)" accentDarken: "สีหลัก (มืด)"
accentLighten: "เน้น (สว่าง)" accentLighten: "สีหลัก (สว่าง)"
fgHighlighted: "ข้อความที่ไฮไลต์" fgHighlighted: "ข้อความที่ไฮไลต์"
_sfx: _sfx:
note: "หมายเหตุ" note: "โน้ต"
noteMy: "โน้ตของตัวเอง" noteMy: "โน้ตของตัวเอง"
notification: "การเเจ้งเตือน" notification: "การเเจ้งเตือน"
antenna: "เสาอากาศ" antenna: "เสาอากาศ"
@ -1959,7 +1985,7 @@ _permissions:
"read:reactions": "ดูรีแอคชั่นของคุณ" "read:reactions": "ดูรีแอคชั่นของคุณ"
"write:reactions": "แก้ไขรีแอคชั่นของคุณ" "write:reactions": "แก้ไขรีแอคชั่นของคุณ"
"write:votes": "โหวตบนสำรวจความคิดเห็น" "write:votes": "โหวตบนสำรวจความคิดเห็น"
"read:pages": "ดหน้าเพจ" "read:pages": "ดหน้าเพจ"
"write:pages": "แก้ไขหรือลบเพจของคุณ" "write:pages": "แก้ไขหรือลบเพจของคุณ"
"read:page-likes": "ดูรายการเพจที่ถูกใจไว้" "read:page-likes": "ดูรายการเพจที่ถูกใจไว้"
"write:page-likes": "แก้ไขรายการเพจที่ถูกใจ" "write:page-likes": "แก้ไขรายการเพจที่ถูกใจ"
@ -1971,8 +1997,8 @@ _permissions:
"write:gallery": "แก้ไขแกลเลอรี่ของคุณ" "write:gallery": "แก้ไขแกลเลอรี่ของคุณ"
"read:gallery-likes": "ดูรายการโพสต์แกลเลอรีที่ถูกใจไว้" "read:gallery-likes": "ดูรายการโพสต์แกลเลอรีที่ถูกใจไว้"
"write:gallery-likes": "แก้ไขรายการโพสต์แกลเลอรีที่ถูกใจไว้" "write:gallery-likes": "แก้ไขรายการโพสต์แกลเลอรีที่ถูกใจไว้"
"read:flash": "วิว เพลย์" "read:flash": "ดู Play"
"write:flash": "แก้ไขเพลย์" "write:flash": "แก้ไข Play"
"read:flash-likes": "ดูรายการ play ที่ถูกใจไว้" "read:flash-likes": "ดูรายการ play ที่ถูกใจไว้"
"write:flash-likes": "แก้ไขรายการ play ที่ถูกใจไว้" "write:flash-likes": "แก้ไขรายการ play ที่ถูกใจไว้"
"read:admin:abuse-user-reports": "ดูรายงานจากผู้ใช้" "read:admin:abuse-user-reports": "ดูรายงานจากผู้ใช้"
@ -1999,8 +2025,8 @@ _permissions:
"read:admin:roles": "ดูบทบาท" "read:admin:roles": "ดูบทบาท"
"write:admin:relays": "จัดการรีเลย์" "write:admin:relays": "จัดการรีเลย์"
"read:admin:relays": "ดูรีเลย์" "read:admin:relays": "ดูรีเลย์"
"write:admin:invite-codes": "จัดการคำเชิญ" "write:admin:invite-codes": "จัดการรหัสเชิญ"
"read:admin:invite-codes": "ดูรหัสคำเชิญ" "read:admin:invite-codes": "ดูรหัสเชิญ"
"write:admin:announcements": "จัดการประกาศ" "write:admin:announcements": "จัดการประกาศ"
"read:admin:announcements": "ดูประกาศ" "read:admin:announcements": "ดูประกาศ"
"write:admin:avatar-decorations": "จัดการการตกแต่งอวตาร" "write:admin:avatar-decorations": "จัดการการตกแต่งอวตาร"
@ -2018,7 +2044,7 @@ _permissions:
"read:admin:stream": "ใช้ Websocket API สำหรับผู้ดูแลระบบ" "read:admin:stream": "ใช้ Websocket API สำหรับผู้ดูแลระบบ"
"write:admin:ad": "จัดการโฆษณา" "write:admin:ad": "จัดการโฆษณา"
"read:admin:ad": "ดูโฆษณา" "read:admin:ad": "ดูโฆษณา"
"write:invite-codes": "สร้างรหัสคำเชิญ" "write:invite-codes": "สร้างรหัสเชิญ"
"read:invite-codes": "รับรหัสเชิญ" "read:invite-codes": "รับรหัสเชิญ"
"write:clip-favorite": "ควบคุมการถูกใจของคลิป" "write:clip-favorite": "ควบคุมการถูกใจของคลิป"
"read:clip-favorite": "ดูการถูกใจของคลิป" "read:clip-favorite": "ดูการถูกใจของคลิป"
@ -2071,8 +2097,8 @@ _widgets:
onlineUsers: "ผู้ใช้ที่ออนไลน์" onlineUsers: "ผู้ใช้ที่ออนไลน์"
jobQueue: "คิวงาน" jobQueue: "คิวงาน"
serverMetric: "ตัวชี้วัดเซิร์ฟเวอร์" serverMetric: "ตัวชี้วัดเซิร์ฟเวอร์"
aiscript: "AiScript คอนโซล" aiscript: " คอนโซล AiScript"
aiscriptApp: "AiScript แอพ" aiscriptApp: "แอป AiScript"
aichan: "ไอ" aichan: "ไอ"
userList: "รายชื่อผู้ใช้" userList: "รายชื่อผู้ใช้"
_userList: _userList:
@ -2086,15 +2112,15 @@ _cw:
files: "{count} ไฟล์" files: "{count} ไฟล์"
_poll: _poll:
noOnlyOneChoice: "จำเป็นต้องมีอย่างน้อยสองตัวเลือก" noOnlyOneChoice: "จำเป็นต้องมีอย่างน้อยสองตัวเลือก"
choiceN: "ตัวเลือก {n}" choiceN: "ตัวเลือกที่ {n}"
noMore: "คุณไม่สามารถเพิ่มตัวเลือกอื่นได้" noMore: "เพิ่มตัวเลือกอีกไม่ได้แล้ว"
canMultipleVote: "สามารถตอบได้หลายคำตอบ" canMultipleVote: "สามารถตอบได้หลายคำตอบ"
expiration: "สิ้นสุดการสำรวจความคิดเห็น" expiration: "สิ้นสุดโพล"
infinite: "ไม่ต้องเลย" infinite: "ไม่กำหนดระยะเวลา"
at: "จบที่..." at: "ระบุวันเวลา"
after: "สิ้นสุดหลัง..." after: "ระบุระยะเวลา"
deadlineDate: "วันสิ้นสุด" deadlineDate: "วันสิ้นสุด"
deadlineTime: "ชั่วโมง" deadlineTime: "เวลา"
duration: "ระยะเวลา" duration: "ระยะเวลา"
votesCount: "{n} คะแนนเสียง" votesCount: "{n} คะแนนเสียง"
totalVotes: "{n} คะแนนเสียงทั้งหมด" totalVotes: "{n} คะแนนเสียงทั้งหมด"
@ -2102,17 +2128,17 @@ _poll:
showResult: "ดูผลลัพธ์" showResult: "ดูผลลัพธ์"
voted: "โหวตแล้ว" voted: "โหวตแล้ว"
closed: "สิ้นสุดแล้ว" closed: "สิ้นสุดแล้ว"
remainingDays: "จะเสร็จสิ้นในอีก {d} วัน {h} ชั่วโมง" remainingDays: "เหลืออีก {d} วัน {h} ชั่วโมง"
remainingHours: "{h} ชั่วโมง(s) {m} นาที(s) ที่เหลืออยู่" remainingHours: "เหลืออีก {h} ชั่วโมง {m} นาที"
remainingMinutes: "{m} นาที(s) {s} วินาที(s) ที่เหลืออยู่" remainingMinutes: "เหลืออีก {m} นาที {s} วินาที"
remainingSeconds: "{s} นาที(s) ที่เหลืออยู่" remainingSeconds: "เหลืออีก {s} วินาที"
_visibility: _visibility:
public: "สาธารณะ" public: "สาธารณะ"
publicDescription: "โน้ตของคุณจะปรากฏแก่ผู้ใช้ทุกคน" publicDescription: "โน้ตของคุณจะปรากฏแก่ผู้ใช้ทุกคน"
home: "หน้าแรก" home: "หน้าแรก"
homeDescription: "โพสลงไทม์ไลน์ที่บ้านเท่านั้น" homeDescription: "โพสลงไทม์ไลน์ที่บ้านเท่านั้น"
followers: "ผู้ติดตาม" followers: "ผู้ติดตาม"
followersDescription: "ทำให้ผู้ติดตามนั้นมองเห็นแค่คุณเท่านั้น" followersDescription: "เฉพาะผู้ติดตามเท่านั้นที่มองเห็นได้"
specified: "ไดเร็ค" specified: "ไดเร็ค"
specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น" specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น"
disableFederation: "ไม่มีสหพันธ์" disableFederation: "ไม่มีสหพันธ์"
@ -2122,11 +2148,11 @@ _postForm:
quotePlaceholder: "อ้างโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..."
channelPlaceholder: "โพสต์ลงช่อง..." channelPlaceholder: "โพสต์ลงช่อง..."
_placeholders: _placeholders:
a: "คุณเป็นอะไรไปหรอ?" a: "ตอนนี้เป็นยังไงบ้าง?"
b: "เกิดอะไรขึ้นรอบตัวคุณ?" b: "มีอะไรเกิดขึ้นหรือเปล่า?"
c: "คุณกำลังคิดอะไรอยู่?" c: "กำลังคิดอะไรอยู่?"
d: "คุณต้องการจะพูดอะไร?" d: "ต้องการจะพูดอะไรไหม?"
e: "เริ่มเขียน..." e: "มาเขียนกันเถอะ"
f: "กำลังรอให้คุณเขียน..." f: "กำลังรอให้คุณเขียน..."
_profile: _profile:
name: "ชื่อ" name: "ชื่อ"
@ -2140,11 +2166,11 @@ _profile:
metadataContent: "เนื้อหา" metadataContent: "เนื้อหา"
changeAvatar: "เปลี่ยนอวาตาร์" changeAvatar: "เปลี่ยนอวาตาร์"
changeBanner: "เปลี่ยนแบนเนอร์" changeBanner: "เปลี่ยนแบนเนอร์"
verifiedLinkDescription: "โดยการป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณตรงนี้ ส่วนไอคอนการยืนยันความเป็นเจ้าของนั้นก็สามารถแสดงถัดจากฟิลด์ได้นะ" verifiedLinkDescription: "หากป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณ ไอคอนการยืนยันความเป็นเจ้าของจะแสดงถัดจากฟิลด์นั้น ๆ"
avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}" avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}"
_exportOrImport: _exportOrImport:
allNotes: "โน้ตทั้งหมด" allNotes: "โน้ตทั้งหมด"
favoritedNotes: "บันทึกที่ชื่นชอบ" favoritedNotes: "โน้ตที่ถูกใจไว้"
clips: "คลิป" clips: "คลิป"
followingList: "กำลังติดตาม" followingList: "กำลังติดตาม"
muteList: "ปิดเสียง" muteList: "ปิดเสียง"
@ -2227,7 +2253,7 @@ _pages:
summary: "สรุปเพจ" summary: "สรุปเพจ"
alignCenter: "เซ็นเตอร์" alignCenter: "เซ็นเตอร์"
hideTitleWhenPinned: "ซ่อนชื่อหน้าเพจเมื่อปักหมุดไว้ที่โปรไฟล์" hideTitleWhenPinned: "ซ่อนชื่อหน้าเพจเมื่อปักหมุดไว้ที่โปรไฟล์"
font: "ตัวอักษร" font: "แบบอักษร"
fontSerif: "Serif" fontSerif: "Serif"
fontSansSerif: "Sans Serif" fontSansSerif: "Sans Serif"
eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ" eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ"
@ -2253,27 +2279,28 @@ _relayStatus:
accepted: "ได้รับการอนุมัติ" accepted: "ได้รับการอนุมัติ"
rejected: "ถูกปฏิเสธ" rejected: "ถูกปฏิเสธ"
_notification: _notification:
fileUploaded: "ไฟล์ถูกอัพโหลดแล้วน่ะ" fileUploaded: "ไฟล์ถูกอัปโหลดแล้ว"
youGotMention: "{name} กล่าวถึงคุณ" youGotMention: "{name} กล่าวถึงคุณ"
youGotReply: "{name} ตอบกลับถึงคุณ" youGotReply: "{name} ตอบกลับถึงคุณ"
youGotQuote: "{name} อ้างถึงคุณ" youGotQuote: "{name} อ้างอิงคุณ"
youRenoted: "รีโน้ตจาก {name}" youRenoted: "รีโน้ตจาก {name}"
youWereFollowed: "ได้ติดตามคุณ" youWereFollowed: "ได้ติดตามคุณ"
youReceivedFollowRequest: "คุณมีคำขอติดตามใหม่น่ะ" youReceivedFollowRequest: "ได้รับคำขอติดตาม"
yourFollowRequestAccepted: "คำขอติดตามของคุณได้รับการยอมรับแล้วน่ะ" yourFollowRequestAccepted: "คำขอติดตามได้รับการอนุมัติแล้ว"
pollEnded: "โพลสำรวจความคิดเห็นผลลัพธ์มีพร้อมใช้งาน" pollEnded: "ผลโพลออกมาแล้ว"
newNote: "โพสต์ใหม่" newNote: "โพสต์ใหม่"
unreadAntennaNote: "เสาอากาศ {name}" unreadAntennaNote: "เสาอากาศ {name}"
roleAssigned: "ได้รับบทบาท" roleAssigned: "ได้รับบทบาท"
emptyPushNotificationMessage: "การแจ้งเตือนแบบพุชได้รับการอัพเดทแล้ว" emptyPushNotificationMessage: "อัปเดตการแจ้งเตือนแบบพุชแล้ว"
achievementEarned: "รับความสำเร็จ" achievementEarned: "รับความสำเร็จ"
testNotification: "ทดสอบการแจ้งเตือน" testNotification: "ทดสอบการแจ้งเตือน"
checkNotificationBehavior: "กดเพื่อดูลักษณะการแจ้งเตือน" checkNotificationBehavior: "กดเพื่อดูลักษณะการแจ้งเตือน"
sendTestNotification: "ส่งทดสอบการแจ้งเตือน" sendTestNotification: "ส่งทดสอบการแจ้งเตือน"
notificationWillBeDisplayedLikeThis: "การแจ้งเตือนมีลักษณะแบบนี้" notificationWillBeDisplayedLikeThis: "การแจ้งเตือนมีลักษณะแบบนี้"
reactedBySomeUsers: "ถูกรีแอคชั่นโดยผู้ใช้ {n} ราย" reactedBySomeUsers: "ถูกรีแอคชั่นโดยผู้ใช้ {n} ราย"
renotedBySomeUsers: "Renote จากผู้ใช้จำนวน {n} ราย" renotedBySomeUsers: "รีโน้ตจากผู้ใช้ {n} ราย"
followedBySomeUsers: "มีผู้ติดตาม {n} ราย" followedBySomeUsers: "มีผู้ติดตาม {n} ราย"
flushNotification: "ล้างประวัติการแจ้งเตือน"
_types: _types:
all: "ทั้งหมด" all: "ทั้งหมด"
note: "โน้ตใหม่" note: "โน้ตใหม่"
@ -2283,9 +2310,9 @@ _notification:
renote: "รีโน้ต" renote: "รีโน้ต"
quote: "อ้างคำพูด" quote: "อ้างคำพูด"
reaction: "รีแอคชั่น" reaction: "รีแอคชั่น"
pollEnded: "โพลนี้สิ้นสุดลงแล้ว" pollEnded: "โพลสิ้นสุดแล้ว"
receiveFollowRequest: "ได้รับคำขอติดตาม\n" receiveFollowRequest: "ได้รับคำร้องขอติดตาม"
followRequestAccepted: "ยอมรับคำขอติดตาม" followRequestAccepted: "อนุมัติให้ติดตามแล้ว"
roleAssigned: "ให้บทบาท" roleAssigned: "ให้บทบาท"
achievementEarned: "ปลดล็อกความสำเร็จแล้ว" achievementEarned: "ปลดล็อกความสำเร็จแล้ว"
app: "การแจ้งเตือนจากแอปที่มีลิงก์" app: "การแจ้งเตือนจากแอปที่มีลิงก์"
@ -2322,7 +2349,7 @@ _deck:
list: "รายการ" list: "รายการ"
channel: "ช่อง" channel: "ช่อง"
mentions: "พูดถึง" mentions: "พูดถึง"
direct: "ไดเร็" direct: "ไดเร็กต์"
roleTimeline: "บทบาทไทม์ไลน์" roleTimeline: "บทบาทไทม์ไลน์"
_dialog: _dialog:
charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}"
@ -2353,8 +2380,8 @@ _moderationLogTypes:
updateRole: "อัปเดตบทบาทแล้ว" updateRole: "อัปเดตบทบาทแล้ว"
assignRole: "ได้รับมอบหมายบทบาท" assignRole: "ได้รับมอบหมายบทบาท"
unassignRole: "ถอดออกจากบทบาทแล้ว" unassignRole: "ถอดออกจากบทบาทแล้ว"
suspend: "ถูกระงับ" suspend: "ระงับ"
unsuspend: "เลิกถูกระงับ" unsuspend: "เลิกระงับ"
addCustomEmoji: "เพิ่มเอโมจิที่กำหนดเองแล้ว" addCustomEmoji: "เพิ่มเอโมจิที่กำหนดเองแล้ว"
updateCustomEmoji: "อัปเดตเอโมจิที่กำหนดเองแล้ว" updateCustomEmoji: "อัปเดตเอโมจิที่กำหนดเองแล้ว"
deleteCustomEmoji: "ลบเอโมจิที่กำหนดเองออกแล้ว" deleteCustomEmoji: "ลบเอโมจิที่กำหนดเองออกแล้ว"
@ -2369,12 +2396,13 @@ _moderationLogTypes:
deleteGlobalAnnouncement: "ลบประกาศทั่วโลกออกแล้ว" deleteGlobalAnnouncement: "ลบประกาศทั่วโลกออกแล้ว"
deleteUserAnnouncement: "ลบประกาศผู้ใช้ออกแล้ว" deleteUserAnnouncement: "ลบประกาศผู้ใช้ออกแล้ว"
resetPassword: "รีเซ็ตรหัสผ่าน" resetPassword: "รีเซ็ตรหัสผ่าน"
suspendRemoteInstance: "อินสแตนซ์ระยะไกลถูกระงับ" suspendRemoteInstance: "ระงับอินสแตนซ์ระยะไกล"
unsuspendRemoteInstance: "อินสแตนซ์ระยะไกลเลิกการระงับ" unsuspendRemoteInstance: "เลิกระงับอินสแตนซ์ระยะไกล"
updateRemoteInstanceNote: "อัปเดตโน้ตการกลั่นกรองของอินสแตนซ์ระยะไกลแล้ว"
markSensitiveDriveFile: "ทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" markSensitiveDriveFile: "ทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน"
unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน"
resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว" resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว"
createInvitation: "สร้างคำเชิญ" createInvitation: "สร้างรหัสเชิญ"
createAd: "สร้างโฆษณาแล้ว" createAd: "สร้างโฆษณาแล้ว"
deleteAd: "ลบโฆษณาออกแล้ว" deleteAd: "ลบโฆษณาออกแล้ว"
updateAd: "อัปเดตโฆษณาแล้ว" updateAd: "อัปเดตโฆษณาแล้ว"
@ -2491,6 +2519,8 @@ _reversi:
opponentHasSettingsChanged: "อีกฝ่ายเปลี่ยนการตั้งค่า" opponentHasSettingsChanged: "อีกฝ่ายเปลี่ยนการตั้งค่า"
allowIrregularRules: "อนุญาตกฎที่ไม่ปรกติ (โหมดฟรีทุกอย่าง)" allowIrregularRules: "อนุญาตกฎที่ไม่ปรกติ (โหมดฟรีทุกอย่าง)"
disallowIrregularRules: "ไม่อนุญาตกฎที่ไม่ปรกติ" disallowIrregularRules: "ไม่อนุญาตกฎที่ไม่ปรกติ"
showBoardLabels: "แสดงหมายเลขแถว/คอลัมน์บนกระดาน"
useAvatarAsStone: "ใช้รูปอวตารเป็นหมาก"
_offlineScreen: _offlineScreen:
title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"
header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"

View file

@ -1048,6 +1048,7 @@ verifiedLink: "Chúng tôi đã xác nhận bạn là chủ sở hữu của đ
sourceCode: "Mã nguồn" sourceCode: "Mã nguồn"
flip: "Lật" flip: "Lật"
lastNDays: "{n} ngày trước" lastNDays: "{n} ngày trước"
surrender: "Từ chối"
_announcement: _announcement:
forExistingUsers: "Chỉ những người dùng đã tồn tại" forExistingUsers: "Chỉ những người dùng đã tồn tại"
forExistingUsersDescription: "Nếu được bật, thông báo này sẽ chỉ hiển thị với những người dùng đã tồn tại vào lúc thông báo được tạo. Nếu tắt đi, những tài khoản mới đăng ký sau khi thông báo được đăng lên cũng sẽ thấy nó." forExistingUsersDescription: "Nếu được bật, thông báo này sẽ chỉ hiển thị với những người dùng đã tồn tại vào lúc thông báo được tạo. Nếu tắt đi, những tài khoản mới đăng ký sau khi thông báo được đăng lên cũng sẽ thấy nó."

View file

@ -336,7 +336,7 @@ displayOfSensitiveMedia: "显示敏感媒体"
whenServerDisconnected: "与服务器连接中断时" whenServerDisconnected: "与服务器连接中断时"
disconnectedFromServer: "已和服务器断开连接" disconnectedFromServer: "已和服务器断开连接"
reload: "重新加载" reload: "重新加载"
doNothing: "关闭弹窗" doNothing: "关闭"
reloadConfirm: "确定要重新加载吗?" reloadConfirm: "确定要重新加载吗?"
watch: "关注" watch: "关注"
unwatch: "取消关注" unwatch: "取消关注"
@ -991,6 +991,7 @@ neverShow: "不再显示"
remindMeLater: "稍后提醒我" remindMeLater: "稍后提醒我"
didYouLikeMisskey: "您喜欢 Misskey 吗?" didYouLikeMisskey: "您喜欢 Misskey 吗?"
pleaseDonate: "Misskey 是 {host} 所使用的免费软件。为了今后也能够维持 Misskey 的开发,请在有余力的情况下进行捐助!" pleaseDonate: "Misskey 是 {host} 所使用的免费软件。为了今后也能够维持 Misskey 的开发,请在有余力的情况下进行捐助!"
correspondingSourceIsAvailable: "对应的源代码可在{anchor}找到"
roles: "角色" roles: "角色"
role: "角色" role: "角色"
noRole: "角色不存在" noRole: "角色不存在"
@ -1042,6 +1043,7 @@ sensitiveWords: "敏感词"
sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。"
sensitiveWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。" sensitiveWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。"
prohibitedWords: "禁用词" prohibitedWords: "禁用词"
prohibitedWordsDescription: "发布包含设定词汇的帖子时将出错。可用换行设定多个关键字"
prohibitedWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。" prohibitedWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。"
hiddenTags: "隐藏标签" hiddenTags: "隐藏标签"
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。" hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
@ -1115,7 +1117,7 @@ branding: "品牌"
enableServerMachineStats: "公开服务器硬件统计信息" enableServerMachineStats: "公开服务器硬件统计信息"
enableIdenticonGeneration: "启用生成用户 Identicon" enableIdenticonGeneration: "启用生成用户 Identicon"
turnOffToImprovePerformance: "关闭该选项可以提高性能。" turnOffToImprovePerformance: "关闭该选项可以提高性能。"
createInviteCode: "发行邀请码" createInviteCode: "生成邀请码"
createWithOptions: "使用选项来创建" createWithOptions: "使用选项来创建"
createCount: "发行数" createCount: "发行数"
inviteCodeCreated: "已创建邀请码" inviteCodeCreated: "已创建邀请码"
@ -1127,7 +1129,7 @@ noExpirationDate: "不设置有效日期"
inviteCodeUsedAt: "邀请码被使用的日期和时间" inviteCodeUsedAt: "邀请码被使用的日期和时间"
registeredUserUsingInviteCode: "使用了邀请码的用户" registeredUserUsingInviteCode: "使用了邀请码的用户"
waitingForMailAuth: "等待验证电子邮件" waitingForMailAuth: "等待验证电子邮件"
inviteCodeCreator: "发行邀请码的用户" inviteCodeCreator: "生成邀请码的用户"
usedAt: "使用时间" usedAt: "使用时间"
unused: "未使用" unused: "未使用"
used: "已使用" used: "已使用"
@ -1158,6 +1160,7 @@ showRenotes: "显示转帖"
edited: "已编辑" edited: "已编辑"
notificationRecieveConfig: "通知接收设置" notificationRecieveConfig: "通知接收设置"
mutualFollow: "互相关注" mutualFollow: "互相关注"
followingOrFollower: "关注中或关注者"
fileAttachedOnly: "仅限媒体" fileAttachedOnly: "仅限媒体"
showRepliesToOthersInTimeline: "在时间线中包含给别人的回复" showRepliesToOthersInTimeline: "在时间线中包含给别人的回复"
hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复" hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复"
@ -1167,6 +1170,12 @@ confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中包含
confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?" confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?"
externalServices: "外部服务" externalServices: "外部服务"
sourceCode: "源代码" sourceCode: "源代码"
sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。"
repositoryUrl: "仓库地址"
repositoryUrlDescription: "若源代码所在的仓库是公开的,请填入对应的 URL。若是按原样使用 Misskey并未追加或者修改代码的情况请填入 https://github.com/misskey-dev/misskey。"
repositoryUrlOrTarballRequired: "若仓库并未公开,则需要提供 tarball 作为替代。详情请看 .config/example.yml。"
feedback: "反馈"
feedbackUrl: "反馈地址"
impressum: "运营商信息" impressum: "运营商信息"
impressumUrl: "运营商信息地址" impressumUrl: "运营商信息地址"
impressumDescription: "德国等国家和地区有义务展示此类信息Impressum。" impressumDescription: "德国等国家和地区有义务展示此类信息Impressum。"
@ -1196,11 +1205,14 @@ seasonalScreenEffect: "应景的画面效果"
decorate: "装饰" decorate: "装饰"
addMfmFunction: "添加装饰" addMfmFunction: "添加装饰"
enableQuickAddMfmFunction: "显示高级 MFM 选择器" enableQuickAddMfmFunction: "显示高级 MFM 选择器"
bubbleGame: "泡泡游戏"
sfx: "音效" sfx: "音效"
soundWillBePlayed: "声音将会播放" soundWillBePlayed: "声音将会播放"
showReplay: "查看重播" showReplay: "观看回放"
replay: "重播" replay: "重播"
replaying: "重播中" replaying: "重播中"
endReplay: "结束回放"
copyReplayData: "复制回放数据"
ranking: "排行榜" ranking: "排行榜"
lastNDays: "最近 {n} 天" lastNDays: "最近 {n} 天"
backToTitle: "返回标题" backToTitle: "返回标题"
@ -1208,8 +1220,19 @@ hemisphere: "居住地区"
withSensitive: "显示包含敏感媒体的帖子" withSensitive: "显示包含敏感媒体的帖子"
userSaysSomethingSensitive: "含 {name} 敏感文件的帖子" userSaysSomethingSensitive: "含 {name} 敏感文件的帖子"
enableHorizontalSwipe: "滑动切换标签页" enableHorizontalSwipe: "滑动切换标签页"
loading: "读取中"
surrender: "取消"
gameRetry: "重试"
_bubbleGame: _bubbleGame:
howToPlay: "游戏说明" howToPlay: "游戏说明"
hold: "抓住"
_score:
score: "得分"
scoreYen: "赚到的钱"
highScore: "最高分"
maxChain: "最高连击数"
yen: "{yen} 日元"
estimatedQty: "约 {qty} 个"
_howToPlay: _howToPlay:
section1: "对准位置将Emoji投入盒子。" section1: "对准位置将Emoji投入盒子。"
section2: "相同的Emoji相互接触合成后会得到新的Emoji以此获得分数。" section2: "相同的Emoji相互接触合成后会得到新的Emoji以此获得分数。"
@ -1298,8 +1321,8 @@ _initialTutorial:
description: "对于服务器方针所要求要求的,又或者不适合直接展示的附件,请添加「敏感」标记。\n" description: "对于服务器方针所要求要求的,又或者不适合直接展示的附件,请添加「敏感」标记。\n"
tryThisFile: "试试看,将附加到此窗口的图像标注为敏感!" tryThisFile: "试试看,将附加到此窗口的图像标注为敏感!"
_exampleNote: _exampleNote:
note: "不该打开纳豆的盖子的……" note: "拆纳豆包装时出错了…"
method: "要标注附件为敏感内容,请单击该文件以打开菜单,然后单击“设置为敏感”。" method: "要标注附件为敏感内容,请单击该文件以打开菜单,然后单击“标记为敏感内容”。"
sensitiveSucceeded: "附加文件时,请遵循服务器的条款来设置正确敏感设定。\n" sensitiveSucceeded: "附加文件时,请遵循服务器的条款来设置正确敏感设定。\n"
doItToContinue: "将图像标记为敏感后才能够继续" doItToContinue: "将图像标记为敏感后才能够继续"
_done: _done:
@ -1630,8 +1653,9 @@ _role:
gtlAvailable: "查看全局时间线" gtlAvailable: "查看全局时间线"
ltlAvailable: "查看本地时间线" ltlAvailable: "查看本地时间线"
canPublicNote: "允许公开发帖" canPublicNote: "允许公开发帖"
mentionMax: "帖子内最多提及数"
canInvite: "发放服务器邀请码" canInvite: "发放服务器邀请码"
inviteLimit: "可发行邀请码的数量" inviteLimit: "可生成邀请码的数量"
inviteLimitCycle: "邀请码的发行间隔" inviteLimitCycle: "邀请码的发行间隔"
inviteExpirationTime: "邀请码的有效日期" inviteExpirationTime: "邀请码的有效日期"
canManageCustomEmojis: "管理自定义表情符号" canManageCustomEmojis: "管理自定义表情符号"
@ -1653,6 +1677,7 @@ _role:
canUseTranslator: "使用翻译功能" canUseTranslator: "使用翻译功能"
avatarDecorationLimit: "可添加头像挂件的最大个数" avatarDecorationLimit: "可添加头像挂件的最大个数"
_condition: _condition:
roleAssignedTo: "已分配给手动角色"
isLocal: "是本地用户" isLocal: "是本地用户"
isRemote: "是远程用户" isRemote: "是远程用户"
createdLessThan: "账户创建时间少于" createdLessThan: "账户创建时间少于"
@ -1753,6 +1778,8 @@ _aboutMisskey:
contributors: "主要贡献者" contributors: "主要贡献者"
allContributors: "全体贡献者" allContributors: "全体贡献者"
source: "源代码" source: "源代码"
original: "原版"
thisIsModifiedVersion: "{name}正在使用修改后的 Misskey。"
translation: "翻译 Misskey" translation: "翻译 Misskey"
donate: "赞助 Misskey" donate: "赞助 Misskey"
morePatrons: "还有很多其它的人也在支持我们,非常感谢🥰" morePatrons: "还有很多其它的人也在支持我们,非常感谢🥰"
@ -2015,7 +2042,7 @@ _permissions:
"read:admin:stream": "使用管理员用的 Websocket API" "read:admin:stream": "使用管理员用的 Websocket API"
"write:admin:ad": "编辑广告" "write:admin:ad": "编辑广告"
"read:admin:ad": "查看广告" "read:admin:ad": "查看广告"
"write:invite-codes": "发行邀请码" "write:invite-codes": "生成邀请码"
"read:invite-codes": "获取已发行的邀请码" "read:invite-codes": "获取已发行的邀请码"
"write:clip-favorite": "编辑便签的点赞" "write:clip-favorite": "编辑便签的点赞"
"read:clip-favorite": "查看便签的点赞" "read:clip-favorite": "查看便签的点赞"
@ -2271,6 +2298,7 @@ _notification:
reactedBySomeUsers: "{n} 人回应了" reactedBySomeUsers: "{n} 人回应了"
renotedBySomeUsers: "{n} 人转发了" renotedBySomeUsers: "{n} 人转发了"
followedBySomeUsers: "被 {n} 人关注" followedBySomeUsers: "被 {n} 人关注"
flushNotification: "重置通知历史"
_types: _types:
all: "全部" all: "全部"
note: "用户的新帖子" note: "用户的新帖子"
@ -2368,10 +2396,11 @@ _moderationLogTypes:
resetPassword: "重置密码" resetPassword: "重置密码"
suspendRemoteInstance: "停止远程服务器" suspendRemoteInstance: "停止远程服务器"
unsuspendRemoteInstance: "恢复远程服务器" unsuspendRemoteInstance: "恢复远程服务器"
updateRemoteInstanceNote: "更新远程服务器的管理笔记"
markSensitiveDriveFile: "标记网盘文件为敏感媒体" markSensitiveDriveFile: "标记网盘文件为敏感媒体"
unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体" unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体"
resolveAbuseReport: "处理举报" resolveAbuseReport: "处理举报"
createInvitation: "发行邀请码" createInvitation: "生成邀请码"
createAd: "创建了广告" createAd: "创建了广告"
deleteAd: "删除了广告" deleteAd: "删除了广告"
updateAd: "更新了广告" updateAd: "更新了广告"
@ -2462,6 +2491,8 @@ _reversi:
myTurn: "你的回合" myTurn: "你的回合"
turnOf: "{name}的回合" turnOf: "{name}的回合"
pastTurnOf: "{name}的回合" pastTurnOf: "{name}的回合"
surrender: "认输"
surrendered: "已认输"
timeout: "超时" timeout: "超时"
drawn: "平局" drawn: "平局"
won: "{name}获胜" won: "{name}获胜"
@ -2483,6 +2514,8 @@ _reversi:
opponentHasSettingsChanged: "对手更改了设定" opponentHasSettingsChanged: "对手更改了设定"
allowIrregularRules: "允许非常规规则(完全自由)" allowIrregularRules: "允许非常规规则(完全自由)"
disallowIrregularRules: "禁止非常规规则" disallowIrregularRules: "禁止非常规规则"
showBoardLabels: "显示行号和列号"
useAvatarAsStone: "用头像作为棋子"
_offlineScreen: _offlineScreen:
title: "离线——无法连接到服务器" title: "离线——无法连接到服务器"
header: "无法连接到服务器" header: "无法连接到服务器"

View file

@ -991,6 +991,7 @@ neverShow: "不再顯示"
remindMeLater: "以後再說" remindMeLater: "以後再說"
didYouLikeMisskey: "您喜歡 Misskey 嗎?" didYouLikeMisskey: "您喜歡 Misskey 嗎?"
pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!"
correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
roles: "角色" roles: "角色"
role: "角色" role: "角色"
noRole: "沒有角色" noRole: "沒有角色"
@ -1159,6 +1160,7 @@ showRenotes: "顯示其他人的轉發貼文"
edited: "已編輯" edited: "已編輯"
notificationRecieveConfig: "接受通知的設定" notificationRecieveConfig: "接受通知的設定"
mutualFollow: "互相追隨" mutualFollow: "互相追隨"
followingOrFollower: "追隨中或追隨者"
fileAttachedOnly: "顯示包含附件的貼文" fileAttachedOnly: "顯示包含附件的貼文"
showRepliesToOthersInTimeline: "顯示給其他人的回覆" showRepliesToOthersInTimeline: "顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
@ -1168,6 +1170,12 @@ confirmShowRepliesAll: "進行此操作後無法復原。您真的希望時間
confirmHideRepliesAll: "進行此操作後無法復原。您真的希望時間軸「不包含」您目前追隨的所有人的回覆嗎?" confirmHideRepliesAll: "進行此操作後無法復原。您真的希望時間軸「不包含」您目前追隨的所有人的回覆嗎?"
externalServices: "外部服務" externalServices: "外部服務"
sourceCode: "原始碼" sourceCode: "原始碼"
sourceCodeIsNotYetProvided: "尚未提供原始碼,請洽詢管理員解決這個問題。"
repositoryUrl: "儲存庫 URL"
repositoryUrlDescription: "如果存在可公開取得原始碼的儲存庫,請輸入其 URL。 如果您按原樣使用 Misskey不對原始碼進行任何更改請輸入 https://github.com/misskey-dev/misskey。"
repositoryUrlOrTarballRequired: "如果儲存庫不是公開的,則必須提供 tarball。 詳細資訊請參閱 .config/example.yml。"
feedback: "意見回饋"
feedbackUrl: "意見回饋 URL"
impressum: "營運者資訊" impressum: "營運者資訊"
impressumUrl: "營運者資訊網址" impressumUrl: "營運者資訊網址"
impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。" impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。"
@ -1203,6 +1211,8 @@ soundWillBePlayed: "將播放音效"
showReplay: "觀看重播" showReplay: "觀看重播"
replay: "重播" replay: "重播"
replaying: "重播中" replaying: "重播中"
endReplay: "退出重播"
copyReplayData: "複製重播資料"
ranking: "排行榜" ranking: "排行榜"
lastNDays: "過去 {n} 天" lastNDays: "過去 {n} 天"
backToTitle: "回到遊戲標題頁" backToTitle: "回到遊戲標題頁"
@ -1210,8 +1220,20 @@ hemisphere: "您居住的地區"
withSensitive: "顯示包含敏感檔案的貼文" withSensitive: "顯示包含敏感檔案的貼文"
userSaysSomethingSensitive: "包含 {name} 敏感檔案的貼文" userSaysSomethingSensitive: "包含 {name} 敏感檔案的貼文"
enableHorizontalSwipe: "滑動切換時間軸" enableHorizontalSwipe: "滑動切換時間軸"
loading: "載入中"
surrender: "退出"
gameRetry: "再試一次"
_bubbleGame: _bubbleGame:
howToPlay: "玩法說明" howToPlay: "玩法說明"
hold: "保留"
_score:
score: "分數"
scoreYen: "賺取的金額"
highScore: "最高分"
maxChain: "最大結合數"
yen: "{yen} 日圓"
estimatedQty: "{qty}個"
scoreSweets: "飯糰 {onigiriQtyWithUnit}"
_howToPlay: _howToPlay:
section1: "調整位置並將物體放入盒子中。" section1: "調整位置並將物體放入盒子中。"
section2: "當相同類型的物體黏在一起時,它們會變成不同的物體,您就會得到分數。" section2: "當相同類型的物體黏在一起時,它們會變成不同的物體,您就會得到分數。"
@ -1615,7 +1637,7 @@ _role:
baseRole: "基本角色" baseRole: "基本角色"
useBaseValue: "使用基本角色的值" useBaseValue: "使用基本角色的值"
chooseRoleToAssign: "選擇要指派的角色" chooseRoleToAssign: "選擇要指派的角色"
iconUrl: "圖示的URL" iconUrl: "圖示的 URL"
asBadge: "顯示為徽章" asBadge: "顯示為徽章"
descriptionOfAsBadge: "開啟的話,角色圖示會顯示在使用者名稱旁邊。" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在使用者名稱旁邊。"
isExplorable: "讓使用者更容易找到您" isExplorable: "讓使用者更容易找到您"
@ -1633,6 +1655,7 @@ _role:
gtlAvailable: "瀏覽全域時間軸" gtlAvailable: "瀏覽全域時間軸"
ltlAvailable: "瀏覽本地時間軸" ltlAvailable: "瀏覽本地時間軸"
canPublicNote: "允許公開貼文" canPublicNote: "允許公開貼文"
mentionMax: "貼文內的最大提及數"
canInvite: "發行伺服器邀請碼" canInvite: "發行伺服器邀請碼"
inviteLimit: "可建立邀請碼的數量" inviteLimit: "可建立邀請碼的數量"
inviteLimitCycle: "邀請碼的發放間隔" inviteLimitCycle: "邀請碼的發放間隔"
@ -1656,6 +1679,7 @@ _role:
canUseTranslator: "使用翻譯功能" canUseTranslator: "使用翻譯功能"
avatarDecorationLimit: "頭像裝飾的最大設置量" avatarDecorationLimit: "頭像裝飾的最大設置量"
_condition: _condition:
roleAssignedTo: "手動指派角色完成"
isLocal: "本地使用者" isLocal: "本地使用者"
isRemote: "遠端使用者" isRemote: "遠端使用者"
createdLessThan: "帳戶加入時間不超過" createdLessThan: "帳戶加入時間不超過"
@ -1756,6 +1780,8 @@ _aboutMisskey:
contributors: "主要貢獻者" contributors: "主要貢獻者"
allContributors: "全體貢獻人員" allContributors: "全體貢獻人員"
source: "原始碼" source: "原始碼"
original: "原始"
thisIsModifiedVersion: "{name} 使用原始 Misskey 的修改版本。"
translation: "翻譯 Misskey" translation: "翻譯 Misskey"
donate: "贊助 Misskey" donate: "贊助 Misskey"
morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰"
@ -2274,6 +2300,7 @@ _notification:
reactedBySomeUsers: "{n}人做出了反應" reactedBySomeUsers: "{n}人做出了反應"
renotedBySomeUsers: "{n}人做了轉發" renotedBySomeUsers: "{n}人做了轉發"
followedBySomeUsers: "被{n}人追隨了" followedBySomeUsers: "被{n}人追隨了"
flushNotification: "重置通知歷史紀錄"
_types: _types:
all: "全部 " all: "全部 "
note: "使用者的最新貼文" note: "使用者的最新貼文"
@ -2359,7 +2386,7 @@ _moderationLogTypes:
updateCustomEmoji: "更新自訂表情符號" updateCustomEmoji: "更新自訂表情符號"
deleteCustomEmoji: "刪除自訂表情符號" deleteCustomEmoji: "刪除自訂表情符號"
updateServerSettings: "更新伺服器設定" updateServerSettings: "更新伺服器設定"
updateUserNote: "更新管理筆記" updateUserNote: "更新了使用者的管理筆記"
deleteDriveFile: "刪除檔案" deleteDriveFile: "刪除檔案"
deleteNote: "刪除貼文" deleteNote: "刪除貼文"
createGlobalAnnouncement: "建立全網通知" createGlobalAnnouncement: "建立全網通知"
@ -2371,6 +2398,7 @@ _moderationLogTypes:
resetPassword: "重設密碼" resetPassword: "重設密碼"
suspendRemoteInstance: "封鎖遠端伺服器" suspendRemoteInstance: "封鎖遠端伺服器"
unsuspendRemoteInstance: "解除封鎖遠端伺服器" unsuspendRemoteInstance: "解除封鎖遠端伺服器"
updateRemoteInstanceNote: "更新了遠端伺服器的管理筆記"
markSensitiveDriveFile: "標記為敏感檔案" markSensitiveDriveFile: "標記為敏感檔案"
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
resolveAbuseReport: "解決檢舉" resolveAbuseReport: "解決檢舉"
@ -2491,6 +2519,8 @@ _reversi:
opponentHasSettingsChanged: "對手更改了設定" opponentHasSettingsChanged: "對手更改了設定"
allowIrregularRules: "允許異常規則(完全自由)" allowIrregularRules: "允許異常規則(完全自由)"
disallowIrregularRules: "不允許異常規則" disallowIrregularRules: "不允許異常規則"
showBoardLabels: "在棋盤上顯示行、列號"
useAvatarAsStone: "用大頭貼當作棋子"
_offlineScreen: _offlineScreen:
title: "離線-無法連接伺服器" title: "離線-無法連接伺服器"
header: "無法連接伺服器" header: "無法連接伺服器"

View file

@ -1,12 +1,12 @@
{ {
"name": "sharkey", "name": "sharkey",
"version": "2024.2.0-beta.12", "version": "2024.3.1",
"codename": "shonk", "codename": "shonk",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://activitypub.software/TransFem-org/Sharkey.git" "url": "https://activitypub.software/TransFem-org/Sharkey.git"
}, },
"packageManager": "pnpm@8.15.1", "packageManager": "pnpm@8.15.4",
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",
@ -48,23 +48,23 @@
"lodash": "4.17.21" "lodash": "4.17.21"
}, },
"dependencies": { "dependencies": {
"cssnano": "6.0.3", "cssnano": "6.0.5",
"execa": "8.0.1", "execa": "8.0.1",
"fast-glob": "3.3.2", "fast-glob": "3.3.2",
"ignore-walk": "6.0.4", "ignore-walk": "6.0.4",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"postcss": "8.4.33", "postcss": "8.4.35",
"tar": "6.2.0", "tar": "6.2.0",
"terser": "5.27.0", "terser": "5.28.1",
"typescript": "5.3.3" "typescript": "5.3.3"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "7.1.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.6.3", "cypress": "13.6.6",
"eslint": "8.56.0", "eslint": "8.57.0",
"start-server-and-test": "2.0.3", "ncp": "2.0.0",
"ncp": "2.0.0" "start-server-and-test": "2.0.3"
} }
} }

View file

@ -65,9 +65,9 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.412.0", "@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0", "@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.14.0", "@bull-board/api": "5.14.2",
"@bull-board/fastify": "5.14.0", "@bull-board/fastify": "5.14.2",
"@bull-board/ui": "5.14.0", "@bull-board/ui": "5.14.2",
"@discordapp/twemoji": "15.0.2", "@discordapp/twemoji": "15.0.2",
"@fastify/accepts": "4.3.0", "@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.3.1", "@fastify/cookie": "9.3.1",
@ -77,28 +77,28 @@
"@fastify/multipart": "8.1.0", "@fastify/multipart": "8.1.0",
"@fastify/static": "6.12.0", "@fastify/static": "6.12.0",
"@fastify/view": "8.2.0", "@fastify/view": "8.2.0",
"@misskey-dev/sharp-read-bmp": "^1.2.0", "@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "^5.0.3", "@misskey-dev/summaly": "5.0.3",
"@nestjs/common": "10.2.10", "@nestjs/common": "10.3.3",
"@nestjs/core": "10.2.10", "@nestjs/core": "10.3.3",
"@nestjs/testing": "10.2.10", "@nestjs/testing": "10.3.3",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@transfem-org/sfm-js": "0.24.4", "@simplewebauthn/server": "9.0.3",
"@simplewebauthn/server": "9.0.2",
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.10", "@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.107", "@swc/core": "1.3.107",
"@transfem-org/sfm-js": "0.24.4",
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
"argon2": "^0.31.1", "argon2": "^0.40.1",
"async-mutex": "0.4.1", "async-mutex": "0.4.1",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "5.1.9", "bullmq": "5.4.0",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "9.0.2",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -109,19 +109,19 @@
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify-multer": "^2.0.3",
"fastify": "4.25.2", "fastify": "4.25.2",
"fastify-multer": "^2.0.3",
"fastify-raw-body": "4.3.0", "fastify-raw-body": "4.3.0",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "19.0.0", "file-type": "19.0.0",
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0", "form-data": "4.0.0",
"glob": "10.3.10", "glob": "10.3.10",
"got": "14.1.0", "got": "14.2.0",
"happy-dom": "10.0.3", "happy-dom": "10.0.3",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "^1.1.1", "htmlescape": "1.1.1",
"http-link-header": "1.1.1", "http-link-header": "1.1.2",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"ip-cidr": "3.1.0", "ip-cidr": "3.1.0",
"ipaddr.js": "2.1.0", "ipaddr.js": "2.1.0",
@ -130,18 +130,18 @@
"jsdom": "23.2.0", "jsdom": "23.2.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.2", "jsonld": "8.3.2",
"jsrsasign": "11.1.0",
"megalodon": "workspace:*", "megalodon": "workspace:*",
"jsrsasign": "11.0.0",
"meilisearch": "0.37.0", "meilisearch": "0.37.0",
"microformats-parser": "2.0.2", "microformats-parser": "2.0.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"nanoid": "5.0.4", "nanoid": "5.0.6",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.8", "nodemailer": "6.9.10",
"oauth": "0.10.0", "oauth": "0.10.0",
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
@ -160,19 +160,19 @@
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.20.9", "re2": "1.20.9",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.14", "reflect-metadata": "0.2.1",
"rename": "1.0.4", "rename": "1.0.4",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"sanitize-html": "2.11.0", "sanitize-html": "2.12.1",
"secure-json-parse": "2.7.0", "secure-json-parse": "2.7.0",
"sharp": "0.33.2", "sharp": "0.33.2",
"slacc": "0.0.10", "slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"systeminformation": "5.21.24", "systeminformation": "5.22.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.2",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typeorm": "0.3.20", "typeorm": "0.3.20",
@ -187,7 +187,7 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@nestjs/platform-express": "10.3.1", "@nestjs/platform-express": "10.3.3",
"@simplewebauthn/types": "9.0.1", "@simplewebauthn/types": "9.0.1",
"@swc/jest": "0.2.31", "@swc/jest": "0.2.31",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
@ -206,21 +206,21 @@
"@types/jsrsasign": "10.5.12", "@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.11.17", "@types/node": "20.11.22",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14", "@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3", "@types/oauth2orize": "1.11.3",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.0", "@types/pg": "8.11.2",
"@types/pug": "2.0.10", "@types/pug": "2.0.10",
"@types/punycode": "2.1.3", "@types/punycode": "2.1.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5", "@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6", "@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7", "@types/rename": "1.0.7",
"@types/sanitize-html": "2.9.5", "@types/sanitize-html": "2.11.0",
"@types/semver": "7.5.6", "@types/semver": "7.5.8",
"@types/simple-oauth2": "5.0.7", "@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5", "@types/sinonjs__fake-timers": "8.1.5",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
@ -229,17 +229,17 @@
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.3", "@types/web-push": "3.6.3",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "7.1.0",
"aws-sdk-client-mock": "3.0.1", "aws-sdk-client-mock": "3.0.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.56.0", "eslint": "8.57.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"execa": "8.0.1", "execa": "8.0.1",
"fkill": "^9.0.0", "fkill": "^9.0.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-mock": "29.7.0", "jest-mock": "29.7.0",
"nodemon": "3.0.3", "nodemon": "3.1.0",
"pid-port": "1.0.0", "pid-port": "1.0.0",
"simple-oauth2": "5.0.0" "simple-oauth2": "5.0.0"
} }

View file

@ -20,7 +20,6 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
@ -60,7 +59,6 @@ export class AccountMoveService {
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private metaService: MetaService, private metaService: MetaService,
private relayService: RelayService, private relayService: RelayService,
private cacheService: CacheService,
private queueService: QueueService, private queueService: QueueService,
) { ) {
} }
@ -84,7 +82,7 @@ export class AccountMoveService {
Object.assign(src, update); Object.assign(src, update);
// Update cache // Update cache
this.cacheService.uriPersonCache.set(srcUri, src); this.globalEventService.publishInternalEvent('localUserUpdated', src);
const srcPerson = await this.apRendererService.renderPerson(src); const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));

View file

@ -129,10 +129,12 @@ export class CacheService implements OnApplicationShutdown {
switch (type) { switch (type) {
case 'userChangeSuspendedState': case 'userChangeSuspendedState':
case 'userChangeDeletedState': case 'userChangeDeletedState':
case 'remoteUserUpdated': { case 'remoteUserUpdated':
case 'localUserUpdated': {
const user = await this.usersRepository.findOneBy({ id: body.id }); const user = await this.usersRepository.findOneBy({ id: body.id });
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);

View file

@ -416,6 +416,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
return this.emojisRepository.findOneBy({ id }); return this.emojisRepository.findOneBy({ id });
} }
@bindThis
public getEmojiByName(name: string): Promise<MiEmoji | null> {
return this.emojisRepository.findOneBy({ name, host: IsNull() });
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.cache.dispose();

View file

@ -69,6 +69,7 @@ export interface MainEventTypes {
file: Packed<'DriveFile'>; file: Packed<'DriveFile'>;
}; };
readAllNotifications: undefined; readAllNotifications: undefined;
notificationFlushed: undefined;
unreadNotification: Packed<'Notification'>; unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id']; unreadMention: MiNote['id'];
readAllUnreadMentions: undefined; readAllUnreadMentions: undefined;
@ -216,6 +217,7 @@ export interface InternalEventTypes {
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; }; userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; }; remoteUserUpdated: { id: MiUser['id']; };
localUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };

View file

@ -479,176 +479,176 @@ export class MfmService {
const handlers: { const handlers: {
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any; [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
} = { } = {
async bold(node) { async bold(node) {
const el = doc.createElement('span'); const el = doc.createElement('span');
el.textContent = '**'; el.textContent = '**';
await appendChildren(node.children, el); await appendChildren(node.children, el);
el.textContent += '**'; el.textContent += '**';
return el; return el;
}, },
async small(node) { async small(node) {
const el = doc.createElement('small'); const el = doc.createElement('small');
await appendChildren(node.children, el); await appendChildren(node.children, el);
return el; return el;
}, },
async strike(node) { async strike(node) {
const el = doc.createElement('span'); const el = doc.createElement('span');
el.textContent = '~~'; el.textContent = '~~';
await appendChildren(node.children, el); await appendChildren(node.children, el);
el.textContent += '~~'; el.textContent += '~~';
return el; return el;
}, },
async italic(node) { async italic(node) {
const el = doc.createElement('span'); const el = doc.createElement('span');
el.textContent = '*'; el.textContent = '*';
await appendChildren(node.children, el); await appendChildren(node.children, el);
el.textContent += '*'; el.textContent += '*';
return el; return el;
}, },
async fn(node) { async fn(node) {
const el = doc.createElement('span'); const el = doc.createElement('span');
el.textContent = '*'; el.textContent = '*';
await appendChildren(node.children, el); await appendChildren(node.children, el);
el.textContent += '*'; el.textContent += '*';
return el; return el;
}, },
blockCode(node) { blockCode(node) {
const pre = doc.createElement('pre'); const pre = doc.createElement('pre');
const inner = doc.createElement('code'); const inner = doc.createElement('code');
const nodes = node.props.code const nodes = node.props.code
.split(/\r\n|\r|\n/) .split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x)); .map((x) => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) { for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
inner.appendChild(x === 'br' ? doc.createElement('br') : x); inner.appendChild(x === 'br' ? doc.createElement('br') : x);
} }
pre.appendChild(inner); pre.appendChild(inner);
return pre; return pre;
}, },
async center(node) { async center(node) {
const el = doc.createElement('div'); const el = doc.createElement('div');
await appendChildren(node.children, el); await appendChildren(node.children, el);
return el; return el;
}, },
emojiCode(node) { emojiCode(node) {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
}, },
unicodeEmoji(node) { unicodeEmoji(node) {
return doc.createTextNode(node.props.emoji); return doc.createTextNode(node.props.emoji);
}, },
hashtag: (node) => { hashtag: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`; a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag'); a.setAttribute('rel', 'tag');
a.setAttribute('class', 'hashtag'); a.setAttribute('class', 'hashtag');
return a; return a;
}, },
inlineCode(node) { inlineCode(node) {
const el = doc.createElement('code'); const el = doc.createElement('code');
el.textContent = node.props.code; el.textContent = node.props.code;
return el; return el;
}, },
mathInline(node) { mathInline(node) {
const el = doc.createElement('code'); const el = doc.createElement('code');
el.textContent = node.props.formula; el.textContent = node.props.formula;
return el; return el;
}, },
mathBlock(node) { mathBlock(node) {
const el = doc.createElement('code'); const el = doc.createElement('code');
el.textContent = node.props.formula; el.textContent = node.props.formula;
return el; return el;
}, },
async link(node) { async link(node) {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer'); a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url); a.setAttribute('href', node.props.url);
await appendChildren(node.children, a); await appendChildren(node.children, a);
return a; return a;
}, },
async mention(node) { async mention(node) {
const { username, host, acct } = node.props; const { username, host, acct } = node.props;
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
const el = doc.createElement('span'); const el = doc.createElement('span');
if (!resolved) { if (!resolved) {
el.textContent = acct; el.textContent = acct;
} else { } else {
el.setAttribute('class', 'h-card'); el.setAttribute('class', 'h-card');
el.setAttribute('translate', 'no'); el.setAttribute('translate', 'no');
const a = doc.createElement('a'); const a = doc.createElement('a');
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri); a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
a.className = 'u-url mention'; a.className = 'u-url mention';
const span = doc.createElement('span'); const span = doc.createElement('span');
span.textContent = resolved.username || username; span.textContent = resolved.username || username;
a.textContent = '@'; a.textContent = '@';
a.appendChild(span); a.appendChild(span);
el.appendChild(a); el.appendChild(a);
} }
return el; return el;
}, },
async quote(node) { async quote(node) {
const el = doc.createElement('blockquote'); const el = doc.createElement('blockquote');
await appendChildren(node.children, el); await appendChildren(node.children, el);
return el; return el;
}, },
text(node) { text(node) {
const el = doc.createElement('span'); const el = doc.createElement('span');
const nodes = node.props.text const nodes = node.props.text
.split(/\r\n|\r|\n/) .split(/\r\n|\r|\n/)
.map((x) => doc.createTextNode(x)); .map((x) => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) { for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x); el.appendChild(x === 'br' ? doc.createElement('br') : x);
} }
return el; return el;
}, },
url(node) { url(node) {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.setAttribute('rel', 'nofollow noopener noreferrer'); a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank'); a.setAttribute('target', '_blank');
a.setAttribute('href', node.props.url); a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, ''); a.textContent = node.props.url.replace(/^https?:\/\//, '');
return a; return a;
}, },
search: (node) => { search: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
a.setAttribute('href', `https"google.com/${node.props.query}`); a.setAttribute('href', `https"google.com/${node.props.query}`);
a.textContent = node.props.content; a.textContent = node.props.content;
return a; return a;
}, },
async plain(node) {
const el = doc.createElement('span');
await appendChildren(node.children, el);
return el;
},
};
async plain(node) {
const el = doc.createElement('span');
await appendChildren(node.children, el);
return el;
},
};
await appendChildren(nodes, doc.body); await appendChildren(nodes, doc.body);
if (quoteUri !== null) { if (quoteUri !== null) {

View file

@ -386,6 +386,10 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
} }
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then( setImmediate('post created', { signal: this.#shutdownController.signal }).then(
@ -438,7 +442,13 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) { const hasProhibitedWords = await this.checkProhibitedWordsContain({
cw: data.cw,
text: data.text,
pollChoices: data.poll?.choices,
}, meta.prohibitedWords);
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
} }
@ -554,6 +564,10 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then( setImmediate('post created', { signal: this.#shutdownController.signal }).then(
@ -1315,6 +1329,23 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
} }
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
if (prohibitedWords == null) {
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
}
if (
this.utilityService.isKeyWordIncluded(
this.utilityService.concatNoteContentsForKeyWordCheck(content),
prohibitedWords,
)
) {
return true;
}
return false;
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.#shutdownController.abort(); this.#shutdownController.abort();

View file

@ -51,6 +51,7 @@ import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js'; import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
@ -146,8 +147,6 @@ type Option = {
export class NoteEditService implements OnApplicationShutdown { export class NoteEditService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
public static ContainsProhibitedWordsError = class extends Error {};
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -283,7 +282,13 @@ export class NoteEditService implements OnApplicationShutdown {
} }
} }
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) { const hasProhibitedWords = await this.checkProhibitedWordsContain({
cw: data.cw,
text: data.text,
pollChoices: data.poll?.choices,
}, meta.prohibitedWords);
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
} }
@ -407,6 +412,10 @@ export class NoteEditService implements OnApplicationShutdown {
}); });
} }
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const update: Partial<MiNote> = {}; const update: Partial<MiNote> = {};
if (data.text !== oldnote.text) { if (data.text !== oldnote.text) {
update.text = data.text; update.text = data.text;
@ -790,7 +799,7 @@ export class NoteEditService implements OnApplicationShutdown {
const mentions = extractMentions(tokens); const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m => let mentionedUsers = (await Promise.all(mentions.map(m =>
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
))).filter(x => x != null) as MiUser[]; ))).filter(isNotNull) as MiUser[];
// Drop duplicate users // Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) => mentionedUsers = mentionedUsers.filter((u, i, self) =>
@ -964,6 +973,23 @@ export class NoteEditService implements OnApplicationShutdown {
} }
} }
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
if (prohibitedWords == null) {
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
}
if (
this.utilityService.isKeyWordIncluded(
this.utilityService.concatNoteContentsForKeyWordCheck(content),
prohibitedWords,
)
) {
return true;
}
return false;
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.#shutdownController.abort(); this.#shutdownController.abort();

View file

@ -70,7 +70,7 @@ export class NoteReadService implements OnApplicationShutdown {
already present in the `note_unread` table: `upsert` makes sure already present in the `note_unread` table: `upsert` makes sure
we don't throw a "duplicate key" error, while still updating we don't throw a "duplicate key" error, while still updating
the other columns if they've changed */ the other columns if they've changed */
await this.noteUnreadsRepository.upsert(unread,['userId', 'noteId']); await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']);
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {

View file

@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown {
return null; return null;
} }
} else if (recieveConfig?.type === 'mutualFollow') { } else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
]);
if (!(isFollowing && isFollower)) {
return null;
}
} else if (recieveConfig?.type === 'followingOrFollower') {
const [isFollowing, isFollower] = await Promise.all([ const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)), this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown {
const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
if (packed == null) return null;
// Publish notification event // Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed); this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
@ -204,6 +214,15 @@ export class NotificationService implements OnApplicationShutdown {
*/ */
} }
@bindThis
public async flushAllNotifications(userId: MiUser['id']) {
await Promise.all([
this.redisClient.del(`notificationTimeline:${userId}`),
this.redisClient.del(`latestReadNotification:${userId}`),
]);
this.globalEventService.publishMainStream(userId, 'notificationFlushed');
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.#shutdownController.abort(); this.#shutdownController.abort();

View file

@ -36,6 +36,7 @@ export type RolePolicies = {
ltlAvailable: boolean; ltlAvailable: boolean;
btlAvailable: boolean; btlAvailable: boolean;
canPublicNote: boolean; canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean; canInvite: boolean;
inviteLimit: number; inviteLimit: number;
inviteLimitCycle: number; inviteLimitCycle: number;
@ -65,6 +66,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true, ltlAvailable: true,
btlAvailable: false, btlAvailable: false,
canPublicNote: true, canPublicNote: true,
mentionLimit: 20,
canInvite: false, canInvite: false,
inviteLimit: 0, inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7, inviteLimitCycle: 60 * 24 * 7,
@ -204,17 +206,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean { private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
try { try {
switch (value.type) { switch (value.type) {
case 'and': { case 'and': {
return value.values.every(v => this.evalCond(user, v)); return value.values.every(v => this.evalCond(user, roles, v));
} }
case 'or': { case 'or': {
return value.values.some(v => this.evalCond(user, v)); return value.values.some(v => this.evalCond(user, roles, v));
} }
case 'not': { case 'not': {
return !this.evalCond(user, value.value); return !this.evalCond(user, roles, value.value);
}
case 'roleAssignedTo': {
return roles.some(r => r.id === value.roleId);
} }
case 'isLocal': { case 'isLocal': {
return this.userEntityService.isLocalUser(user); return this.userEntityService.isLocalUser(user);
@ -276,7 +281,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
const assigns = await this.getUserAssigns(userId); const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id)); const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
return [...assignedRoles, ...matchedCondRoles]; return [...assignedRoles, ...matchedCondRoles];
} }
@ -289,13 +294,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外 // 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) { if (badgeCondRoles.length > 0) {
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
} else { } else {
return assignedBadgeRoles; return assignedBadgeRoles;
@ -330,6 +335,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)), btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),

View file

@ -101,33 +101,24 @@ export class UserFollowingService implements OnModuleInit {
this.queueService.deliver(followee, content, follower.inbox, false); this.queueService.deliver(followee, content, follower.inbox, false);
} }
/**
* ThinUserでなくともユーザーの情報が最新でない場合はこちらを使うべき
*/
@bindThis
public async followByThinUser(
_follower: ThinUser,
_followee: ThinUser,
options: Parameters<typeof this.follow>[2] = {},
) {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
await this.follow(follower, followee, options);
}
@bindThis @bindThis
public async follow( public async follow(
follower: MiLocalUser | MiRemoteUser, _follower: ThinUser,
followee: MiLocalUser | MiRemoteUser, _followee: ThinUser,
{ requestId, silent = false, withReplies }: { { requestId, silent = false, withReplies }: {
requestId?: string, requestId?: string,
silent?: boolean, silent?: boolean,
withReplies?: boolean, withReplies?: boolean,
} = {}, } = {},
): Promise<void> { ): Promise<void> {
/**
*
*/
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) {
// What? // What?
throw new Error('Remote user cannot follow remote user.'); throw new Error('Remote user cannot follow remote user.');

View file

@ -42,6 +42,20 @@ export class UtilityService {
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
@bindThis
public concatNoteContentsForKeyWordCheck(content: {
cw?: string | null;
text?: string | null;
pollChoices?: string[] | null;
others?: string[] | null;
}): string {
/**
*
* cwとtextは内容が繋がっているかもしれないので間に何も入れずにチェックする
*/
return `${content.cw ?? ''}${content.text ?? ''}\n${(content.pollChoices ?? []).join('\n')}\n${(content.others ?? []).join('\n')}`;
}
@bindThis @bindThis
public isKeyWordIncluded(text: string, keyWords: string[]): boolean { public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
if (keyWords.length === 0) return false; if (keyWords.length === 0) return false;

View file

@ -191,7 +191,7 @@ export class WebAuthnService {
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
const halfLength = (cert.length - 1) / 2; const halfLength = (cert.length - 1) / 2;
const cborMap = new Map<number, number | ArrayBufferLike>(); const cborMap = new Map<number, number | Uint8Array>();
cborMap.set(1, 2); // kty, EC2 cborMap.set(1, 2); // kty, EC2
cborMap.set(3, -7); // alg, ES256 cborMap.set(3, -7); // alg, ES256
cborMap.set(-1, 1); // crv, P256 cborMap.set(-1, 1); // crv, P256

View file

@ -36,7 +36,6 @@ import { ApResolverService } from './ApResolverService.js';
import { ApAudienceService } from './ApAudienceService.js'; import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js'; import { ApQuestionService } from './models/ApQuestionService.js';
import { CacheService } from '@/core/CacheService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Resolver } from './ApResolverService.js'; import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';

View file

@ -25,6 +25,8 @@ import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js'; import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js'; import { ApMfmService } from '../ApMfmService.js';
@ -38,7 +40,6 @@ import { ApQuestionService } from './ApQuestionService.js';
import { ApImageService } from './ApImageService.js'; import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js'; import type { IObject, IPost } from '../type.js';
import { isNotNull } from '@/misc/is-not-null.js';
@Injectable() @Injectable()
export class ApNoteService { export class ApNoteService {
@ -157,11 +158,47 @@ export class ApNoteService {
throw new Error('invalid note.attributedTo: ' + note.attributedTo); throw new Error('invalid note.attributedTo: ' + note.attributedTo);
} }
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; const uri = getOneApId(note.attributedTo);
// 投稿者が凍結されていたらスキップ // ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
//#region Contents Check
// 添付ファイルとユーザーをこのサーバーで登録する前に内容をチェックする
/**
*
*/
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
//#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) { if (actor.isSuspended) {
throw new Error('actor has been suspended'); throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
} }
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
@ -176,9 +213,6 @@ export class ApNoteService {
} }
} }
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
// 添付ファイル // 添付ファイル
// TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない // TODO: attachmentは必ずしも配列ではない
@ -238,18 +272,6 @@ export class ApNoteService {
} }
} }
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
// vote // vote
if (reply && reply.hasPoll) { if (reply && reply.hasPoll) {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
@ -279,8 +301,6 @@ export class ApNoteService {
const apEmojis = emojis.map(emoji => emoji.name); const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
try { try {
return await this.noteCreateService.create(actor, { return await this.noteCreateService.create(actor, {
createdAt: note.published ? new Date(note.published) : null, createdAt: note.published ? new Date(note.published) : null,
@ -318,14 +338,14 @@ export class ApNoteService {
*/ */
@bindThis @bindThis
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> { public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
const uri = typeof value === 'string' ? value : value.id; const noteUri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null'); if (noteUri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ // URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); if (noteUri.startsWith(this.config.url + '/')) throw new Error('uri points local');
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const UpdatedNote = await this.notesRepository.findOneBy({ uri }); const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
if (UpdatedNote == null) throw new Error('Note is not registered'); if (UpdatedNote == null) throw new Error('Note is not registered');
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
@ -365,11 +385,47 @@ export class ApNoteService {
throw new Error('invalid note.attributedTo: ' + note.attributedTo); throw new Error('invalid note.attributedTo: ' + note.attributedTo);
} }
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; const uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
//#region Contents Check
// 添付ファイルとユーザーをこのサーバーで登録する前に内容をチェックする
/**
*
*/
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
//#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 投稿者が凍結されていたらスキップ // 投稿者が凍結されていたらスキップ
if (actor.isSuspended) { if (actor.isSuspended) {
throw new Error('actor has been suspended'); throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
} }
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
@ -384,9 +440,6 @@ export class ApNoteService {
} }
} }
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
// 添付ファイル // 添付ファイル
// TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない // TODO: attachmentは必ずしも配列ではない
@ -446,24 +499,12 @@ export class ApNoteService {
} }
} }
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
// vote // vote
if (reply && reply.hasPoll) { if (reply && reply.hasPoll) {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); const replyPoll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
const tryCreateVote = async (name: string, index: number): Promise<null> => { const tryCreateVote = async (name: string, index: number): Promise<null> => {
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { if (replyPoll.expiresAt && Date.now() > new Date(replyPoll.expiresAt).getTime()) {
this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
} else if (index >= 0) { } else if (index >= 0) {
this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
@ -476,7 +517,7 @@ export class ApNoteService {
}; };
if (note.name) { if (note.name) {
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); return await tryCreateVote(note.name, replyPoll.choices.findIndex(x => x === note.name));
} }
} }
@ -487,8 +528,6 @@ export class ApNoteService {
const apEmojis = emojis.map(emoji => emoji.name); const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
try { try {
return await this.noteEditService.edit(actor, UpdatedNote.id, { return await this.noteEditService.edit(actor, UpdatedNote.id, {
createdAt: note.published ? new Date(note.published) : null, createdAt: note.published ? new Date(note.published) : null,

View file

@ -69,4 +69,19 @@ export class NoteReactionEntityService implements OnModuleInit {
} : {}), } : {}),
}; };
} }
@bindThis
public async packMany(
reactions: MiNoteReaction[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
withNote: boolean;
},
): Promise<Packed<'NoteReaction'>[]> {
const opts = Object.assign({
withNote: false,
}, options);
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts)));
}
} }

View file

@ -14,14 +14,14 @@ import type { MiNote } from '@/models/Note.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js'; import { isNotNull } from '@/misc/is-not-null.js';
import { FilterUnionByProperty, notificationTypes } from '@/types.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
import { CacheService } from '@/core/CacheService.js';
import { RoleEntityService } from './RoleEntityService.js'; import { RoleEntityService } from './RoleEntityService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'edited'] as (typeof notificationTypes[number])[]); const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited']);
@Injectable() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {
@ -41,6 +41,8 @@ export class NotificationEntityService implements OnModuleInit {
@Inject(DI.followRequestsRepository) @Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository, private followRequestsRepository: FollowRequestsRepository,
private cacheService: CacheService,
//private userEntityService: UserEntityService, //private userEntityService: UserEntityService,
//private noteEntityService: NoteEntityService, //private noteEntityService: NoteEntityService,
) { ) {
@ -52,13 +54,15 @@ export class NotificationEntityService implements OnModuleInit {
this.roleEntityService = this.moduleRef.get('RoleEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService');
} }
@bindThis /**
public async pack( *
src: MiNotification, */
async #packInternal <T extends MiNotification | MiGroupedNotification> (
src: T,
meId: MiUser['id'], meId: MiUser['id'],
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
options: { options: {
checkValidNotifier?: boolean;
}, },
hint?: { hint?: {
packedNotes: Map<MiNote['id'], Packed<'Note'>>; packedNotes: Map<MiNote['id'], Packed<'Note'>>;
@ -66,6 +70,9 @@ export class NotificationEntityService implements OnModuleInit {
}, },
): Promise<Packed<'Notification'> | null> { ): Promise<Packed<'Notification'> | null> {
const notification = src; const notification = src;
if (options.checkValidNotifier !== false && !(await this.#isValidNotifier(notification, meId))) return null;
const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification; const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification;
const noteIfNeed = needsNote ? ( const noteIfNeed = needsNote ? (
hint?.packedNotes != null hint?.packedNotes != null
@ -75,9 +82,7 @@ export class NotificationEntityService implements OnModuleInit {
}) })
) : undefined; ) : undefined;
// if the note has been deleted, don't show this notification // if the note has been deleted, don't show this notification
if (needsNote && !noteIfNeed) { if (needsNote && !noteIfNeed) return null;
return null;
}
const needsUser = 'notifierId' in notification; const needsUser = 'notifierId' in notification;
const userIfNeed = needsUser ? ( const userIfNeed = needsUser ? (
@ -86,122 +91,9 @@ export class NotificationEntityService implements OnModuleInit {
: this.userEntityService.pack(notification.notifierId, { id: meId }) : this.userEntityService.pack(notification.notifierId, { id: meId })
) : undefined; ) : undefined;
// if the user has been deleted, don't show this notification // if the user has been deleted, don't show this notification
if (needsUser && !userIfNeed) { if (needsUser && !userIfNeed) return null;
return null;
}
const needsRole = notification.type === 'roleAssigned';
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
// if the role has been deleted, don't show this notification
if (needsRole && !role) {
return null;
}
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type,
userId: 'notifierId' in notification ? notification.notifierId : undefined,
...(userIfNeed != null ? { user: userIfNeed } : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
reaction: notification.reaction,
} : {}),
...(notification.type === 'roleAssigned' ? {
role: role,
} : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader,
icon: notification.customIcon,
} : {}),
});
}
@bindThis
public async packMany(
notifications: MiNotification[],
meId: MiUser['id'],
) {
if (notifications.length === 0) return [];
let validNotifications = notifications;
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) },
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
}) : [];
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull);
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },
}) : [];
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId });
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
// 既に解決されたフォローリクエストの通知を除外
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
if (followRequestNotifications.length > 0) {
const reqs = await this.followRequestsRepository.find({
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
});
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
}
return (await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
packedNotes,
packedUsers,
})))).filter(n => n);
}
@bindThis
public async packGrouped(
src: MiGroupedNotification,
meId: MiUser['id'],
// eslint-disable-next-line @typescript-eslint/ban-types
options: {
},
hint?: {
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
},
): Promise<Packed<'Notification'> | null> {
const notification = src;
const needsNote = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification;
const noteIfNeed = needsNote ? (
hint?.packedNotes != null
? hint.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
detail: true,
})
) : undefined;
// if the note has been deleted, don't show this notification
if (needsNote && !noteIfNeed) {
return null;
}
const needsUser = 'notifierId' in notification;
const userIfNeed = needsUser ? (
hint?.packedUsers != null
? hint.packedUsers.get(notification.notifierId)
: this.userEntityService.pack(notification.notifierId, { id: meId })
) : undefined;
// if the user has been deleted, don't show this notification
if (needsUser && !userIfNeed) {
return null;
}
// #region Grouped notifications
if (notification.type === 'reaction:grouped') { if (notification.type === 'reaction:grouped') {
const reactions = (await Promise.all(notification.reactions.map(async reaction => { const reactions = (await Promise.all(notification.reactions.map(async reaction => {
const user = hint?.packedUsers != null const user = hint?.packedUsers != null
@ -211,9 +103,9 @@ export class NotificationEntityService implements OnModuleInit {
user, user,
reaction: reaction.reaction, reaction: reaction.reaction,
}; };
}))).filter(r => r.user); }))).filter(r => isNotNull(r.user));
// if all users have been deleted, don't show this notification // if all users have been deleted, don't show this notification
if (!reactions.length) { if (reactions.length === 0) {
return null; return null;
} }
@ -232,9 +124,9 @@ export class NotificationEntityService implements OnModuleInit {
} }
return this.userEntityService.pack(userId, { id: meId }); return this.userEntityService.pack(userId, { id: meId });
}))).filter(u => u); }))).filter(isNotNull);
// if all users have been deleted, don't show this notification // if all users have been deleted, don't show this notification
if (!users.length) { if (users.length === 0) {
return null; return null;
} }
@ -246,6 +138,7 @@ export class NotificationEntityService implements OnModuleInit {
users, users,
}); });
} }
// #endregion
const needsRole = notification.type === 'roleAssigned'; const needsRole = notification.type === 'roleAssigned';
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined; const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
@ -278,15 +171,16 @@ export class NotificationEntityService implements OnModuleInit {
}); });
} }
@bindThis async #packManyInternal <T extends MiNotification | MiGroupedNotification> (
public async packGroupedMany( notifications: T[],
notifications: MiGroupedNotification[],
meId: MiUser['id'], meId: MiUser['id'],
) { ): Promise<T[]> {
if (notifications.length === 0) return []; if (notifications.length === 0) return [];
let validNotifications = notifications; let validNotifications = notifications;
validNotifications = await this.#filterValidNotifier(validNotifications, meId);
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({ const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) }, where: { id: In(noteIds) },
@ -312,7 +206,7 @@ export class NotificationEntityService implements OnModuleInit {
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
// 既に解決されたフォローリクエストの通知を除外 // 既に解決されたフォローリクエストの通知を除外
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest'); const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<T, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
if (followRequestNotifications.length > 0) { if (followRequestNotifications.length > 0) {
const reqs = await this.followRequestsRepository.find({ const reqs = await this.followRequestsRepository.find({
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
@ -320,9 +214,107 @@ export class NotificationEntityService implements OnModuleInit {
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
} }
return (await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { const packPromises = validNotifications.map(x => {
packedNotes, return this.pack(
packedUsers, x,
})))).filter(n => n); meId,
{ checkValidNotifier: false },
{ packedNotes, packedUsers },
);
});
return (await Promise.all(packPromises)).filter(isNotNull);
}
@bindThis
public async pack(
src: MiNotification | MiGroupedNotification,
meId: MiUser['id'],
// eslint-disable-next-line @typescript-eslint/ban-types
options: {
checkValidNotifier?: boolean;
},
hint?: {
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
},
): Promise<Packed<'Notification'> | null> {
return await this.#packInternal(src, meId, options, hint);
}
@bindThis
public async packMany(
notifications: MiNotification[],
meId: MiUser['id'],
): Promise<MiNotification[]> {
return await this.#packManyInternal(notifications, meId);
}
@bindThis
public async packGroupedMany(
notifications: MiGroupedNotification[],
meId: MiUser['id'],
): Promise<MiGroupedNotification[]> {
return await this.#packManyInternal(notifications, meId);
}
/**
* notifierが存在するかvalidator
*/
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>,
userMutedInstances: Set<string>,
notifiers: MiUser[],
): boolean {
if (!('notifierId' in notification)) return true;
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
if (notifier == null) return false;
if (notifier.host && userMutedInstances.has(notifier.host)) return false;
if (notifier.isSuspended) return false;
return true;
}
/**
* notifierが存在するか
*/
async #isValidNotifier(
notification: MiNotification | MiGroupedNotification,
meId: MiUser['id'],
): Promise<boolean> {
return (await this.#filterValidNotifier([notification], meId)).length === 1;
}
/**
* notifierが存在するか
*/
async #filterValidNotifier <T extends MiNotification | MiGroupedNotification> (
notifications: T[],
meId: MiUser['id'],
): Promise<T[]> {
const [
userIdsWhoMeMuting,
userMutedInstances,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId),
this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)),
]);
const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull);
const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({
where: { id: In(notifierIds) },
}) : [];
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => {
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
return isValid ? notification : null;
}))) as [T | null] ).filter(isNotNull);
return filteredNotifications;
} }
} }

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs/promises';
import type { PathLike } from 'node:fs';
/**
* `fs.createWriteStream()``WritableStream` (Web標準)
*/
export class FileWriterStream extends WritableStream<Uint8Array> {
constructor(path: PathLike) {
let file: fs.FileHandle | null = null;
super({
start: async () => {
file = await fs.open(path, 'a');
},
write: async (chunk, controller) => {
if (file === null) {
controller.error();
throw new Error();
}
await file.write(chunk);
},
close: async () => {
await file?.close();
},
abort: async () => {
await file?.close();
},
});
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TransformStream } from 'node:stream/web';
/**
* `JSON.stringify()`
*/
export class JsonArrayStream extends TransformStream<unknown, string> {
constructor() {
/** 最初の要素かどうかを変数に記録 */
let isFirst = true;
super({
start(controller) {
controller.enqueue('[');
},
flush(controller) {
controller.enqueue(']');
},
transform(chunk, controller) {
if (isFirst) {
isFirst = false;
} else {
// 妥当なJSON配列にするためには最初以外の要素の前に`,`を挿入しなければならない
controller.enqueue(',\n');
}
controller.enqueue(JSON.stringify(chunk));
},
});
}
}

View file

@ -187,6 +187,10 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> { export class MemoryKVCache<T> {
/**
*
* @deprecated
*/
public cache: Map<string, { date: number; value: T; }>; public cache: Map<string, { date: number; value: T; }>;
private lifetime: number; private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout; private gcIntervalHandle: NodeJS.Timeout;
@ -201,6 +205,10 @@ export class MemoryKVCache<T> {
} }
@bindThis @bindThis
/**
* Mapにキャッシュをセットします
* @deprecated InternalEventなどで変更を全てのプロセス/
*/
public set(key: string, value: T): void { public set(key: string, value: T): void {
this.cache.set(key, { this.cache.set(key, {
date: Date.now(), date: Date.now(),

View file

@ -44,6 +44,7 @@ import {
packedRoleCondFormulaLogicsSchema, packedRoleCondFormulaLogicsSchema,
packedRoleCondFormulaValueNot, packedRoleCondFormulaValueNot,
packedRoleCondFormulaValueIsLocalOrRemoteSchema, packedRoleCondFormulaValueIsLocalOrRemoteSchema,
packedRoleCondFormulaValueAssignedRoleSchema,
packedRoleCondFormulaValueCreatedSchema, packedRoleCondFormulaValueCreatedSchema,
packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
packedRoleCondFormulaValueSchema, packedRoleCondFormulaValueSchema,
@ -96,6 +97,7 @@ export const refs = {
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
RoleCondFormulaValueNot: packedRoleCondFormulaValueNot, RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema, RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema, RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
RoleCondFormulaValue: packedRoleCondFormulaValueSchema, RoleCondFormulaValue: packedRoleCondFormulaValueSchema,

View file

@ -29,6 +29,11 @@ type CondFormulaValueIsRemote = {
type: 'isRemote'; type: 'isRemote';
}; };
type CondFormulaValueRoleAssignedTo = {
type: 'roleAssignedTo';
roleId: string;
};
type CondFormulaValueCreatedLessThan = { type CondFormulaValueCreatedLessThan = {
type: 'createdLessThan'; type: 'createdLessThan';
sec: number; sec: number;
@ -75,6 +80,7 @@ export type RoleCondFormulaValue = { id: string } & (
CondFormulaValueNot | CondFormulaValueNot |
CondFormulaValueIsLocal | CondFormulaValueIsLocal |
CondFormulaValueIsRemote | CondFormulaValueIsRemote |
CondFormulaValueRoleAssignedTo |
CondFormulaValueCreatedLessThan | CondFormulaValueCreatedLessThan |
CondFormulaValueCreatedMoreThan | CondFormulaValueCreatedMoreThan |
CondFormulaValueFollowersLessThanOrEq | CondFormulaValueFollowersLessThanOrEq |

View file

@ -256,6 +256,8 @@ export class MiUserProfile {
type: 'follower'; type: 'follower';
} | { } | {
type: 'mutualFollow'; type: 'mutualFollow';
} | {
type: 'followingOrFollower';
} | { } | {
type: 'list'; type: 'list';
userListId: MiUserList['id']; userListId: MiUserList['id'];

View file

@ -57,6 +57,26 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
}, },
} as const; } as const;
export const packedRoleCondFormulaValueAssignedRoleSchema = {
type: 'object',
properties: {
id: {
type: 'string', optional: false,
},
type: {
type: 'string',
nullable: false, optional: false,
enum: ['roleAssignedTo'],
},
roleId: {
type: 'string',
nullable: false, optional: false,
format: 'id',
example: 'xxxxxxxxxx',
},
},
} as const;
export const packedRoleCondFormulaValueCreatedSchema = { export const packedRoleCondFormulaValueCreatedSchema = {
type: 'object', type: 'object',
properties: { properties: {
@ -115,6 +135,9 @@ export const packedRoleCondFormulaValueSchema = {
{ {
ref: 'RoleCondFormulaValueIsLocalOrRemote', ref: 'RoleCondFormulaValueIsLocalOrRemote',
}, },
{
ref: 'RoleCondFormulaValueAssignedRole',
},
{ {
ref: 'RoleCondFormulaValueCreated', ref: 'RoleCondFormulaValueCreated',
}, },
@ -136,10 +159,18 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
btlAvailable: {
type: 'boolean',
optional: false, nullable: false,
},
canPublicNote: { canPublicNote: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
mentionLimit: {
type: 'integer',
optional: false, nullable: false,
},
canInvite: { canInvite: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -13,7 +13,7 @@ export const notificationRecieveConfig = {
type: { type: {
type: 'string', type: 'string',
nullable: false, nullable: false,
enum: ['all', 'following', 'follower', 'mutualFollow', 'never'], enum: ['all', 'following', 'follower', 'mutualFollow', 'followingOrFollower', 'never'],
}, },
}, },
required: ['type'], required: ['type'],
@ -170,6 +170,9 @@ export const packedUserLiteSchema = {
emojis: { emojis: {
type: 'object', type: 'object',
nullable: false, optional: false, nullable: false, optional: false,
additionalProperties: {
type: 'string',
},
}, },
onlineStatus: { onlineStatus: {
type: 'string', type: 'string',

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as fs from 'node:fs'; import { ReadableStream, TextEncoderStream } from 'node:stream/web';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm'; import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
@ -18,10 +18,82 @@ import { bindThis } from '@/decorators.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { Packed } from '@/misc/json-schema.js'; import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { JsonArrayStream } from '@/misc/JsonArrayStream.js';
import { FileWriterStream } from '@/misc/FileWriterStream.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
class NoteStream extends ReadableStream<Record<string, unknown>> {
constructor(
job: Bull.Job,
notesRepository: NotesRepository,
pollsRepository: PollsRepository,
driveFileEntityService: DriveFileEntityService,
idService: IdService,
userId: string,
) {
let exportedNotesCount = 0;
let cursor: MiNote['id'] | null = null;
const serialize = (
note: MiNote,
poll: MiPoll | null,
files: Packed<'DriveFile'>[],
): Record<string, unknown> => {
return {
id: note.id,
text: note.text,
createdAt: idService.parse(note.id).date.toISOString(),
fileIds: note.fileIds,
files: files,
replyId: note.replyId,
renoteId: note.renoteId,
poll: poll,
cw: note.cw,
visibility: note.visibility,
visibleUserIds: note.visibleUserIds,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
};
};
super({
async pull(controller): Promise<void> {
const notes = await notesRepository.find({
where: {
userId,
...(cursor !== null ? { id: MoreThan(cursor) } : {}),
},
take: 100, // 100件ずつ取得
order: { id: 1 },
});
if (notes.length === 0) {
job.updateProgress(100);
controller.close();
}
cursor = notes.at(-1)?.id ?? null;
for (const note of notes) {
const poll = note.hasPoll
? await pollsRepository.findOneByOrFail({ noteId: note.id }) // N+1
: null;
const files = await driveFileEntityService.packManyByIds(note.fileIds); // N+1
const content = serialize(note, poll, files);
controller.enqueue(content);
exportedNotesCount++;
}
const total = await notesRepository.countBy({ userId });
job.updateProgress(exportedNotesCount / total);
},
});
}
}
@Injectable() @Injectable()
export class ExportNotesProcessorService { export class ExportNotesProcessorService {
private logger: Logger; private logger: Logger;
@ -59,67 +131,19 @@ export class ExportNotesProcessorService {
this.logger.info(`Temp file is ${path}`); this.logger.info(`Temp file is ${path}`);
try { try {
const stream = fs.createWriteStream(path, { flags: 'a' }); // メモリが足りなくならないようにストリームで処理する
await new NoteStream(
job,
this.notesRepository,
this.pollsRepository,
this.driveFileEntityService,
this.idService,
user.id,
)
.pipeThrough(new JsonArrayStream())
.pipeThrough(new TextEncoderStream())
.pipeTo(new FileWriterStream(path));
const write = (text: string): Promise<void> => {
return new Promise<void>((res, rej) => {
stream.write(text, err => {
if (err) {
this.logger.error(err);
rej(err);
} else {
res();
}
});
});
};
await write('[');
let exportedNotesCount = 0;
let cursor: MiNote['id'] | null = null;
while (true) {
const notes = await this.notesRepository.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
}) as MiNote[];
if (notes.length === 0) {
job.updateProgress(100);
break;
}
cursor = notes.at(-1)?.id ?? null;
for (const note of notes) {
let poll: MiPoll | undefined;
if (note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id });
}
const files = await this.driveFileEntityService.packManyByIds(note.fileIds);
const content = JSON.stringify(this.serialize(note, poll, files));
const isFirst = exportedNotesCount === 0;
await write(isFirst ? content : ',\n' + content);
exportedNotesCount++;
}
const total = await this.notesRepository.countBy({
userId: user.id,
});
job.updateProgress(exportedNotesCount / total);
}
await write(']');
stream.end();
this.logger.succ(`Exported to: ${path}`); this.logger.succ(`Exported to: ${path}`);
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
@ -130,22 +154,4 @@ export class ExportNotesProcessorService {
cleanup(); cleanup();
} }
} }
private serialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record<string, unknown> {
return {
id: note.id,
text: note.text,
createdAt: this.idService.parse(note.id).date.toISOString(),
fileIds: note.fileIds,
files: files,
replyId: note.replyId,
renoteId: note.renoteId,
poll: poll,
cw: note.cw,
visibility: note.visibility,
visibleUserIds: note.visibleUserIds,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,
};
}
} }

View file

@ -1,6 +1,5 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises'; import * as fsp from 'node:fs/promises';
import * as vm from 'node:vm';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ZipReader } from 'slacc'; import { ZipReader } from 'slacc';

View file

@ -196,7 +196,10 @@ export class InboxProcessorService {
await this.apInboxService.performActivity(authUser.user, activity); await this.apInboxService.performActivity(authUser.user, activity);
} catch (e) { } catch (e) {
if (e instanceof IdentifiableError) { if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') return 'blocked notes with prohibited words'; if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
return 'blocked notes with prohibited words';
}
if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended';
} }
throw e; throw e;
} }

View file

@ -35,7 +35,7 @@ export class RelationshipProcessorService {
@bindThis @bindThis
public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> { public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id} ${job.data.withReplies ? "with replies" : "without replies"}`); this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id} ${job.data.withReplies ? "with replies" : "without replies"}`);
await this.userFollowingService.followByThinUser(job.data.from, job.data.to, { await this.userFollowingService.follow(job.data.from, job.data.to, {
requestId: job.data.requestId, requestId: job.data.requestId,
silent: job.data.silent, silent: job.data.silent,
withReplies: job.data.withReplies, withReplies: job.data.withReplies,

View file

@ -305,6 +305,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js';
import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pagePush from './endpoints/page-push.js';
@ -689,6 +690,7 @@ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timelin
const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default }; const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default };
const $notes_versions: Provider = { provide: 'ep:notes/versions', useClass: ep___notes_versions.default }; const $notes_versions: Provider = { provide: 'ep:notes/versions', useClass: ep___notes_versions.default };
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default };
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default };
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
@ -1077,6 +1079,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_edit, $notes_edit,
$notes_versions, $notes_versions,
$notifications_create, $notifications_create,
$notifications_flush,
$notifications_markAllAsRead, $notifications_markAllAsRead,
$notifications_testNotification, $notifications_testNotification,
$pagePush, $pagePush,
@ -1459,7 +1462,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$notes_edit, $notes_edit,
$notes_versions, $notes_versions,
$notifications_create, $notifications_create,
$notifications_flush,
$notifications_markAllAsRead, $notifications_markAllAsRead,
$notifications_testNotification,
$pagePush, $pagePush,
$pages_create, $pages_create,
$pages_delete, $pages_delete,

View file

@ -305,6 +305,7 @@ import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeli
import * as ep___notes_edit from './endpoints/notes/edit.js'; import * as ep___notes_edit from './endpoints/notes/edit.js';
import * as ep___notes_versions from './endpoints/notes/versions.js'; import * as ep___notes_versions from './endpoints/notes/versions.js';
import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pagePush from './endpoints/page-push.js';
@ -687,6 +688,7 @@ const eps = [
['notes/edit', ep___notes_edit], ['notes/edit', ep___notes_edit],
['notes/versions', ep___notes_versions], ['notes/versions', ep___notes_versions],
['notifications/create', ep___notifications_create], ['notifications/create', ep___notifications_create],
['notifications/flush', ep___notifications_flush],
['notifications/mark-all-as-read', ep___notifications_markAllAsRead], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
['notifications/test-notification', ep___notifications_testNotification], ['notifications/test-notification', ep___notifications_testNotification],
['page-push', ep___pagePush], ['page-push', ep___pagePush],

View file

@ -31,7 +31,10 @@ export const meta = {
}, },
}, },
ref: 'EmojiDetailed', res: {
type: 'object',
ref: 'EmojiDetailed',
},
} as const; } as const;
export const paramDef = { export const paramDef = {

View file

@ -57,7 +57,10 @@ export const paramDef = {
type: 'string', type: 'string',
} }, } },
}, },
required: ['id', 'name', 'aliases'], anyOf: [
{ required: ['id'] },
{ required: ['name'] },
],
} as const; } as const;
@Injectable() @Injectable()
@ -70,27 +73,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let driveFile; let driveFile;
if (ps.fileId) { if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
} }
const emoji = await this.customEmojiService.getEmojiById(ps.id);
if (emoji != null) { let emojiId;
if (ps.name !== emoji.name) { if (ps.id) {
emojiId = ps.id;
const emoji = await this.customEmojiService.getEmojiById(ps.id);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
if (ps.name && (ps.name !== emoji.name)) {
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
} }
} else { } else {
throw new ApiError(meta.errors.noSuchEmoji); if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
const emoji = await this.customEmojiService.getEmojiByName(ps.name);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
emojiId = emoji.id;
} }
await this.customEmojiService.update(ps.id, { await this.customEmojiService.update(emojiId, {
driveFile, driveFile,
name: ps.name, name: ps.name,
category: ps.category ?? null, category: ps.category,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license ?? null, license: ps.license,
isSensitive: ps.isSensitive, isSensitive: ps.isSensitive,
localOnly: ps.localOnly, localOnly: ps.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,

View file

@ -71,7 +71,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean' } withReplies: { type: 'boolean' },
}, },
required: ['userId'], required: ['userId'],
} as const; } as const;

View file

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Brackets, In } from 'typeorm'; import { In } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReadService } from '@/core/NoteReadService.js'; import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
@ -48,10 +48,10 @@ export const paramDef = {
markAsRead: { type: 'boolean', default: true }, markAsRead: { type: 'boolean', default: true },
// 後方互換のため、廃止された通知タイプも受け付ける // 後方互換のため、廃止された通知タイプも受け付ける
includeTypes: { type: 'array', items: { includeTypes: { type: 'array', items: {
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
} }, } },
excludeTypes: { type: 'array', items: { excludeTypes: { type: 'array', items: {
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
} }, } },
}, },
required: [], required: [],
@ -79,12 +79,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; return [];
} }
// excludeTypes に全指定されている場合はクエリしない // excludeTypes に全指定されている場合はクエリしない
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) {
return []; return [];
} }
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange( const notificationsRes = await this.redisClient.xrevrange(
@ -162,7 +162,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
groupedNotifications = groupedNotifications.slice(0, ps.limit); groupedNotifications = groupedNotifications.slice(0, ps.limit);
const noteIds = groupedNotifications const noteIds = groupedNotifications
.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote' | 'edited'> => ['mention', 'reply', 'quote', 'edited'].includes(notification.type)) .filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote' | 'edited'> => ['mention', 'reply', 'quote', 'edited'].includes(notification.type))
.map(notification => notification.noteId!); .map(notification => notification.noteId!);

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Brackets, In } from 'typeorm'; import { In } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';

View file

@ -488,9 +488,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.hashtagService.updateUsertags(user, tags); this.hashtagService.updateUsertags(user, tags);
//#endregion //#endregion
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); if (Object.keys(updates).length > 0) {
if (Object.keys(updates).includes('alsoKnownAs')) { await this.usersRepository.update(user.id, updates);
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
} }
await this.userProfilesRepository.update(user.id, { await this.userProfilesRepository.update(user.id, {

View file

@ -44,11 +44,6 @@ describe('api:notes/create', () => {
.toBe(INVALID); .toBe(INVALID);
}); });
test('over 3000 characters post', async () => {
expect(v({ text: await tooLong }))
.toBe(INVALID);
});
test('whitespace-only post', () => { test('whitespace-only post', () => {
expect(v({ text: ' ' })) expect(v({ text: ' ' }))
.toBe(INVALID); .toBe(INVALID);

View file

@ -91,6 +91,12 @@ export const meta = {
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16', id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
}, },
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
message: 'You cannot reply to a specified visibility note with extended visibility.',
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
},
cannotCreateAlreadyExpiredPoll: { cannotCreateAlreadyExpiredPoll: {
message: 'Poll is already expired.', message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
@ -126,6 +132,12 @@ export const meta = {
code: 'CONTAINS_PROHIBITED_WORDS', code: 'CONTAINS_PROHIBITED_WORDS',
id: 'aa6e01d3-a85c-669d-758a-76aab43af334', id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
}, },
containsTooManyMentions: {
message: 'Cannot post because it exceeds the allowed number of mentions.',
code: 'CONTAINS_TOO_MANY_MENTIONS',
id: '4de0363a-3046-481b-9b0f-feff3e211025',
},
}, },
} as const; } as const;
@ -323,7 +335,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else if (isPureRenote(reply)) { } else if (isPureRenote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote); throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new ApiError(meta.errors.noSuchReplyTarget); throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
} }
// Check blocking // Check blocking
@ -389,9 +403,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} catch (e) { } catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof IdentifiableError) { if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
}
} }
throw e; throw e;
} }
}); });

View file

@ -11,6 +11,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteEditService } from '@/core/NoteEditService.js'; import { NoteEditService } from '@/core/NoteEditService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -19,6 +20,8 @@ export const meta = {
requireCredential: true, requireCredential: true,
prohibitMoved: true,
limit: { limit: {
duration: ms('1hour'), duration: ms('1hour'),
max: 300, max: 300,
@ -53,18 +56,42 @@ export const meta = {
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
}, },
cannotRenoteDueToVisibility: {
message: 'You can not Renote due to target visibility.',
code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
},
noSuchReplyTarget: { noSuchReplyTarget: {
message: 'No such reply target.', message: 'No such reply target.',
code: 'NO_SUCH_REPLY_TARGET', code: 'NO_SUCH_REPLY_TARGET',
id: '749ee0f6-d3da-459a-bf02-282e2da4292c', id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
}, },
cannotReplyToInvisibleNote: {
message: 'You cannot reply to an invisible Note.',
code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE',
id: 'b98980fa-3780-406c-a935-b6d0eeee10d1',
},
cannotReplyToPureRenote: { cannotReplyToPureRenote: {
message: 'You can not reply to a pure Renote.', message: 'You can not reply to a pure Renote.',
code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
}, },
maxLength: {
message: 'You tried posting a note which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
message: 'You cannot reply to a specified visibility note with extended visibility.',
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
},
cannotCreateAlreadyExpiredPoll: { cannotCreateAlreadyExpiredPoll: {
message: 'Poll is already expired.', message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
@ -83,6 +110,12 @@ export const meta = {
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
}, },
noSuchFile: {
message: 'Some files are not found.',
code: 'NO_SUCH_FILE',
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
},
accountLocked: { accountLocked: {
message: 'You migrated. Your account is now locked.', message: 'You migrated. Your account is now locked.',
code: 'ACCOUNT_LOCKED', code: 'ACCOUNT_LOCKED',
@ -137,17 +170,17 @@ export const meta = {
id: '33510210-8452-094c-6227-4a6c05d99f02', id: '33510210-8452-094c-6227-4a6c05d99f02',
}, },
maxLength: {
message: 'You tried posting a note which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
containsProhibitedWords: { containsProhibitedWords: {
message: 'Cannot post because it contains prohibited words.', message: 'Cannot post because it contains prohibited words.',
code: 'CONTAINS_PROHIBITED_WORDS', code: 'CONTAINS_PROHIBITED_WORDS',
id: 'aa6e01d3-a85c-669d-758a-76aab43af334', id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
}, },
containsTooManyMentions: {
message: 'Cannot post because it exceeds the allowed number of mentions.',
code: 'CONTAINS_TOO_MANY_MENTIONS',
id: '4de0363a-3046-481b-9b0f-feff3e211025',
},
}, },
} as const; } as const;
@ -201,7 +234,7 @@ export const paramDef = {
uniqueItems: true, uniqueItems: true,
minItems: 2, minItems: 2,
maxItems: 10, maxItems: 10,
items: { type: 'string', minLength: 1, maxLength: 50 }, items: { type: 'string', minLength: 1, maxLength: 150 },
}, },
multiple: { type: 'boolean' }, multiple: { type: 'boolean' },
expiresAt: { type: 'integer', nullable: true }, expiresAt: { type: 'integer', nullable: true },
@ -210,38 +243,33 @@ export const paramDef = {
required: ['choices'], required: ['choices'],
}, },
}, },
anyOf: [ // (re)note with text, files and poll are optional
{ if: {
// (re)note with text, files and poll are optional properties: {
properties: { renoteId: {
text: { type: 'null',
type: 'string',
minLength: 1,
nullable: false,
},
}, },
required: ['text'], fileIds: {
}, type: 'null',
{ },
// (re)note with files, text and poll are optional mediaIds: {
required: ['fileIds'], type: 'null',
}, },
{ poll: {
// (re)note with files, text and poll are optional type: 'null',
required: ['mediaIds'],
},
{
// (re)note with poll, text and files are optional
properties: {
poll: { type: 'object', nullable: false },
}, },
required: ['poll'],
}, },
{ },
// pure renote then: {
required: ['renoteId'], properties: {
text: {
type: 'string',
minLength: 1,
pattern: '[^\\s]+',
},
}, },
], required: ['text'],
},
} as const; } as const;
@Injectable() @Injectable()
@ -292,7 +320,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.getMany(); .getMany();
if (files.length !== fileIds.length) { if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchNote); throw new ApiError(meta.errors.noSuchFile);
} }
} }
@ -301,14 +329,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.renoteId === ps.editId) { if (ps.renoteId === ps.editId) {
throw new ApiError(meta.errors.cannotQuoteCurrentPost); throw new ApiError(meta.errors.cannotQuoteCurrentPost);
} }
if (ps.renoteId != null) { if (ps.renoteId != null) {
// Fetch renote to note // Fetch renote to note
renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
if (renote == null) { if (renote == null) {
throw new ApiError(meta.errors.noSuchRenoteTarget); throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { } else if (isPureRenote(renote)) {
throw new ApiError(meta.errors.cannotReRenote); throw new ApiError(meta.errors.cannotReRenote);
} }
@ -329,6 +357,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
if (renote.visibility === 'followers' && renote.userId !== me.id) {
// 他人のfollowers noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
}
if (renote.channelId && renote.channelId !== ps.channelId) { if (renote.channelId && renote.channelId !== ps.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する // リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
@ -350,8 +386,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (reply == null) { if (reply == null) {
throw new ApiError(meta.errors.noSuchReplyTarget); throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { } else if (isPureRenote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote); throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
} }
// Check blocking // Check blocking
@ -415,9 +455,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} catch (e) { } catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof IdentifiableError) { if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
}
} }
throw e; throw e;
} }
}); });

View file

@ -0,0 +1,33 @@
/*
* 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 { NotificationService } from '@/core/NotificationService.js';
export const meta = {
tags: ['notifications', 'account'],
requireCredential: true,
kind: 'write:notifications',
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
this.notificationService.flushAllNotifications(me.id);
});
}
}

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me, { withNote: true }))); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
}); });
} }
} }

View file

@ -73,13 +73,21 @@ class HomeTimelineChannel extends Channel {
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; // 純粋なリノート(引用リノートでないリノート)の場合
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
}
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return; if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return;
if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return;

View file

@ -28,12 +28,21 @@ export const notificationTypes = [
'quote', 'quote',
'reaction', 'reaction',
'pollEnded', 'pollEnded',
'edited',
'receiveFollowRequest', 'receiveFollowRequest',
'followRequestAccepted', 'followRequestAccepted',
'roleAssigned', 'roleAssigned',
'achievementEarned', 'achievementEarned',
'app', 'app',
'test'] as const; 'test',
] as const;
export const groupedNotificationTypes = [
...notificationTypes,
'reaction:grouped',
'renote:grouped',
] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View file

@ -117,5 +117,185 @@ describe('Mute', () => {
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi' });
await post(carol, { text: '@alice hi' });
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: 'hi', renoteId: aliceNote.id });
await post(carol, { text: 'hi', renoteId: aliceNote.id });
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { renoteId: aliceNote.id });
await post(carol, { renoteId: aliceNote.id });
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
await api('/i/follow', { userId: alice.id }, bob);
await api('/i/follow', { userId: alice.id }, carol);
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
await api('/i/update/', { isLocked: true }, alice);
await api('/following/create', { userId: alice.id }, bob);
await api('/following/create', { userId: alice.id }, carol);
const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
});
describe('Notification (Grouped)', () => {
test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi' });
await post(carol, { text: '@alice hi' });
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: 'hi', renoteId: aliceNote.id });
await post(carol, { text: 'hi', renoteId: aliceNote.id });
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { renoteId: aliceNote.id });
await post(carol, { renoteId: aliceNote.id });
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
await api('/i/follow', { userId: alice.id }, bob);
await api('/i/follow', { userId: alice.id }, carol);
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
await api('/i/update/', { isLocked: true }, alice);
await api('/following/create', { userId: alice.id }, bob);
await api('/following/create', { userId: alice.id }, carol);
const res = await api('/i/notifications-grouped', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
});
}); });
}); });

View file

@ -176,6 +176,87 @@ describe('Note', () => {
assert.strictEqual(deleteRes.status, 204); assert.strictEqual(deleteRes.status, 204);
}); });
test('visibility: followersなートに対してフォロワーはリプライできる', async () => {
await api('/following/create', {
userId: alice.id,
}, bob);
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'followers',
}, alice);
assert.strictEqual(aliceNote.status, 200);
const replyId = aliceNote.body.createdNote.id;
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId,
}, bob);
assert.strictEqual(bobReply.status, 200);
assert.strictEqual(bobReply.body.createdNote.replyId, replyId);
await api('/following/delete', {
userId: alice.id,
}, bob);
});
test('visibility: followersなートに対してフォロワーでないユーザーがリプライしようとすると怒られる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'followers',
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId: aliceNote.body.createdNote.id,
}, bob);
assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE');
});
test('visibility: specifiedなートに対してvisibility: specifiedで返信できる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'specified',
visibleUserIds: [bob.id],
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note',
replyId: aliceNote.body.createdNote.id,
visibility: 'specified',
visibleUserIds: [alice.id],
}, bob);
assert.strictEqual(bobReply.status, 200);
});
test('visibility: specifiedなートに対してvisibility: follwersで返信しようとすると怒られる', async () => {
const aliceNote = await api('/notes/create', {
text: 'direct note to bob',
visibility: 'specified',
visibleUserIds: [bob.id],
}, alice);
assert.strictEqual(aliceNote.status, 200);
const bobReply = await api('/notes/create', {
text: 'reply to alice note with visibility: followers',
replyId: aliceNote.body.createdNote.id,
visibility: 'followers',
}, bob);
assert.strictEqual(bobReply.status, 400);
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY');
});
test('文字数ぎりぎりで怒られない', async () => { test('文字数ぎりぎりで怒られない', async () => {
const post = { const post = {
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字 text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字
@ -680,6 +761,171 @@ describe('Note', () => {
assert.strictEqual(note1.status, 400); assert.strictEqual(note1.status, 400);
}); });
test('メンションの数が上限を超えるとエラーになる', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 0,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: '@bob potentially annoying text',
}, alice);
assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
test('ダイレクト投稿もエラーになる', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 0,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: 'potentially annoying text',
visibility: 'specified',
visibleUserIds: [ bob.id ],
}, alice);
assert.strictEqual(note.status, 400);
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
test('ダイレクトの宛先とメンションが同じ場合は重複してカウントしない', async () => {
const res = await api('admin/roles/create', {
name: 'test',
description: '',
color: null,
iconUrl: null,
displayOrder: 0,
target: 'manual',
condFormula: {},
isAdministrator: false,
isModerator: false,
isPublic: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
policies: {
mentionLimit: {
useDefault: false,
priority: 1,
value: 1,
},
},
}, alice);
assert.strictEqual(res.status, 200);
await new Promise(x => setTimeout(x, 2));
const assign = await api('admin/roles/assign', {
userId: alice.id,
roleId: res.body.id,
}, alice);
assert.strictEqual(assign.status, 204);
await new Promise(x => setTimeout(x, 2));
const note = await api('/notes/create', {
text: '@bob potentially annoying text',
visibility: 'specified',
visibleUserIds: [ bob.id ],
}, alice);
assert.strictEqual(note.status, 200);
await api('admin/roles/unassign', {
userId: alice.id,
roleId: res.body.id,
});
await api('admin/roles/delete', {
roleId: res.body.id,
}, alice);
});
}); });
describe('notes/delete', () => { describe('notes/delete', () => {

View file

@ -40,9 +40,9 @@ describe('Streaming', () => {
let chinatsu: misskey.entities.SignupResponse; let chinatsu: misskey.entities.SignupResponse;
let takumi: misskey.entities.SignupResponse; let takumi: misskey.entities.SignupResponse;
let kyokoNote: any; let kyokoNote: misskey.entities.Note;
let kanakoNote: any; let kanakoNote: misskey.entities.Note;
let takumiNote: any; let takumiNote: misskey.entities.Note;
let list: any; let list: any;
beforeAll(async () => { beforeAll(async () => {
@ -68,6 +68,9 @@ describe('Streaming', () => {
// Follow: ayano => akari // Follow: ayano => akari
await follow(ayano, akari); await follow(ayano, akari);
// Follow: kyoko => chitose
await api('following/create', { userId: chitose.id }, kyoko);
// Mute: chitose => kanako // Mute: chitose => kanako
await api('mute/create', { userId: kanako.id }, chitose); await api('mute/create', { userId: kanako.id }, chitose);
@ -170,7 +173,28 @@ describe('Streaming', () => {
*/ */
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
// TODO const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
);
assert.strictEqual(fired, false);
});
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信のリノートが流れない', async () => {
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
);
assert.strictEqual(fired, false);
}); });
test('フォローしていないユーザーの投稿は流れない', async () => { test('フォローしていないユーザーの投稿は流れない', async () => {
@ -202,6 +226,79 @@ describe('Streaming', () => {
assert.strictEqual(fired, false); assert.strictEqual(fired, false);
}); });
/**
* TODO: 落ちる
* @see https://github.com/misskey-dev/misskey/issues/13474
test('visibility: specified なノートで visibleUserIds に自分が含まれているときそのノートへのリプライが流れてくる', async () => {
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, true);
});
*/
test('visibility: specified な投稿に対するリプライで visibleUserIds が拡張されたとき、その拡張されたユーザーの HTL にはそのリプライが流れない', async () => {
const chitoseToKyoko = await post(chitose, { text: 'direct note from chitose to kyoko', visibility: 'specified', visibleUserIds: [kyoko.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyoko.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, false);
});
test('visibility: specified な投稿に対するリプライで visibleUserIds が収縮されたとき、その収縮されたユーザーの HTL にはそのリプライが流れない', async () => {
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'direct reply from kyoko to chitose', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko),
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
);
assert.strictEqual(fired, false);
});
test('withRenotes: false のときリノートが流れない', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { renoteId: kyokoNote.id }, kyoko), // kyoko renote
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
{ withRenotes: false },
);
assert.strictEqual(fired, false);
});
test('withRenotes: false のとき引用リノートが流れる', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { text: 'quote', renoteId: kyokoNote.id }, kyoko), // kyoko quote
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
{ withRenotes: false },
);
assert.strictEqual(fired, true);
});
test('withRenotes: false のとき投票のみのリノートが流れる', async () => {
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
() => api('notes/create', { poll: { choices: ['kinoko', 'takenoko'] }, renoteId: kyokoNote.id }, kyoko), // kyoko renote with poll
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
{ withRenotes: false },
);
assert.strictEqual(fired, true);
});
}); // Home }); // Home
describe('Local Timeline', () => { describe('Local Timeline', () => {

View file

@ -251,6 +251,34 @@ describe('RoleService', () => {
expect(user2Policies.canManageCustomEmojis).toBe(true); expect(user2Policies.canManageCustomEmojis).toBe(true);
}); });
test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
const [user1, user2, role1] = await Promise.all([
createUser(),
createUser(),
createRole({
name: 'manual role',
}),
]);
const role2 = await createRole({
name: 'conditional role',
target: 'conditional',
condFormula: {
// idはバックエンドのロジックに必要ない
id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
type: 'roleAssignedTo',
roleId: role1.id,
},
});
await roleService.assign(user2.id, role1.id);
const [u1role, u2role] = await Promise.all([
roleService.getUserRoles(user1.id),
roleService.getUserRoles(user2.id),
]);
expect(u1role.some(r => r.id === role2.id)).toBe(false);
expect(u2role.some(r => r.id === role2.id)).toBe(true);
});
test('expired role', async () => { test('expired role', async () => {
const user = await createUser(); const user = await createUser();
const role = await createRole({ const role = await createRole({

View file

@ -223,7 +223,7 @@ describe('ActivityPub', () => {
await personService.createPerson(actor.id, resolver); await personService.createPerson(actor.id, resolver);
// All notes in `featured` are same-origin, no need to fetch notes again // All notes in `featured` are same-origin, no need to fetch notes again
assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]); assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, `${actor.id}/outbox`, actor.featured]);
// Created notes without resolving anything // Created notes without resolving anything
for (const item of featured.items as IPost[]) { for (const item of featured.items as IPost[]) {
@ -256,7 +256,7 @@ describe('ActivityPub', () => {
// actor2Note is from a different server and needs to be fetched again // actor2Note is from a different server and needs to be fetched again
assert.deepStrictEqual( assert.deepStrictEqual(
resolver.remoteGetTrials(), resolver.remoteGetTrials(),
[actor1.id, actor1.featured, actor2Note.id, actor2.id], [actor1.id, `${actor1.id}/outbox`, actor1.featured, actor2Note.id, actor2.id, `${actor2.id}/outbox` ],
); );
const note = await noteService.fetchNote(actor2Note.id); const note = await noteService.fetchNote(actor2Note.id);

View file

@ -355,7 +355,7 @@ export const uploadUrl = async (user: UserToken, url: string): Promise<Packed<'D
return catcher; return catcher;
}; };
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> { export function connectStream<C extends keyof misskey.Channels>(user: UserToken, channel: C, listener: (message: Record<string, any>) => any, params?: misskey.Channels[C]['params']): Promise<WebSocket> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const url = new URL(`ws://127.0.0.1:${port}/streaming`); const url = new URL(`ws://127.0.0.1:${port}/streaming`);
const options: ClientOptions = {}; const options: ClientOptions = {};
@ -390,7 +390,7 @@ export function connectStream(user: UserToken, channel: string, listener: (messa
}); });
} }
export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => { export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
return new Promise<boolean>(async (res, rej) => { return new Promise<boolean>(async (res, rej) => {
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
@ -435,7 +435,7 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any
*/ */
export function makeStreamCatcher<T>( export function makeStreamCatcher<T>(
user: UserToken, user: UserToken,
channel: string, channel: keyof misskey.Channels,
cond: (message: Record<string, any>) => boolean, cond: (message: Record<string, any>) => boolean,
extractor: (message: Record<string, any>) => T, extractor: (message: Record<string, any>) => T,
timeout = 60 * 1000): Promise<T> { timeout = 60 * 1000): Promise<T> {

View file

@ -401,7 +401,8 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue') // glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => { (async () => {
const globs = await Promise.all([ const globs = await Promise.all([
glob('src/components/global/*.vue'), glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk{A,B}*.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkDigitalClock.vue'), glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'),

View file

@ -28,19 +28,19 @@
"@syuilo/aiscript": "0.17.0", "@syuilo/aiscript": "0.17.0",
"@phosphor-icons/web": "^2.0.3", "@phosphor-icons/web": "^2.0.3",
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.3", "@vitejs/plugin-vue": "5.0.4",
"@vue/compiler-sfc": "3.4.15", "@vue/compiler-sfc": "3.4.21",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
"astring": "1.8.6", "astring": "1.8.6",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.0.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"canvas-confetti": "1.6.1", "canvas-confetti": "1.9.2",
"chart.js": "4.4.1", "chart.js": "4.4.2",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "10.6.1", "chromatic": "11.0.0",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@ -58,79 +58,79 @@
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"photoswipe": "5.4.3", "photoswipe": "5.4.3",
"punycode": "2.3.1", "punycode": "2.3.1",
"rollup": "4.9.6", "rollup": "4.12.0",
"sanitize-html": "2.11.0", "sanitize-html": "2.12.1",
"sass": "1.70.0", "sass": "1.71.1",
"shiki": "1.0.0-beta.3", "shiki": "1.1.7",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.160.1", "three": "0.162.0",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.3.3", "typescript": "5.3.3",
"uuid": "9.0.1", "uuid": "9.0.1",
"v-code-diff": "1.7.2", "v-code-diff": "1.9.0",
"vite": "5.1.0", "vite": "5.1.4",
"vue": "3.4.15", "vue": "3.4.21",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.0.3", "@misskey-dev/summaly": "5.0.3",
"@storybook/addon-actions": "8.0.0-beta.2", "@storybook/addon-actions": "8.0.0-beta.6",
"@storybook/addon-essentials": "8.0.0-beta.2", "@storybook/addon-essentials": "8.0.0-beta.6",
"@storybook/addon-interactions": "8.0.0-beta.2", "@storybook/addon-interactions": "8.0.0-beta.6",
"@storybook/addon-links": "8.0.0-beta.2", "@storybook/addon-links": "8.0.0-beta.6",
"@storybook/addon-mdx-gfm": "8.0.0-beta.2", "@storybook/addon-mdx-gfm": "8.0.0-beta.6",
"@storybook/addon-storysource": "8.0.0-beta.2", "@storybook/addon-storysource": "8.0.0-beta.6",
"@storybook/blocks": "8.0.0-beta.2", "@storybook/blocks": "8.0.0-beta.6",
"@storybook/components": "8.0.0-beta.2", "@storybook/components": "8.0.0-beta.6",
"@storybook/core-events": "8.0.0-beta.2", "@storybook/core-events": "8.0.0-beta.6",
"@storybook/manager-api": "8.0.0-beta.2", "@storybook/manager-api": "8.0.0-beta.6",
"@storybook/preview-api": "8.0.0-beta.2", "@storybook/preview-api": "8.0.0-beta.6",
"@storybook/react": "8.0.0-beta.2", "@storybook/react": "8.0.0-beta.6",
"@storybook/react-vite": "8.0.0-beta.2", "@storybook/react-vite": "8.0.0-beta.6",
"@storybook/test": "8.0.0-beta.2", "@storybook/test": "8.0.0-beta.6",
"@storybook/theming": "8.0.0-beta.2", "@storybook/theming": "8.0.0-beta.6",
"@storybook/types": "8.0.0-beta.2", "@storybook/types": "8.0.0-beta.6",
"@storybook/vue3": "8.0.0-beta.2", "@storybook/vue3": "8.0.0-beta.6",
"@storybook/vue3-vite": "8.0.0-beta.2", "@storybook/vue3-vite": "8.0.0-beta.6",
"@testing-library/vue": "8.0.2", "@testing-library/vue": "8.0.2",
"@types/escape-regexp": "0.0.3", "@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@types/matter-js": "0.19.6", "@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6", "@types/micromatch": "4.0.6",
"@types/node": "20.11.17", "@types/node": "20.11.22",
"@types/punycode": "2.1.3", "@types/punycode": "2.1.4",
"@types/sanitize-html": "2.9.5", "@types/sanitize-html": "2.11.0",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8", "@types/uuid": "9.0.8",
"@types/ws": "8.5.10", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "6.18.1", "@typescript-eslint/parser": "7.1.0",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.15", "@vue/runtime-core": "3.4.21",
"acorn": "8.11.3", "acorn": "8.11.3",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.6.4", "cypress": "13.6.6",
"eslint": "8.56.0", "eslint": "8.57.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.20.1", "eslint-plugin-vue": "9.22.0",
"fast-glob": "3.3.2", "fast-glob": "3.3.2",
"happy-dom": "10.0.3", "happy-dom": "13.6.2",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
"micromatch": "4.0.5", "micromatch": "4.0.5",
"msw": "2.1.7", "msw": "2.1.7",
"msw-storybook-addon": "2.0.0-beta.1", "msw-storybook-addon": "2.0.0-beta.1",
"nodemon": "3.0.3", "nodemon": "3.1.0",
"prettier": "3.2.5", "prettier": "3.2.5",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"start-server-and-test": "2.0.3", "start-server-and-test": "2.0.3",
"storybook": "8.0.0-beta.2", "storybook": "8.0.0-beta.6",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6", "vitest": "0.34.6",

View file

@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile, text: i18n.ts.profile,
to: `/@${ $i.username }`, to: `/@${ $i.username }`,
avatar: $i, avatar: $i,
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const, type: 'parent' as const,
icon: 'ph-plus ph-bold ph-lg', icon: 'ph-plus ph-bold ph-lg',
text: i18n.ts.addAccount, text: i18n.ts.addAccount,

View file

@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</ol> </ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/> <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span> <span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
@ -77,7 +77,7 @@ const emojiDb = computed(() => {
unicodeEmojiDB.push({ unicodeEmojiDB.push({
emoji: emoji, emoji: emoji,
name: k, name: k,
aliasOf: getEmojiName(emoji)!, aliasOf: getEmojiName(emoji),
url: char2path(emoji), url: char2path(emoji),
}); });
} }

View file

@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="select.items"> <template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option> <option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template> </template>
<template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
</optgroup>
</template>
</MkSelect> </MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
type Input = { type Input = {
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string; autocomplete?: string;
default: string | number | null; default: string | number | null;
@ -74,22 +69,17 @@ type Input = {
type Select = { type Select = {
items: { items: {
value: string; value: any;
text: string; text: string;
}[]; }[];
groupedItems: {
label: string;
items: {
value: string;
text: string;
}[];
}[];
default: string | null; default: string | null;
}; };
type Result = string | number | true | null;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title: string; title?: string;
text?: string; text?: string;
input?: Input; input?: Input;
select?: Select; select?: Select;
@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { canceled: boolean; result: any }): void; (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null; return null;
}); });
function done(canceled: boolean, result?) { // overload function 使 lint
emit('done', { canceled, result }); function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
modal.value?.close(); modal.value?.close();
} }

View file

@ -39,13 +39,13 @@ withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[]): void; (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]); const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
function ok() { function ok() {
emit('done', selected.value); emit('done', selected.value);
@ -57,7 +57,7 @@ function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
function onChangeSelection(files: Misskey.entities.DriveFile[]) { function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
selected.value = files; selected.value = v;
} }
</script> </script>

View file

@ -16,10 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji" :key="emoji"
:data-emoji="emoji" :data-emoji="emoji"
class="_button item" class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)" @click="emit('chosen', emoji, $event)"
> >
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/>
</button> </button>
</div> </div>
@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="emoji" :key="emoji"
:data-emoji="emoji" :data-emoji="emoji"
class="_button item" class="_button item"
:disabled="disabledEmojis?.value.includes(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="emit('chosen', emoji, $event)" @click="emit('chosen', emoji, $event)"
> >
@ -67,6 +69,7 @@ import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
const props = defineProps<{ const props = defineProps<{
emojis: string[] | Ref<string[]>; emojis: string[] | Ref<string[]>;
disabledEmojis?: Ref<string[]>;
initialShown?: boolean; initialShown?: boolean;
hasChildSection?: boolean; hasChildSection?: boolean;
customEmojiTree?: CustomEmojiFolderTree[]; customEmojiTree?: CustomEmojiFolderTree[];
@ -84,7 +87,7 @@ const shown = ref(!!props.initialShown);
function computeButtonTitle(ev: MouseEvent): void { function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement; const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string; const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji; elm.title = getEmojiName(emoji);
} }
function nestedChosen(emoji: any, ev: MouseEvent) { function nestedChosen(emoji: any, ev: MouseEvent) {

View file

@ -14,11 +14,12 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="emoji in searchResultCustom" v-for="emoji in searchResultCustom"
:key="emoji.name" :key="emoji.name"
class="_button item" class="_button item"
:disabled="!canReact(emoji)"
:title="emoji.name" :title="emoji.name"
tabindex="0" tabindex="0"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkCustomEmoji class="emoji" :name="emoji.name"/> <MkCustomEmoji class="emoji" :name="emoji.name" :fallbackToImage="true"/>
</button> </button>
</div> </div>
<div v-if="searchResultUnicode.length > 0" class="body"> <div v-if="searchResultUnicode.length > 0" class="body">
@ -39,16 +40,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<section v-if="showPinned && (pinned && pinned.length > 0)"> <section v-if="showPinned && (pinned && pinned.length > 0)">
<div class="body"> <div class="body">
<button <button
v-for="emoji in pinned" v-for="emoji in pinnedEmojisDef"
:key="emoji" :key="getKey(emoji)"
:data-emoji="emoji" :data-emoji="getKey(emoji)"
class="_button item" class="_button item"
:disabled="!canReact(emoji)"
tabindex="0" tabindex="0"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button> </button>
</div> </div>
</section> </section>
@ -57,15 +59,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header> <header class="_acrylic"><i class="ph-clock ph-bold ph-lg ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div class="body"> <div class="body">
<button <button
v-for="emoji in recentlyUsedEmojis" v-for="emoji in recentlyUsedEmojisDef"
:key="emoji" :key="getKey(emoji)"
class="_button item" class="_button item"
:data-emoji="emoji" :disabled="!canReact(emoji)"
:data-emoji="getKey(emoji)"
@pointerenter="computeButtonTitle" @pointerenter="computeButtonTitle"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
> >
<MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button> </button>
</div> </div>
</section> </section>
@ -76,7 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="child in customEmojiFolderRoot.children" v-for="child in customEmojiFolderRoot.children"
:key="`custom:${child.value}`" :key="`custom:${child.value}`"
:initialShown="false" :initialShown="false"
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))" :emojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).map(e => `:${e.name}:`))"
:disabledEmojis="computed(() => customEmojis.filter(e => filterCategory(e, child.value)).filter(e => !canReact(e)).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0" :hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children" :customEmojiTree="child.children"
@chosen="chosen" @chosen="chosen"
@ -109,6 +113,7 @@ import {
unicodeEmojiCategories as categories, unicodeEmojiCategories as categories,
getEmojiName, getEmojiName,
CustomEmojiFolderTree, CustomEmojiFolderTree,
getUnicodeEmoji,
} from '@/scripts/emojilist.js'; } from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -146,6 +151,13 @@ const {
recentlyUsedEmojis, recentlyUsedEmojis,
} = defaultStore.reactiveState; } = defaultStore.reactiveState;
const recentlyUsedEmojisDef = computed(() => {
return recentlyUsedEmojis.value.map(getDef).filter(x => x != null);
});
const pinnedEmojisDef = computed(() => {
return pinned.value?.map(getDef).filter(x => x != null);
});
const pinned = computed(() => props.pinnedEmojis); const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => emojiPickerScale.value); const size = computed(() => emojiPickerScale.value);
const width = computed(() => emojiPickerWidth.value); const width = computed(() => emojiPickerWidth.value);
@ -337,14 +349,18 @@ watch(q, () => {
return matches; return matches;
}; };
searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable); searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode()); searchResultUnicode.value = Array.from(searchUnicode());
}); });
function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { function canReact(emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji); return !props.targetNote || checkReactionPermissions($i!, props.targetNote, emoji);
} }
function filterCategory(emoji: Misskey.entities.EmojiSimple, category: string): boolean {
return category === '' ? (emoji.category === 'null' || !emoji.category) : emoji.category === category;
}
function focus() { function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({ searchEl.value?.focus({
@ -362,11 +378,22 @@ function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef):
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
} }
function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeEmojiDef {
if (emoji.includes(':')) {
//
// undefined
const name = emoji.replaceAll(':', '');
return customEmojisMap.get(name) ?? emoji;
} else {
return getUnicodeEmoji(emoji);
}
}
/** @see MkEmojiPicker.section.vue */ /** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void { function computeButtonTitle(ev: MouseEvent): void {
const elm = ev.target as HTMLElement; const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string; const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji) ?? emoji; elm.title = getEmojiName(emoji);
} }
function chosen(emoji: any, ev?: MouseEvent) { function chosen(emoji: any, ev?: MouseEvent) {
@ -526,6 +553,18 @@ defineExpose({
width: auto; width: auto;
height: auto; height: auto;
min-width: 0; min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
} }
} }
} }
@ -548,6 +587,18 @@ defineExpose({
width: auto; width: auto;
height: auto; height: auto;
min-width: 0; min-width: 0;
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
} }
} }
} }
@ -663,6 +714,18 @@ defineExpose({
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
} }
&:disabled {
cursor: not-allowed;
background: linear-gradient(-45deg, transparent 0% 48%, var(--X6) 48% 52%, transparent 52% 100%);
opacity: 1;
> .emoji {
filter: grayscale(1);
mix-blend-mode: exclusion;
opacity: 0.8;
}
}
> .emoji { > .emoji {
height: 1.25em; height: 1.25em;
vertical-align: -.25em; vertical-align: -.25em;

View file

@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: any): void; (ev: 'done', v: string): void;
(ev: 'close'): void; (ev: 'close'): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
@ -64,7 +64,7 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) { function chosen(emoji: string) {
emit('done', emoji); emit('done', emoji);
if (props.choseAndClose) { if (props.choseAndClose) {
modal.value?.close(); modal.value?.close();

View file

@ -1,49 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkWindow
ref="window"
:initialWidth="300"
:initialHeight="290"
:canResize="true"
:mini="true"
:front="true"
@closed="emit('closed')"
>
<MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
withDefaults(defineProps<{
src?: HTMLElement;
showPinned?: boolean;
asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(ev: 'chosen', v: any): void;
(ev: 'closed'): void;
}>();
function chosen(emoji: any) {
emit('chosen', emoji);
}
</script>
<style lang="scss" module>
.picker {
height: 100%;
}
</style>

View file

@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="32"> <MkSpacer :marginMin="20" :marginMax="32">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> <template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> <MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput> </MkInput>
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput> </MkInput>
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea> </MkTextarea>
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="form[item].label || item"></span> <span v-text="v.label || k"></span>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch> </MkSwitch>
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option> <option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkSelect> </MkSelect>
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option> <option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkRadios> </MkRadios>
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter"> <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange> </MkRange>
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="form[item].content || item"></span> <span v-text="v.content || k"></span>
</MkButton> </MkButton>
</template> </template>
</div> </div>
@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue'; import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue'; import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue'; import MkRadios from './MkRadios.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
const props = defineProps<{ const props = defineProps<{
title: string; title: string;
form: any; form: Form;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { (ev: 'done', v: {
canceled?: boolean; canceled: true;
result?: any; } | {
result: Record<string, any>;
}): void; }): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();

View file

@ -166,7 +166,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button>
</div> </div>
<div> <div>
<div v-if="tab === 'replies'" :class="$style.tab_replies"> <div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px"> <div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div> </div>
@ -183,7 +183,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
</MkPagination> </MkPagination>
</div> </div>
<div v-if="tab === 'quotes'" :class="$style.tab_replies"> <div v-if="tab === 'quotes'">
<div v-if="!quotesLoaded" style="padding: 16px"> <div v-if="!quotesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
</div> </div>

View file

@ -40,6 +40,7 @@ import { notificationTypes } from '@/const.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import * as Misskey from 'misskey-js';
const props = defineProps<{ const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][]; excludeTypes?: typeof notificationTypes[number][];
@ -80,17 +81,19 @@ function reload() {
}); });
} }
let connection; let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
onMounted(() => { onMounted(() => {
connection = useStream().useChannel('main'); connection = useStream().useChannel('main');
connection.on('notification', onNotification); connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
}); });
onActivated(() => { onActivated(() => {
pagingComponent.value?.reload(); pagingComponent.value?.reload();
connection = useStream().useChannel('main'); connection = useStream().useChannel('main');
connection.on('notification', onNotification); connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
}); });
onUnmounted(() => { onUnmounted(() => {

View file

@ -174,7 +174,7 @@ const emit = defineEmits<{
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
const cwInputEl = shallowRef<HTMLInputElement | null>(null); const cwInputEl = shallowRef<HTMLInputElement | null>(null);
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
const visibilityButton = shallowRef<HTMLElement | null>(null); const visibilityButton = shallowRef<HTMLElement>();
const posting = ref(false); const posting = ref(false);
const posted = ref(false); const posted = ref(false);
@ -467,6 +467,7 @@ function setVisibility() {
isSilenced: $i.isSilenced, isSilenced: $i.isSilenced,
localOnly: localOnly.value, localOnly: localOnly.value,
src: visibilityButton.value, src: visibilityButton.value,
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
}, { }, {
changeVisibility: v => { changeVisibility: v => {
visibility.value = v; visibility.value = v;

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/> <MkCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
<MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/> <MkEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
</template> </template>

View file

@ -44,7 +44,7 @@ function getReactionName(reaction: string): string {
if (trimLocal.startsWith(':')) { if (trimLocal.startsWith(':')) {
return trimLocal; return trimLocal;
} }
return getEmojiName(reaction) ?? reaction; return getEmojiName(reaction);
} }
</script> </script>

View file

@ -33,7 +33,8 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojisMap } from '@/custom-emojis.js';
import { getUnicodeEmoji } from '@/scripts/emojilist.js';
const props = defineProps<{ const props = defineProps<{
reaction: string; reaction: string;
@ -50,13 +51,11 @@ const emit = defineEmits<{
const buttonEl = shallowRef<HTMLElement>(); const buttonEl = shallowRef<HTMLElement>();
const isCustomEmoji = computed(() => props.reaction.includes(':')); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
const emoji = computed(() => isCustomEmoji.value ? customEmojis.value.find(emoji => emoji.name === props.reaction.replace(/:/g, '').replace(/@\./, '')) : null); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
const canToggle = computed(() => { const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
&& (emoji.value && checkReactionPermissions($i, props.note, emoji.value))
|| !isCustomEmoji.value;
}); });
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));

View file

@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</dt> </dt>
<dd :class="$style.fieldvalue"> <dd :class="$style.fieldvalue">
<Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/> <Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg" :class="$style.verifiedLink"></i> <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg"></i>
</dd> </dd>
</dl> </dl>
</div> </div>

View file

@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.label, $style.item]"> <div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }} {{ i18n.ts.visibility }}
</div> </div>
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> <button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
<div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div> <div :class="$style.icon"><i class="ph-globe-hemisphere-west ph-bold ph-lg"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
</div> </div>
</button> </button>
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')"> <button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
<div :class="$style.icon"><i class="ph-house ph-bold ph-lg"></i></div> <div :class="$style.icon"><i class="ph-house ph-bold ph-lg"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span> <span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
</div> </div>
</button> </button>
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')"> <button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
<div :class="$style.icon"><i class="ph-lock ph-bold ph-lg"></i></div> <div :class="$style.icon"><i class="ph-lock ph-bold ph-lg"></i></div>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span> <span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
isSilenced: boolean; isSilenced: boolean;
localOnly: boolean; localOnly: boolean;
src?: HTMLElement; src?: HTMLElement;
isReplyVisibilitySpecified?: boolean;
}>(), { }>(), {
}); });

View file

@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<template v-if="appearNote.reply && appearNote.reply.replyId"> <template v-if="appearNote.reply && appearNote.reply.replyId">
<SkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws" detailed/> <SkNoteSub v-for="note in conversation" :key="note.id" :note="note" :expandAllCws="props.expandAllCws" detailed/>
</template> </template>
<SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/> <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws" detailed/>
<article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu"> <article :id="appearNote.id" ref="noteEl" :class="$style.note" tabindex="-1" @contextmenu.stop="onContextmenu">
@ -174,7 +174,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button> <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></i> {{ i18n.ts.reactions }}</button>
</div> </div>
<div> <div>
<div v-if="tab === 'replies'" :class="$style.tab_replies"> <div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px"> <div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div> </div>
@ -191,7 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
</MkPagination> </MkPagination>
</div> </div>
<div v-if="tab === 'quotes'" :class="$style.tab_replies"> <div v-if="tab === 'quotes'">
<div v-if="!quotesLoaded" style="padding: 16px"> <div v-if="!quotesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton>
</div> </div>
@ -798,10 +798,6 @@ onUnmounted(() => {
padding-bottom: 0; padding-bottom: 0;
} }
.replyToMore {
}
.renote { .renote {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -32,7 +32,8 @@ export const Default = {
async play({ canvasElement }) { async play({ canvasElement }) {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link'); const a = canvas.getByRole<HTMLAnchorElement>('link');
await expect(a.href).toMatch(/^https?:\/\/.*#test$/); // FIXME: 通るけどその後落ちるのでコメントアウト
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
await userEvent.pointer({ keys: '[MouseRight]', target: a }); await userEvent.pointer({ keys: '[MouseRight]', target: a });
await tick(); await tick();
const menu = canvas.getByRole('menu'); const menu = canvas.getByRole('menu');
@ -44,6 +45,7 @@ export const Default = {
}, },
args: { args: {
to: '#test', to: '#test',
behavior: 'browser',
}, },
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View file

@ -48,3 +48,18 @@ export const Missing = {
name: Default.args.name, name: Default.args.name,
}, },
} satisfies StoryObj<typeof MkCustomEmoji>; } satisfies StoryObj<typeof MkCustomEmoji>;
export const ErrorToText = {
...Default,
args: {
url: 'https://example.com/404',
name: Default.args.name,
},
} satisfies StoryObj<typeof MkCustomEmoji>;
export const ErrorToImage = {
...Default,
args: {
url: 'https://example.com/404',
name: Default.args.name,
fallbackToImage: true,
},
} satisfies StoryObj<typeof MkCustomEmoji>;

View file

@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<span v-if="errored">:{{ customEmojiName }}:</span> <img
v-if="errored && fallbackToImage"
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
src="/client-assets/dummy.png"
:title="alt"
/>
<span v-else-if="errored">:{{ customEmojiName }}:</span>
<img <img
v-else v-else
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
@ -39,6 +45,7 @@ const props = defineProps<{
useOriginalSize?: boolean; useOriginalSize?: boolean;
menu?: boolean; menu?: boolean;
menuReaction?: boolean; menuReaction?: boolean;
fallbackToImage?: boolean;
}>(); }>();
const react = inject<((name: string) => void) | null>('react', null); const react = inject<((name: string) => void) | null>('react', null);

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import { char2twemojiFilePath, char2fluentEmojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js'; import { char2fluentEmojiFilePath, char2twemojiFilePath, char2tossfaceFilePath } from '@/scripts/emoji-base.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -26,7 +26,7 @@ const props = defineProps<{
const react = inject<((name: string) => void) | null>('react', null); const react = inject<((name: string) => void) | null>('react', null);
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.reactiveState.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : defaultStore.state.emojiStyle === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native');
const url = computed(() => char2path(props.emoji)); const url = computed(() => char2path(props.emoji));
@ -34,8 +34,7 @@ const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void { function computeTitle(event: PointerEvent): void {
const title = getEmojiName(props.emoji as string) ?? props.emoji as string; (event.target as HTMLElement).title = getEmojiName(props.emoji);
(event.target as HTMLElement).title = title;
} }
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {

View file

@ -410,6 +410,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
useOriginalSize: scale >= 2.5, useOriginalSize: scale >= 2.5,
menu: props.enableEmojiMenu, menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction, menuReaction: props.enableEmojiMenuReaction,
fallbackToImage: false,
})]; })];
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

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