Merge branch 'misskey-dev:master' into master

This commit is contained in:
老兄 2023-02-26 21:27:21 +08:00 committed by GitHub
commit b0d61366d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 2088 additions and 1018 deletions

View File

@ -7,5 +7,5 @@ charset = utf-8
insert_final_newline = true insert_final_newline = true
end_of_line = lf end_of_line = lf
[*.yml] [*.{yml,yaml}]
indent_style = space indent_style = space

View File

@ -0,0 +1,17 @@
<!-- お読みください / README
PRありがとうございます PRを作成する前に、コントリビューションガイドをご確認ください:
Thank you for your PR! Before creating a PR, please check the contribution guide:
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
-->
# What
<!-- このPRで何をしたのか どう変わるのか? -->
<!-- What did you do with this PR? How will it change things? -->
# Why
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
# Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->

View File

@ -0,0 +1,19 @@
## Summary
This is a release PR.
For more information on the release instructions, please see:
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release
## For reviewers
- CHANGELOGに抜け漏れは無いか
- バージョンの上げ方は適切か
- 他にこのリリースに含めなければならない変更は無いか
- 全体的な変更内容を俯瞰し問題は無いか
- レビューされていないコミットがある場合は、それが問題ないか
などを確認し、リリースする準備が整っていると思われる場合は approve してください。
## Checklist
- [ ] package.jsonのバージョンが正しく更新されている
- [ ] CHANGELOGが過不足無く更新されている
- [ ] CIが全て通っている

View File

@ -9,14 +9,46 @@ name: Destroy preview environment
jobs: jobs:
destroy-preview-environment: destroy-preview-environment:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
steps: steps:
- uses: actions/github-script@v6.3.3
id: check-conclusion
env:
number: ${{ github.event.number }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
result-encoding: string
script: |
const { data: pull } = await github.rest.pulls.get({
...context.repo,
pull_number: process.env.number
});
const ref = pull.head.sha;
const { data: checks } = await github.rest.checks.listForRef({
...context.repo,
ref
});
const check = checks.check_runs.filter(c => c.name === 'deploy-preview-environment');
if (check.length === 0) {
return;
}
const { data: result } = await github.rest.checks.get({
...context.repo,
check_run_id: check[0].id,
});
return result.conclusion;
- name: Context - name: Context
if: steps.check-conclusion.outputs.result == 'success'
uses: okteto/context@latest uses: okteto/context@latest
with: with:
token: ${{ secrets.OKTETO_TOKEN }} token: ${{ secrets.OKTETO_TOKEN }}
- name: Destroy preview environment - name: Destroy preview environment
if: steps.check-conclusion.outputs.result == 'success'
uses: okteto/destroy-preview@latest uses: okteto/destroy-preview@latest
with: with:
name: pr-${{ github.event.number }}-syuilo name: pr-${{ github.event.number }}-syuilo

View File

@ -2,5 +2,8 @@
"search.exclude": { "search.exclude": {
"**/node_modules": true "**/node_modules": true
}, },
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib",
"files.associations": {
"*.test.ts": "typescript"
}
} }

View File

@ -2,13 +2,41 @@
## 13.x.x (unreleased) ## 13.x.x (unreleased)
### Improvements ### Improvements
- -
### Bugfixes ### Bugfixes
- -
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.8.1 (2023/02/26)
### Bugfixes
- モバイルでドロワーメニューが表示されない問題を修正
## 13.8.0 (2023/02/26)
### Improvements
- チャンネル内ハイライト
- ホームタイムラインのパフォーマンスを改善
- renoteした際の表示を改善
- バックグラウンドで一定時間経過したらページネーションのアイテム更新をしない
- enhance(client): MkUrlPreviewの閉じるボタンを見やすく
- Add dialog to remove follower
- enhance(client): improve clip menu ux
- 検索画面の統合
- enhance(client): ノートメニューからユーザーメニューを開けるように
- photoswipe 表示時に戻る操作をしても前の画面に戻らないように
### Bugfixes
- Windows環境でswcを使うと正しくビルドできない問題の修正
- fix(client): Android ChromeでPWAとしてインストールできない問題を修正
- 未知のユーザーが deleteActor されたら処理をスキップする
- fix(server): notes/createで、fileIdsと見つかったファイルの数が異なる場合はエラーにする
- fix(server): notes/createのバリデーションが機能していないのを修正
- fix(server): エラーのスタックトレースは返さないように
## 13.7.5 (2023/02/24) ## 13.7.5 (2023/02/24)
### Note ### Note

View File

@ -299,6 +299,27 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
- 生成後、ファイルをmigration下に移してください - 生成後、ファイルをmigration下に移してください
- 作成されたスクリプトは不必要な変更を含むため除去してください - 作成されたスクリプトは不必要な変更を含むため除去してください
### JSON SchemaのobjectでanyOfを使うとき
JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。
バリデーションが効かないため。SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます
https://github.com/misskey-dev/misskey/pull/10082
テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合:
```
export const paramDef = {
type: 'object',
properties: {
hoge: { type: 'string', minLength: 1 },
fuga: { type: 'string', minLength: 1 },
},
anyOf: [
{ required: ['hoge'] },
{ required: ['fuga'] },
],
} as const;
```
### コネクションには`markRaw`せよ ### コネクションには`markRaw`せよ
**Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 **Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。

View File

@ -1,5 +1,4 @@
coverage: coverage:
status: status:
project: project: false
default: patch: false
only_pulls: true

View File

@ -971,6 +971,7 @@ _ago:
weeksAgo: "منذ {n} أسابيع" weeksAgo: "منذ {n} أسابيع"
monthsAgo: "منذ {n} أشهر" monthsAgo: "منذ {n} أشهر"
yearsAgo: "منذ {n} سنوات" yearsAgo: "منذ {n} سنوات"
invalid: "لا يوجد شيء هنا"
_time: _time:
second: "ثا" second: "ثا"
minute: "د" minute: "د"

View File

@ -1033,6 +1033,7 @@ _ago:
weeksAgo: "{n} সপ্তাহ আগে" weeksAgo: "{n} সপ্তাহ আগে"
monthsAgo: "{n} মাস আগে" monthsAgo: "{n} মাস আগে"
yearsAgo: "{n} বছর আগে" yearsAgo: "{n} বছর আগে"
invalid: "এখানে কিছুই নাই"
_time: _time:
second: "সেকেন্ড" second: "সেকেন্ড"
minute: "মিনিট" minute: "মিনিট"

View File

@ -659,6 +659,7 @@ _sfx:
_ago: _ago:
future: "Budoucí" future: "Budoucí"
justNow: "Teď" justNow: "Teď"
invalid: "Nic nebylo nalezeno"
_time: _time:
second: "Sekund" second: "Sekund"
minute: "Minut" minute: "Minut"

View File

@ -393,13 +393,19 @@ about: "Über"
aboutMisskey: "Über Misskey" aboutMisskey: "Über Misskey"
administrator: "Administrator" administrator: "Administrator"
token: "Token" token: "Token"
2fa: "Zwei-Faktor-Authentifizierung"
totp: "Authentifizierungs-App"
totpDescription: "Logge dich via Authentifizierungs-App mit Einmalpasswort ein"
moderator: "Moderator" moderator: "Moderator"
moderation: "Moderation" moderation: "Moderation"
nUsersMentioned: "Von {n} Benutzern erwähnt" nUsersMentioned: "Von {n} Benutzern erwähnt"
securityKeyAndPasskey: "Security-Tokens und Passkeys"
securityKey: "Sicherheitsschlüssel" securityKey: "Sicherheitsschlüssel"
lastUsed: "Zuletzt benutzt" lastUsed: "Zuletzt benutzt"
lastUsedAt: "Zuletzt verwendet: {t}"
unregister: "Deaktivieren" unregister: "Deaktivieren"
passwordLessLogin: "Passwortloses Anmelden einrichten" passwordLessLogin: "Passwortloses Anmelden einrichten"
passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey"
resetPassword: "Passwort zurücksetzen" resetPassword: "Passwort zurücksetzen"
newPasswordIs: "Das neue Passwort ist „{password}“" newPasswordIs: "Das neue Passwort ist „{password}“"
reduceUiAnimation: "Animationen der Benutzeroberfläche reduzieren" reduceUiAnimation: "Animationen der Benutzeroberfläche reduzieren"
@ -773,6 +779,7 @@ popularPosts: "Beliebte Beiträge"
shareWithNote: "Mit Notiz teilen" shareWithNote: "Mit Notiz teilen"
ads: "Werbung" ads: "Werbung"
expiration: "Frist" expiration: "Frist"
startingperiod: "Start"
memo: "Merkzettel" memo: "Merkzettel"
priority: "Priorität" priority: "Priorität"
high: "Hoch" high: "Hoch"
@ -805,6 +812,7 @@ lastCommunication: "Letzte Kommunikation"
resolved: "Gelöst" resolved: "Gelöst"
unresolved: "Ungelöst" unresolved: "Ungelöst"
breakFollow: "Follower entfernen" breakFollow: "Follower entfernen"
breakFollowConfirm: "Diesen Follower wirklich entfernen?"
itsOn: "Eingeschaltet" itsOn: "Eingeschaltet"
itsOff: "Ausgeschaltet" itsOff: "Ausgeschaltet"
emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren" emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren"
@ -939,6 +947,10 @@ collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen"
internalServerError: "Serverinterner Fehler" internalServerError: "Serverinterner Fehler"
internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten." internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten."
copyErrorInfo: "Fehlerdetails kopieren" copyErrorInfo: "Fehlerdetails kopieren"
joinThisServer: "Bei dieser Instanz registrieren"
exploreOtherServers: "Eine andere Instanz finden"
letsLookAtTimeline: "Die Chronik durchstöbern"
disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt."
_achievements: _achievements:
earnedAt: "Freigeschaltet am" earnedAt: "Freigeschaltet am"
_types: _types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "vor {n} Woche(n)" weeksAgo: "vor {n} Woche(n)"
monthsAgo: "vor {n} Monat(en)" monthsAgo: "vor {n} Monat(en)"
yearsAgo: "vor {n} Jahr(en)" yearsAgo: "vor {n} Jahr(en)"
invalid: "Ungültig"
_time: _time:
second: "Sekunde(n)" second: "Sekunde(n)"
minute: "Minute(n)" minute: "Minute(n)"
@ -1485,14 +1498,29 @@ _tutorial:
step8_3: "Diese Einstellung kannst du jederzeit ändern." step8_3: "Diese Einstellung kannst du jederzeit ändern."
_2fa: _2fa:
alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert." alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert."
registerTOTP: "Authentifizierungs-App registrieren"
passwordToTOTP: "Bitte Passwort eingeben"
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem Gerät." step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem Gerät."
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät." step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."
step2Click: "Durch Klicken dieses QR-Codes kannst du Verifikation mit deinem Security-Token oder einer App registrieren."
step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:"
step3Title: "Authentifizierungsscode eingeben"
step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird."
step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen."
securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens."
registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren."
securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten."
removeKeyConfirm: "Das Backup {name} löschen?" chromePasskeyNotSupported: "Chrome-Passkeys werden zur Zeit nicht unterstützt."
renewTOTPCancel: "Nein, danke" registerSecurityKey: "Security-Token oder Passkey registrieren"
securityKeyName: "Schlüsselname eingeben"
tapSecurityKey: "Bitten folge den Anweisungen deines Browsers zur Registrierung"
removeKey: "Sicherheitsschlüssel entfernen"
removeKeyConfirm: "Den Schlüssel {name} wirklich löschen?"
whyTOTPOnlyRenew: "Solange ein Sicherheitsschlüssel registriert ist, kann die Authentifizierungs-App nicht entfernt werden."
renewTOTP: "Authentifizierungs-App neu einrichten"
renewTOTPConfirm: "Codes der bisherigen App werden hierdurch nutzlos"
renewTOTPOk: "Neu einrichten"
renewTOTPCancel: "Abbrechen"
_permissions: _permissions:
"read:account": "Deine Benutzerkontoinformationen lesen" "read:account": "Deine Benutzerkontoinformationen lesen"
"write:account": "Deine Benutzerkontoinformationen bearbeiten" "write:account": "Deine Benutzerkontoinformationen bearbeiten"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "Nur für Follower sichtbar" followersDescription: "Nur für Follower sichtbar"
specified: "Direkt" specified: "Direkt"
specifiedDescription: "Nur für bestimmte Benutzer sichtbar" specifiedDescription: "Nur für bestimmte Benutzer sichtbar"
disableFederation: "Deförderiert"
disableFederationDescription: "Nicht an andere Instanzen übertragen"
_postForm: _postForm:
replyPlaceholder: "Dieser Notiz antworten …" replyPlaceholder: "Dieser Notiz antworten …"
quotePlaceholder: "Diese Notiz zitieren …" quotePlaceholder: "Diese Notiz zitieren …"
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "Ende von Umfragen" pollEnded: "Ende von Umfragen"
receiveFollowRequest: "Erhaltene Follow-Anfragen" receiveFollowRequest: "Erhaltene Follow-Anfragen"
followRequestAccepted: "Akzeptierte Follow-Anfragen" followRequestAccepted: "Akzeptierte Follow-Anfragen"
achievementEarned: "Errungenschaft freigeschaltet"
app: "Benachrichtigungen von Apps" app: "Benachrichtigungen von Apps"
_actions: _actions:
followBack: "folgt dir nun auch" followBack: "folgt dir nun auch"
@ -1802,3 +1833,6 @@ _deck:
channel: "Kanal" channel: "Kanal"
mentions: "Erwähnungen" mentions: "Erwähnungen"
direct: "Direktnachrichten" direct: "Direktnachrichten"
_dialog:
charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}"
charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}"

View File

@ -393,13 +393,19 @@ about: "About"
aboutMisskey: "About Misskey" aboutMisskey: "About Misskey"
administrator: "Administrator" administrator: "Administrator"
token: "Token" token: "Token"
2fa: "Two-factor authentication"
totp: "Authenticator App"
totpDescription: "Use an authenticator app to enter one-time passwords"
moderator: "Moderator" moderator: "Moderator"
moderation: "Moderation" moderation: "Moderation"
nUsersMentioned: "Mentioned by {n} users" nUsersMentioned: "Mentioned by {n} users"
securityKeyAndPasskey: "Security- and passkeys"
securityKey: "Security key" securityKey: "Security key"
lastUsed: "Last used" lastUsed: "Last used"
lastUsedAt: "Last used: {t}"
unregister: "Unregister" unregister: "Unregister"
passwordLessLogin: "Password-less login" passwordLessLogin: "Password-less login"
passwordLessLoginDescription: "Allows password-less login using a security- or passkey only"
resetPassword: "Reset password" resetPassword: "Reset password"
newPasswordIs: "The new password is \"{password}\"" newPasswordIs: "The new password is \"{password}\""
reduceUiAnimation: "Reduce UI animations" reduceUiAnimation: "Reduce UI animations"
@ -773,6 +779,7 @@ popularPosts: "Popular posts"
shareWithNote: "Share with note" shareWithNote: "Share with note"
ads: "Advertisements" ads: "Advertisements"
expiration: "Deadline" expiration: "Deadline"
startingperiod: "Start"
memo: "Memo" memo: "Memo"
priority: "Priority" priority: "Priority"
high: "High" high: "High"
@ -805,6 +812,7 @@ lastCommunication: "Last communication"
resolved: "Resolved" resolved: "Resolved"
unresolved: "Unresolved" unresolved: "Unresolved"
breakFollow: "Remove follower" breakFollow: "Remove follower"
breakFollowConfirm: "Really remove this follower?"
itsOn: "Enabled" itsOn: "Enabled"
itsOff: "Disabled" itsOff: "Disabled"
emailRequiredForSignup: "Require email address for sign-up" emailRequiredForSignup: "Require email address for sign-up"
@ -939,6 +947,10 @@ collapseRenotes: "Collapse renotes you've already seen"
internalServerError: "Internal Server Error" internalServerError: "Internal Server Error"
internalServerErrorDescription: "The server has run into an unexpected error." internalServerErrorDescription: "The server has run into an unexpected error."
copyErrorInfo: "Copy error details" copyErrorInfo: "Copy error details"
joinThisServer: "Sign up at this instance"
exploreOtherServers: "Look for another instance"
letsLookAtTimeline: "Have a look at the timeline"
disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting."
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "{n}w ago" weeksAgo: "{n}w ago"
monthsAgo: "{n}mo ago" monthsAgo: "{n}mo ago"
yearsAgo: "{n}y ago" yearsAgo: "{n}y ago"
invalid: "None"
_time: _time:
second: "Second(s)" second: "Second(s)"
minute: "Minute(s)" minute: "Minute(s)"
@ -1485,14 +1498,29 @@ _tutorial:
step8_3: "You can always change this setting later." step8_3: "You can always change this setting later."
_2fa: _2fa:
alreadyRegistered: "You have already registered a 2-factor authentication device." alreadyRegistered: "You have already registered a 2-factor authentication device."
registerTOTP: "Register authenticator app"
passwordToTOTP: "Enter your password"
step1: "First, install an authentication app (such as {a} or {b}) on your device." step1: "First, install an authentication app (such as {a} or {b}) on your device."
step2: "Then, scan the QR code displayed on this screen." step2: "Then, scan the QR code displayed on this screen."
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app."
step2Url: "You can also enter this URL if you're using a desktop program:" step2Url: "You can also enter this URL if you're using a desktop program:"
step3Title: "Enter an authentication code"
step3: "Enter the token provided by your app to finish setup." step3: "Enter the token provided by your app to finish setup."
step4: "From now on, any future login attempts will ask for such a login token." step4: "From now on, any future login attempts will ask for such a login token."
securityKeyNotSupported: "Your browser does not support security keys."
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account." securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account."
removeKeyConfirm: "Delete the {name} backup?" chromePasskeyNotSupported: "Chrome passkeys are currently not supported."
renewTOTPCancel: "Not now" registerSecurityKey: "Register a security or pass key"
securityKeyName: "Enter a key name"
tapSecurityKey: "Please follow your browser to register the security or pass key"
removeKey: "Remove security key"
removeKeyConfirm: "Really delete the {name} key?"
whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered."
renewTOTP: "Reconfigure authenticator app"
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working"
renewTOTPOk: "Reconfigure"
renewTOTPCancel: "Cancel"
_permissions: _permissions:
"read:account": "View your account information" "read:account": "View your account information"
"write:account": "Edit your account information" "write:account": "Edit your account information"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "Make visible to your followers only" followersDescription: "Make visible to your followers only"
specified: "Direct" specified: "Direct"
specifiedDescription: "Make visible for specified users only" specifiedDescription: "Make visible for specified users only"
disableFederation: "Unfederated"
disableFederationDescription: "Don't transmit to other instances"
_postForm: _postForm:
replyPlaceholder: "Reply to this note..." replyPlaceholder: "Reply to this note..."
quotePlaceholder: "Quote this note..." quotePlaceholder: "Quote this note..."
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "Polls ending" pollEnded: "Polls ending"
receiveFollowRequest: "Received follow requests" receiveFollowRequest: "Received follow requests"
followRequestAccepted: "Accepted follow requests" followRequestAccepted: "Accepted follow requests"
achievementEarned: "Achievement unlocked"
app: "Notifications from linked apps" app: "Notifications from linked apps"
_actions: _actions:
followBack: "followed you back" followBack: "followed you back"
@ -1802,3 +1833,6 @@ _deck:
channel: "Channel" channel: "Channel"
mentions: "Mentions" mentions: "Mentions"
direct: "Direct notes" direct: "Direct notes"
_dialog:
charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}."
charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}."

View File

@ -103,6 +103,8 @@ renoted: "Renotado"
cantRenote: "No se puede renotar este post" cantRenote: "No se puede renotar este post"
cantReRenote: "No se puede renotar una renota" cantReRenote: "No se puede renotar una renota"
quote: "Citar" quote: "Citar"
inChannelRenote: "Renota sólo del canal"
inChannelQuote: "Cita sólo del canal"
pinnedNote: "Nota fijada" pinnedNote: "Nota fijada"
pinned: "Fijar al perfil" pinned: "Fijar al perfil"
you: "Tú" you: "Tú"
@ -391,13 +393,19 @@ about: "Información"
aboutMisskey: "Sobre Misskey" aboutMisskey: "Sobre Misskey"
administrator: "Administrador" administrator: "Administrador"
token: "Token" token: "Token"
2fa: "Autenticación de doble factor"
totp: "Aplicación autentícadora"
totpDescription: "Ingresa una contaseña de un sólo uso usando la aplicación autenticadora"
moderator: "Moderador" moderator: "Moderador"
moderation: "Moderación" moderation: "Moderación"
nUsersMentioned: "{n} usuarios mencionados" nUsersMentioned: "{n} usuarios mencionados"
securityKeyAndPasskey: "Clave de seguridad / clave de paso"
securityKey: "Clave de seguridad" securityKey: "Clave de seguridad"
lastUsed: "Última vez usado" lastUsed: "Última vez usado"
lastUsedAt: "Último uso: {t}"
unregister: "Cancelar registro" unregister: "Cancelar registro"
passwordLessLogin: "Iniciar sesión sin contraseña" passwordLessLogin: "Iniciar sesión sin contraseña"
passwordLessLoginDescription: "Iniciar sesión con sólo una clave se seguridad / de paso sin usar una contraseña"
resetPassword: "Resetear contraseña" resetPassword: "Resetear contraseña"
newPasswordIs: "La nueva contraseña es \"{password}\"" newPasswordIs: "La nueva contraseña es \"{password}\""
reduceUiAnimation: "Reducir la animación de la UI" reduceUiAnimation: "Reducir la animación de la UI"
@ -451,6 +459,8 @@ native: "Nativo"
disableDrawer: "No mostrar los menús en cajones" disableDrawer: "No mostrar los menús en cajones"
noHistory: "No hay datos en el historial" noHistory: "No hay datos en el historial"
signinHistory: "Historial de ingresos" signinHistory: "Historial de ingresos"
enableAdvancedMfm: "Habilitar MFM avanzado"
enableAnimatedMfm: "Habilitar MFM con movimiento"
doing: "Voy en camino" doing: "Voy en camino"
category: "Categoría" category: "Categoría"
tags: "Etiqueta" tags: "Etiqueta"
@ -769,6 +779,7 @@ popularPosts: "Más vistos"
shareWithNote: "Compartir con una nota" shareWithNote: "Compartir con una nota"
ads: "Anuncios" ads: "Anuncios"
expiration: "Termina el" expiration: "Termina el"
startingperiod: "periodo de inicio"
memo: "Notas" memo: "Notas"
priority: "Prioridad" priority: "Prioridad"
high: "Alta" high: "Alta"
@ -801,6 +812,7 @@ lastCommunication: "Última comunicación"
resolved: "Resuelto" resolved: "Resuelto"
unresolved: "Sin resolver" unresolved: "Sin resolver"
breakFollow: "Dejar de seguir" breakFollow: "Dejar de seguir"
breakFollowConfirm: "¿Quieres dejar de seguir?"
itsOn: "¡Está encendido!" itsOn: "¡Está encendido!"
itsOff: "¡Está apagado!" itsOff: "¡Está apagado!"
emailRequiredForSignup: "Se requere una dirección de correo electrónico para el registro de la cuenta" emailRequiredForSignup: "Se requere una dirección de correo electrónico para el registro de la cuenta"
@ -845,6 +857,8 @@ failedToFetchAccountInformation: "No se pudo obtener información de la cuenta"
rateLimitExceeded: "Se excedió el límite de peticiones" rateLimitExceeded: "Se excedió el límite de peticiones"
cropImage: "Recortar imágen" cropImage: "Recortar imágen"
cropImageAsk: "¿Desea recortar la imagen?" cropImageAsk: "¿Desea recortar la imagen?"
cropYes: "Recortar"
cropNo: "Usar como está"
file: "Archivos" file: "Archivos"
recentNHours: "Últimas {n} horas" recentNHours: "Últimas {n} horas"
recentNDays: "Últimos {n} días" recentNDays: "Últimos {n} días"
@ -925,6 +939,18 @@ selectFromPresets: "Escoger desde predefinidos"
achievements: "Logros" achievements: "Logros"
gotInvalidResponseError: "Respuesta del servidor inválida" gotInvalidResponseError: "Respuesta del servidor inválida"
gotInvalidResponseErrorDescription: "Puede que el servidor esté caído o en mantenimiento. Favor de intentar más tarde" gotInvalidResponseErrorDescription: "Puede que el servidor esté caído o en mantenimiento. Favor de intentar más tarde"
thisPostMayBeAnnoying: "Ésta publicación puede resultar molesta."
thisPostMayBeAnnoyingHome: "Publicar en línea de tiempo 'Inicio'"
thisPostMayBeAnnoyingCancel: "detener"
thisPostMayBeAnnoyingIgnore: "Publicar de todos modos"
collapseRenotes: "Colapsar renotas que ya hayas visto"
internalServerError: "Error interno del servidor"
internalServerErrorDescription: "El servidor tuvo un error inesperado."
copyErrorInfo: "Copiar detalles del error"
joinThisServer: "Registrarse en esta instancia"
exploreOtherServers: "Buscar otra instancia"
letsLookAtTimeline: "Mirar la línea de tiempo local"
disableFederationWarn: "Esto desactivará la federación, pero las publicaciones segurán siendo públicas al menos que se configure diferente. Usualmente no necesitas usar esta configuración."
_achievements: _achievements:
earnedAt: "Desbloqueado el" earnedAt: "Desbloqueado el"
_types: _types:
@ -1438,6 +1464,7 @@ _ago:
weeksAgo: "Hace {n} semanas" weeksAgo: "Hace {n} semanas"
monthsAgo: "Hace {n} meses" monthsAgo: "Hace {n} meses"
yearsAgo: "Hace {n} años" yearsAgo: "Hace {n} años"
invalid: "No hay nada que ver aqui"
_time: _time:
second: "Segundos" second: "Segundos"
minute: "Minutos" minute: "Minutos"
@ -1471,13 +1498,28 @@ _tutorial:
step8_3: "La configuración de las notificaciones puede modificarse posteriormente." step8_3: "La configuración de las notificaciones puede modificarse posteriormente."
_2fa: _2fa:
alreadyRegistered: "Ya has completado la configuración." alreadyRegistered: "Ya has completado la configuración."
registerTOTP: "Registrar aplicación autenticadora"
passwordToTOTP: "Ingresa tu contraseña"
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o {b} u otra." step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o {b} u otra."
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla." step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app.\nTocar este código QR te permitirá registrar la autenticación 2FA a tu llave de seguridad o aplicación autenticadora."
step2Url: "En una aplicación de escritorio se puede ingresar la siguiente URL:" step2Url: "En una aplicación de escritorio se puede ingresar la siguiente URL:"
step3Title: "Ingresa un código de autenticación"
step3: "Para terminar, ingrese el token mostrado en la aplicación." step3: "Para terminar, ingrese el token mostrado en la aplicación."
step4: "Ahora cuando inicie sesión, ingrese el mismo token" step4: "Ahora cuando inicie sesión, ingrese el mismo token"
securityKeyNotSupported: "Tu navegador no soporta claves de autenticación."
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key.\npor favor. configura una aplicación de autenticación para registrar una llave de seguridad."
securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad de hardware que soporte FIDO2 o con un certificado de huella digital o con un PIN" securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad de hardware que soporte FIDO2 o con un certificado de huella digital o con un PIN"
chromePasskeyNotSupported: "Las llaves de seguridad de Chrome no son soportadas por el momento."
registerSecurityKey: "Registrar una llave de seguridad"
securityKeyName: "Ingresa un nombre para la clave"
tapSecurityKey: "Por favor, sigue tu navegador para registrar una llave de seguridad"
removeKey: "Quitar la llave de seguridad"
removeKeyConfirm: "¿Borrar el respaldo \"{name}\"?" removeKeyConfirm: "¿Borrar el respaldo \"{name}\"?"
whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered.\nLa aplicación autenticadora no puede ser eliminada mientras la llave de seguridad se encuentre registrada."
renewTOTP: "Reconfigurar la aplicación autenticadora"
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working\nEsto hará que los códigos de verificación de la aplicación anterior dejen de funcionar"
renewTOTPOk: "Reconfigurar"
renewTOTPCancel: "No gracias" renewTOTPCancel: "No gracias"
_permissions: _permissions:
"read:account": "Ver información de la cuenta" "read:account": "Ver información de la cuenta"
@ -1601,6 +1643,8 @@ _visibility:
followersDescription: "Visible sólo para tus seguidores" followersDescription: "Visible sólo para tus seguidores"
specified: "Mensaje directo" specified: "Mensaje directo"
specifiedDescription: "Visible sólo para los usuarios elegidos" specifiedDescription: "Visible sólo para los usuarios elegidos"
disableFederation: "No federado"
disableFederationDescription: "No enviar a otras instancias"
_postForm: _postForm:
replyPlaceholder: "Responder a esta nota" replyPlaceholder: "Responder a esta nota"
quotePlaceholder: "Citar esta nota" quotePlaceholder: "Citar esta nota"
@ -1756,6 +1800,7 @@ _notification:
pollEnded: "La encuesta terminó" pollEnded: "La encuesta terminó"
receiveFollowRequest: "Recibió una solicitud de seguimiento" receiveFollowRequest: "Recibió una solicitud de seguimiento"
followRequestAccepted: "El seguimiento fue aceptado" followRequestAccepted: "El seguimiento fue aceptado"
achievementEarned: "Logro desbloqueado"
app: "Notificaciones desde aplicaciones" app: "Notificaciones desde aplicaciones"
_actions: _actions:
followBack: "Te sigue de vuelta" followBack: "Te sigue de vuelta"
@ -1788,3 +1833,6 @@ _deck:
channel: "Canal" channel: "Canal"
mentions: "Menciones" mentions: "Menciones"
direct: "Mensaje directo" direct: "Mensaje directo"
_dialog:
charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}."
charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}."

View File

@ -103,6 +103,8 @@ renoted: "Renoté !"
cantRenote: "Ce message ne peut pas être renoté." cantRenote: "Ce message ne peut pas être renoté."
cantReRenote: "Impossible de renoter une Renote." cantReRenote: "Impossible de renoter une Renote."
quote: "Citer" quote: "Citer"
inChannelRenote: "Renoter dans le canal"
inChannelQuote: "Citer dans le canal"
pinnedNote: "Note épinglée" pinnedNote: "Note épinglée"
pinned: "Épingler sur le profil" pinned: "Épingler sur le profil"
you: "Vous" you: "Vous"
@ -129,6 +131,7 @@ unblockConfirm: "Êtes-vous sûr·e de vouloir débloquer ce compte ?"
suspendConfirm: "Êtes-vous sûr·e de vouloir suspendre ce compte ?" suspendConfirm: "Êtes-vous sûr·e de vouloir suspendre ce compte ?"
unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?" unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?"
selectList: "Sélectionner une liste" selectList: "Sélectionner une liste"
selectChannel: "Sélectionner un canal"
selectAntenna: "Sélectionner une antenne" selectAntenna: "Sélectionner une antenne"
selectWidget: "Sélectionner un widget" selectWidget: "Sélectionner un widget"
editWidgets: "Modifier les widgets" editWidgets: "Modifier les widgets"
@ -898,6 +901,17 @@ show: "Affichage"
neverShow: "Ne plus afficher" neverShow: "Ne plus afficher"
remindMeLater: "Peut-être plus tard" remindMeLater: "Peut-être plus tard"
color: "Couleur" color: "Couleur"
_achievements:
_types:
_notes100000:
title: "ALL YOUR NOTE ARE BELONG TO US"
_login1000:
flavor: "Merci d'utiliser Misskey !"
_markedAsCat:
title: "Je suis un chat"
flavor: "Je n'ai pas encore de nom"
_following50:
title: "Beaucoup d'amis"
_role: _role:
priority: "Priorité" priority: "Priorité"
_priority: _priority:
@ -1121,6 +1135,7 @@ _ago:
weeksAgo: "Il y a {n} semaines" weeksAgo: "Il y a {n} semaines"
monthsAgo: "Il y a {n} mois" monthsAgo: "Il y a {n} mois"
yearsAgo: "Il y a {n} ans" yearsAgo: "Il y a {n} ans"
invalid: "Il n'y a rien à voir ici"
_time: _time:
second: "s" second: "s"
minute: "min" minute: "min"

View File

@ -1452,6 +1452,7 @@ _ago:
weeksAgo: "{n} minggu lalu" weeksAgo: "{n} minggu lalu"
monthsAgo: "{n} bulan lalu" monthsAgo: "{n} bulan lalu"
yearsAgo: "{n} tahun lalu" yearsAgo: "{n} tahun lalu"
invalid: "Tidak ada sama sekali disini"
_time: _time:
second: "detik" second: "detik"
minute: "menit" minute: "menit"

View File

@ -1452,6 +1452,7 @@ _ago:
weeksAgo: "{n} sett. fa" weeksAgo: "{n} sett. fa"
monthsAgo: "{n} mesi fa" monthsAgo: "{n} mesi fa"
yearsAgo: "{n} anni fa" yearsAgo: "{n} anni fa"
invalid: "Niente da visualizzare"
_time: _time:
second: "s" second: "s"
minute: "min" minute: "min"

View File

@ -812,6 +812,7 @@ lastCommunication: "直近の通信"
resolved: "解決済み" resolved: "解決済み"
unresolved: "未解決" unresolved: "未解決"
breakFollow: "フォロワーを解除" breakFollow: "フォロワーを解除"
breakFollowConfirm: "フォロワー解除しますか?"
itsOn: "オンになっています" itsOn: "オンになっています"
itsOff: "オフになっています" itsOff: "オフになっています"
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする" emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする"
@ -1489,6 +1490,7 @@ _ago:
weeksAgo: "{n}週間前" weeksAgo: "{n}週間前"
monthsAgo: "{n}ヶ月前" monthsAgo: "{n}ヶ月前"
yearsAgo: "{n}年前" yearsAgo: "{n}年前"
invalid: "ありません"
_time: _time:
second: "秒" second: "秒"

View File

@ -393,13 +393,19 @@ about: "情報"
aboutMisskey: "Misskeyってなんや" aboutMisskey: "Misskeyってなんや"
administrator: "管理者" administrator: "管理者"
token: "トークン" token: "トークン"
2fa: "二要素認証"
totp: "認証アプリ"
totpDescription: "認証アプリ使てワンタイムパスワードを入れる"
moderator: "モデレーター" moderator: "モデレーター"
moderation: "モデレーション" moderation: "モデレーション"
nUsersMentioned: "{n}人が投稿" nUsersMentioned: "{n}人が投稿"
securityKeyAndPasskey: "セキュリティキー・パスキー"
securityKey: "セキュリティキー" securityKey: "セキュリティキー"
lastUsed: "最後につこうた日" lastUsed: "最後につこうた日"
lastUsedAt: "最後に使たん: {t}"
unregister: "登録やめる" unregister: "登録やめる"
passwordLessLogin: "パスワード無くてもログインできるようにする" passwordLessLogin: "パスワード無くてもログインできるようにする"
passwordLessLoginDescription: "パスワードやなくて、セキュリティキーとかパスキーだけでログインするわ"
resetPassword: "パスワードをリセット" resetPassword: "パスワードをリセット"
newPasswordIs: "今度のパスワードは「{password}」や" newPasswordIs: "今度のパスワードは「{password}」や"
reduceUiAnimation: "UIの動きやアニメーションを減らす" reduceUiAnimation: "UIの動きやアニメーションを減らす"
@ -575,7 +581,7 @@ generateAccessToken: "アクセストークンの発行"
permission: "権限" permission: "権限"
enableAll: "全部使えるようにする" enableAll: "全部使えるようにする"
disableAll: "全部使えへんようにする" disableAll: "全部使えへんようにする"
tokenRequested: "アカウントへのアクセス許" tokenRequested: "アカウントへのアクセス許してやったらどうや"
pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。"
notificationType: "通知の種類" notificationType: "通知の種類"
edit: "編集" edit: "編集"
@ -773,6 +779,7 @@ popularPosts: "人気の投稿"
shareWithNote: "ノートで共有" shareWithNote: "ノートで共有"
ads: "広告" ads: "広告"
expiration: "期限" expiration: "期限"
startingperiod: "始めた期間"
memo: "メモ" memo: "メモ"
priority: "優先度" priority: "優先度"
high: "高い" high: "高い"
@ -805,6 +812,7 @@ lastCommunication: "直近の通信"
resolved: "解決したで" resolved: "解決したで"
unresolved: "まだ解決してないで" unresolved: "まだ解決してないで"
breakFollow: "フォロワーを解除するで" breakFollow: "フォロワーを解除するで"
breakFollowConfirm: "フォロワー解除してもええか?"
itsOn: "オンになっとるよ" itsOn: "オンになっとるよ"
itsOff: "オフになってるで" itsOff: "オフになってるで"
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで" emailRequiredForSignup: "アカウント登録にメールアドレスを必須にするで"
@ -939,6 +947,10 @@ collapseRenotes: "見たことあるRenoteは省略やで"
internalServerError: "サーバー内部エラー" internalServerError: "サーバー内部エラー"
internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ"
copyErrorInfo: "エラー情報をコピー" copyErrorInfo: "エラー情報をコピー"
joinThisServer: "このサーバーに登録するわ"
exploreOtherServers: "他のサーバー見てみる"
letsLookAtTimeline: "タイムライン見てみーや"
disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:
@ -1051,21 +1063,42 @@ _achievements:
_myNoteFavorited1: _myNoteFavorited1:
title: "星ぃ欲しい" title: "星ぃ欲しい"
description: "ワレのノートが他のひとにお気に入り登録されたで" description: "ワレのノートが他のひとにお気に入り登録されたで"
_profileFilled:
title: "準備万端や"
description: "プロフィールを設定した"
_markedAsCat:
title: "吾輩は猫やねん"
description: "アカウントがCatになってもうた"
flavor: "名前はまだないねん。"
_following1:
title: "はじめてのフォロー"
description: "初めてフォローした"
_following10: _following10:
title: "ついてく、ついてく"
description: "フォローが10人超えた" description: "フォローが10人超えた"
_following50: _following50:
title: "友達ぎょうさん"
description: "フォローが50人超えた" description: "フォローが50人超えた"
_following100: _following100:
title: "友達100人"
description: "フォローが100人超えた" description: "フォローが100人超えた"
_following300: _following300:
title: "いや友達多すぎやろ"
description: "フォローが300人超えた" description: "フォローが300人超えた"
_followers1:
title: "はじめてのフォロワー"
description: "初めてフォローされた"
_followers10: _followers10:
title: "フォローみぃ!"
description: "フォロワーが10人超えた" description: "フォロワーが10人超えた"
_followers50: _followers50:
title: "ぞろぞろ"
description: "フォロワーが50人超えた" description: "フォロワーが50人超えた"
_followers100: _followers100:
title: "人気もん"
description: "フォロワーが100人超えた" description: "フォロワーが100人超えた"
_followers300: _followers300:
title: "ほらそこ一列に並んで!"
description: "フォロワーが300人超えた" description: "フォロワーが300人超えた"
_followers500: _followers500:
title: "基地局" title: "基地局"
@ -1150,6 +1183,10 @@ _achievements:
title: "クッキー叩くやつ" title: "クッキー叩くやつ"
description: "クッキー叩いてもうた" description: "クッキー叩いてもうた"
flavor: "兄ちゃんソフト間違っとんで" flavor: "兄ちゃんソフト間違っとんで"
_brainDiver:
title: "Brain Diver"
description: "Brain Diverへのリンクを投稿したった"
flavor: "Misskey-Misskey La-Tu-Ma"
_role: _role:
new: "ロールの作成" new: "ロールの作成"
edit: "ロールの編集" edit: "ロールの編集"
@ -1170,6 +1207,8 @@ _role:
baseRole: "ベースロール" baseRole: "ベースロール"
useBaseValue: "ベースロールの値を使用" useBaseValue: "ベースロールの値を使用"
chooseRoleToAssign: "アサインするロールを選択" chooseRoleToAssign: "アサインするロールを選択"
iconUrl: "アイコン画像のURL"
asBadge: "バッジとして見せる"
descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。"
canEditMembersByModerator: "モデレーターのメンバー編集を許可" canEditMembersByModerator: "モデレーターのメンバー編集を許可"
descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。"
@ -1425,6 +1464,7 @@ _ago:
weeksAgo: "{n}週間前" weeksAgo: "{n}週間前"
monthsAgo: "{n}ヶ月前" monthsAgo: "{n}ヶ月前"
yearsAgo: "{n}年前" yearsAgo: "{n}年前"
invalid: "あらへん"
_time: _time:
second: "秒" second: "秒"
minute: "分" minute: "分"
@ -1458,13 +1498,28 @@ _tutorial:
step8_3: "通知の設定はあとから変更できるで" step8_3: "通知の設定はあとから変更できるで"
_2fa: _2fa:
alreadyRegistered: "もう設定終わっとるわ。" alreadyRegistered: "もう設定終わっとるわ。"
registerTOTP: "認証アプリの設定はじめる"
passwordToTOTP: "パスワードを入れてーや"
step1: "ほんなら、{a}や{b}とかの認証アプリを使っとるデバイスにインストールしてな。" step1: "ほんなら、{a}や{b}とかの認証アプリを使っとるデバイスにインストールしてな。"
step2: "次に、ここにあるQRコードをアプリでスキャンしてな。" step2: "次に、ここにあるQRコードをアプリでスキャンしてな。"
step2Click: "QRコードをクリックすると、今使とる端末に入っとる認証アプリとかキーリングに登録できるで。"
step2Url: "デスクトップアプリやったら次のURLを入力してや:" step2Url: "デスクトップアプリやったら次のURLを入力してや:"
step3Title: "確認コードを入れてーや"
step3: "アプリに表示されているトークンを入力して終わりや。" step3: "アプリに表示されているトークンを入力して終わりや。"
step4: "これからログインするときも、同じようにトークンを入力するんやで" step4: "これからログインするときも、同じようにトークンを入力するんやで"
securityKeyNotSupported: "今使とるブラウザはセキュリティキーに対応してへんのやってさ。"
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するんやったら、まず認証アプリを設定してーな。"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーか端末の指紋認証やPINを使ってログインするように設定できるで。" securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーか端末の指紋認証やPINを使ってログインするように設定できるで。"
chromePasskeyNotSupported: "Chromeのパスキーは今んとこ対応してないねん。"
registerSecurityKey: "セキュリティキー・パスキーを登録するわ"
securityKeyName: "キーの名前を入れてーや"
tapSecurityKey: "ブラウザが言うこと聞いて、セキュリティキーとかパスキー登録しといでや"
removeKey: "セキュリティキーをほかす"
removeKeyConfirm: "{name}を消すん?" removeKeyConfirm: "{name}を消すん?"
whyTOTPOnlyRenew: "セキュリティキーが登録されとったら、認証アプリの設定は解除できへんで。"
renewTOTP: "認証アプリをもっかい設定"
renewTOTPConfirm: "今までの人称アプリの確認コードは使えんくなるけどええか?"
renewTOTPOk: "もっかい設定する"
renewTOTPCancel: "やめとく" renewTOTPCancel: "やめとく"
_permissions: _permissions:
"read:account": "アカウントの情報を見るで" "read:account": "アカウントの情報を見るで"
@ -1500,6 +1555,7 @@ _permissions:
"read:gallery-likes": "ギャラリーのいいねを見るで" "read:gallery-likes": "ギャラリーのいいねを見るで"
"write:gallery-likes": "ギャラリーのいいねを操作するで" "write:gallery-likes": "ギャラリーのいいねを操作するで"
_auth: _auth:
shareAccessTitle: "アプリへのアクセス許してやったらどうや"
shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?" shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?"
shareAccessAsk: "アカウントのアクセスを許可してもええか?" shareAccessAsk: "アカウントのアクセスを許可してもええか?"
permission: "{name}に次の権限つけたってやって" permission: "{name}に次の権限つけたってやって"
@ -1587,14 +1643,16 @@ _visibility:
followersDescription: "自分のフォロワーのみに公開するで" followersDescription: "自分のフォロワーのみに公開するで"
specified: "ダイレクト" specified: "ダイレクト"
specifiedDescription: "選んだユーザーのみに公開するで" specifiedDescription: "選んだユーザーのみに公開するで"
disableFederation: "連合なし"
disableFederationDescription: "他インスタンスへは送らんとくわ"
_postForm: _postForm:
replyPlaceholder: "このノートに返信..." replyPlaceholder: "このノートに返信..."
quotePlaceholder: "このノートを引用..." quotePlaceholder: "このノートを引用..."
channelPlaceholder: "チャンネルに投稿..." channelPlaceholder: "チャンネルに投稿..."
_placeholders: _placeholders:
a: "いまどしとるん?" a: "いまどないしとるん?"
b: "何かあったん?" b: "何かあったん?"
c: "何考えとるん?" c: "何考えとるん?"
d: "何か言いたいことあるん?" d: "何か言いたいことあるん?"
e: "ここに書いてーなー" e: "ここに書いてーなー"
f: "あんたが書くの待っとるで" f: "あんたが書くの待っとるで"
@ -1742,6 +1800,7 @@ _notification:
pollEnded: "アンケートが終了したで" pollEnded: "アンケートが終了したで"
receiveFollowRequest: "フォロー許可してほしいみたいやで" receiveFollowRequest: "フォロー許可してほしいみたいやで"
followRequestAccepted: "フォローが受理されたで" followRequestAccepted: "フォローが受理されたで"
achievementEarned: "実績の獲得"
app: "連携アプリからの通知や" app: "連携アプリからの通知や"
_actions: _actions:
followBack: "フォローバック" followBack: "フォローバック"
@ -1774,3 +1833,6 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あんた宛て" mentions: "あんた宛て"
direct: "ダイレクト" direct: "ダイレクト"
_dialog:
charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}"
charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}"

View File

@ -1452,6 +1452,7 @@ _ago:
weeksAgo: "{n}주 전" weeksAgo: "{n}주 전"
monthsAgo: "{n}개월 전" monthsAgo: "{n}개월 전"
yearsAgo: "{n}년 전" yearsAgo: "{n}년 전"
invalid: "아무것도 없습니다"
_time: _time:
second: "초" second: "초"
minute: "분" minute: "분"

View File

@ -1061,6 +1061,7 @@ _ago:
weeksAgo: "{n} tyg. temu" weeksAgo: "{n} tyg. temu"
monthsAgo: "{n} mies. temu" monthsAgo: "{n} mies. temu"
yearsAgo: "{n} lat temu" yearsAgo: "{n} lat temu"
invalid: "Nie ma tu niczego"
_time: _time:
second: "sekunda" second: "sekunda"
minute: "minuta" minute: "minuta"

View File

@ -648,6 +648,8 @@ _sfx:
note: "Note" note: "Note"
notification: "Notificări" notification: "Notificări"
chat: "Chat" chat: "Chat"
_ago:
invalid: "Nu e nimic de văzut aici"
_widgets: _widgets:
profile: "Profil" profile: "Profil"
instanceInfo: "Informații despre instanță" instanceInfo: "Informații despre instanță"

View File

@ -1452,6 +1452,7 @@ _ago:
weeksAgo: "{n} нед. назад" weeksAgo: "{n} нед. назад"
monthsAgo: "{n} мес. назад" monthsAgo: "{n} мес. назад"
yearsAgo: "{n} г. назад" yearsAgo: "{n} г. назад"
invalid: "Ничего нет"
_time: _time:
second: "с" second: "с"
minute: "мин" minute: "мин"

View File

@ -1123,6 +1123,7 @@ _ago:
weeksAgo: "pred {n} týždňami" weeksAgo: "pred {n} týždňami"
monthsAgo: "pred {n} mesiacmi" monthsAgo: "pred {n} mesiacmi"
yearsAgo: "pred {n} rokmi" yearsAgo: "pred {n} rokmi"
invalid: "Nič tu nie je"
_time: _time:
second: "s" second: "s"
minute: "min" minute: "min"

View File

@ -393,13 +393,19 @@ about: "เกี่ยวกับ"
aboutMisskey: "เกี่ยวกับ Misskey" aboutMisskey: "เกี่ยวกับ Misskey"
administrator: "ผู้ดูแลระบบ" administrator: "ผู้ดูแลระบบ"
token: "โทเค็น" token: "โทเค็น"
2fa: "การยืนยันตัวตนแบบสองชั้น"
totp: "แอป Authenticator"
totpDescription: "ใช้แอปยืนยันตัวตนเพื่อป้อนรหัสผ่านแบบใช้ครั้งเดียว"
moderator: "ผู้ควบคุม" moderator: "ผู้ควบคุม"
moderation: "การกลั่นกรอง" moderation: "การกลั่นกรอง"
nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้"
securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน"
securityKey: "กุญแจความปลอดภัย" securityKey: "กุญแจความปลอดภัย"
lastUsed: "ใช้ล่าสุด" lastUsed: "ใช้ล่าสุด"
lastUsedAt: "ใช้งานครั้งล่าสุด: {t}"
unregister: "เลิกติดตาม" unregister: "เลิกติดตาม"
passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน" passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน"
passwordLessLoginDescription: "อนุญาตให้เข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่านโดยใช้รหัสรักษาความปลอดภัยหรือรหัสผ่านเท่านั้น"
resetPassword: "รีเซ็ตรหัสผ่าน" resetPassword: "รีเซ็ตรหัสผ่าน"
newPasswordIs: "รหัสผ่านใหม่คือ \"{password}\"" newPasswordIs: "รหัสผ่านใหม่คือ \"{password}\""
reduceUiAnimation: "ลดภาพเคลื่อนไหว UI" reduceUiAnimation: "ลดภาพเคลื่อนไหว UI"
@ -773,6 +779,7 @@ popularPosts: "โพสต์ติดอันดับ"
shareWithNote: "แบ่งปันด้วยโน้ต" shareWithNote: "แบ่งปันด้วยโน้ต"
ads: "โฆษณา" ads: "โฆษณา"
expiration: "กำหนดเวลา" expiration: "กำหนดเวลา"
startingperiod: "เริ่ม"
memo: "ข้อควรจำ" memo: "ข้อควรจำ"
priority: "ลำดับความสำคัญ" priority: "ลำดับความสำคัญ"
high: "สูง" high: "สูง"
@ -805,6 +812,7 @@ lastCommunication: "การสื่อสารครั้งสุดท้
resolved: "คลี่คลายแล้ว" resolved: "คลี่คลายแล้ว"
unresolved: "รอการเฉลย" unresolved: "รอการเฉลย"
breakFollow: "ลบผู้ติดตาม" breakFollow: "ลบผู้ติดตาม"
breakFollowConfirm: "ลบผู้ติดตามนี้ออกจริงหรอ?"
itsOn: "เปิดใช้งาน" itsOn: "เปิดใช้งาน"
itsOff: "ปิดใช้งาน" itsOff: "ปิดใช้งาน"
emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร" emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร"
@ -939,6 +947,10 @@ collapseRenotes: "ยุบ renotes ที่คุณได้เห็นแ
internalServerError: "เซิร์ฟเวอร์ภายในเกิดข้อผิดพลาด" internalServerError: "เซิร์ฟเวอร์ภายในเกิดข้อผิดพลาด"
internalServerErrorDescription: "เซิร์ฟเวอร์รันค้นพบข้อผิดพลาดที่ไม่คาดคิด" internalServerErrorDescription: "เซิร์ฟเวอร์รันค้นพบข้อผิดพลาดที่ไม่คาดคิด"
copyErrorInfo: "คัดลอกรายละเอียดข้อผิดพลาด" copyErrorInfo: "คัดลอกรายละเอียดข้อผิดพลาด"
joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้"
exploreOtherServers: "มองหาอินสแตนซ์อื่น"
letsLookAtTimeline: "ลองดูที่ไทม์ไลน์"
disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ"
_achievements: _achievements:
earnedAt: "ได้รับเมื่อ" earnedAt: "ได้รับเมื่อ"
_types: _types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "{n} สัปดาห์ที่แล้ว" weeksAgo: "{n} สัปดาห์ที่แล้ว"
monthsAgo: "{n} เดือนที่แล้ว" monthsAgo: "{n} เดือนที่แล้ว"
yearsAgo: "{n} ปีที่ผ่านมา" yearsAgo: "{n} ปีที่ผ่านมา"
invalid: "ไม่พบผลลัพธ์"
_time: _time:
second: "วินาที" second: "วินาที"
minute: "นาที" minute: "นาที"
@ -1485,13 +1498,28 @@ _tutorial:
step8_3: "คุณสามารถเปลี่ยนการตั้งค่านี้ในภายหลังได้ตลอดเวลานะ" step8_3: "คุณสามารถเปลี่ยนการตั้งค่านี้ในภายหลังได้ตลอดเวลานะ"
_2fa: _2fa:
alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว"
registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์"
passwordToTOTP: "กรอกรหัสผ่าน"
step1: "ขั้นตอนแรก ติดตั้งแอปยืนยันตัวตน (เช่น {a} หรือ {b}) บนอุปกรณ์ของคุณ" step1: "ขั้นตอนแรก ติดตั้งแอปยืนยันตัวตน (เช่น {a} หรือ {b}) บนอุปกรณ์ของคุณ"
step2: "จากนั้นสแกนรหัส QR ที่แสดงบนหน้าจอนี้" step2: "จากนั้นสแกนรหัส QR ที่แสดงบนหน้าจอนี้"
step2Click: "การคลิกที่รหัส QR นี้จะช่วยให้คุณนั้นสามารถลงทะเบียน 2FA กับคีย์ความปลอดภัยหรือแอปตรวจสอบความถูกต้องของโทรศัพท์ได้"
step2Url: "คุณยังสามารถป้อนบน URL นี้หากคุณใช้โปรแกรมเดสก์ท็อป:" step2Url: "คุณยังสามารถป้อนบน URL นี้หากคุณใช้โปรแกรมเดสก์ท็อป:"
step3Title: "ป้อนรหัสยืนยัน"
step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า" step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า"
step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว" step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว"
securityKeyNotSupported: "เบราว์เซอร์ของคุณไม่รองรับคีย์ความปลอดภัยนะ"
registerTOTPBeforeKey: "กรุณาตั้งค่าแอปยืนยันตัวตนเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ" securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ"
chromePasskeyNotSupported: "ขณะนี้ยังไม่รองรับรหัสผ่านของ Chrome"
registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
securityKeyName: "ป้อนชื่อคีย์"
tapSecurityKey: "กรุณาทำตามเบราว์เซอร์ของคุณเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
removeKey: "ลบคีย์ความปลอดภัยออก"
removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?"
whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่มีการลงทะเบียนคีย์ความปลอดภัยไว้แล้ว"
renewTOTP: "กำหนดค่าแอพตัวตรวจสอบสิทธิ์ใหม่"
renewTOTPConfirm: "วิธีการแบบนี้จะทําให้รหัสยืนยันจากแอพก่อนหน้าของคุณหยุดทํางานเลยนะ"
renewTOTPOk: "ตั้งค่าคอนฟิกใหม่"
renewTOTPCancel: "ไม่เป็นไร" renewTOTPCancel: "ไม่เป็นไร"
_permissions: _permissions:
"read:account": "ดูข้อมูลบัญชีของคุณ" "read:account": "ดูข้อมูลบัญชีของคุณ"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "ทำให้ผู้ติดตามนั้นมองเห็นแค่คุณเท่านั้น" followersDescription: "ทำให้ผู้ติดตามนั้นมองเห็นแค่คุณเท่านั้น"
specified: "ไดเร็ค" specified: "ไดเร็ค"
specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น" specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น"
disableFederation: "ไม่มีสหภาพ"
disableFederationDescription: "อย่าส่งไปยังอินสแตนซ์อื่น"
_postForm: _postForm:
replyPlaceholder: "ตอบกลับโน้ตนี้..." replyPlaceholder: "ตอบกลับโน้ตนี้..."
quotePlaceholder: "อ้างโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..."
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "โพลนี้สิ้นสุดลงแล้ว" pollEnded: "โพลนี้สิ้นสุดลงแล้ว"
receiveFollowRequest: "ได้รับคำขอติดตาม\n" receiveFollowRequest: "ได้รับคำขอติดตาม\n"
followRequestAccepted: "ยอมรับคำขอติดตาม" followRequestAccepted: "ยอมรับคำขอติดตาม"
achievementEarned: "ปลดล็อกความสำเร็จแล้ว"
app: "การแจ้งเตือนจากแอปที่มีลิงก์" app: "การแจ้งเตือนจากแอปที่มีลิงก์"
_actions: _actions:
followBack: "ติดตามกลับด้วย" followBack: "ติดตามกลับด้วย"
@ -1802,3 +1833,6 @@ _deck:
channel: "แชนแนล" channel: "แชนแนล"
mentions: "พูดถึง" mentions: "พูดถึง"
direct: "ไดเร็ค" direct: "ไดเร็ค"
_dialog:
charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}"
charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}"

View File

@ -49,6 +49,7 @@ deleteAndEdit: "Видалити й редагувати"
deleteAndEditConfirm: "Ви впевнені, що хочете видалити цю нотатку та відредагувати її? Ви втратите всі реакції, поширення та відповіді на неї." deleteAndEditConfirm: "Ви впевнені, що хочете видалити цю нотатку та відредагувати її? Ви втратите всі реакції, поширення та відповіді на неї."
addToList: "Додати до списку" addToList: "Додати до списку"
sendMessage: "Надіслати повідомлення" sendMessage: "Надіслати повідомлення"
copyRSS: "Скопіювати RSS"
copyUsername: "Скопіювати ім’я користувача" copyUsername: "Скопіювати ім’я користувача"
searchUser: "Пошук користувачів" searchUser: "Пошук користувачів"
reply: "Відповісти" reply: "Відповісти"
@ -128,6 +129,7 @@ unblockConfirm: "Ви впевнені, що хочете розблокуват
suspendConfirm: "Ви впевнені, що хочете призупинити цей акаунт?" suspendConfirm: "Ви впевнені, що хочете призупинити цей акаунт?"
unsuspendConfirm: "Ви впевнені, що хочете відновити цей акаунт?" unsuspendConfirm: "Ви впевнені, що хочете відновити цей акаунт?"
selectList: "Виберіть список" selectList: "Виберіть список"
selectChannel: "Виберіть канал"
selectAntenna: "Виберіть антену" selectAntenna: "Виберіть антену"
selectWidget: "Виберіть віджет" selectWidget: "Виберіть віджет"
editWidgets: "Редагувати віджети" editWidgets: "Редагувати віджети"
@ -255,6 +257,7 @@ noMoreHistory: "Подальшої історії немає"
startMessaging: "Розпочати діалог" startMessaging: "Розпочати діалог"
nUsersRead: "Прочитали {n}" nUsersRead: "Прочитали {n}"
agreeTo: "Я погоджуюсь з {0}" agreeTo: "Я погоджуюсь з {0}"
agreeBelow: "Я погоджуюся з наведеним нижче"
tos: "Умови використання" tos: "Умови використання"
start: "Розпочати" start: "Розпочати"
home: "Домівка" home: "Домівка"
@ -387,6 +390,8 @@ about: "Інформація"
aboutMisskey: "Про Misskey" aboutMisskey: "Про Misskey"
administrator: "Адмін" administrator: "Адмін"
token: "Токен" token: "Токен"
2fa: "Двофакторна аутентифікація"
totp: "Програма аутентифікації"
moderator: "Модератор" moderator: "Модератор"
moderation: "Модерація" moderation: "Модерація"
nUsersMentioned: "Згадали: {n}" nUsersMentioned: "Згадали: {n}"
@ -445,6 +450,8 @@ aboutX: "Про {x}"
disableDrawer: "Не використовувати висувні меню" disableDrawer: "Не використовувати висувні меню"
noHistory: "Історія порожня" noHistory: "Історія порожня"
signinHistory: "Історія входів" signinHistory: "Історія входів"
enableAdvancedMfm: "Увімкнути розширений MFM"
enableAnimatedMfm: "Увімкнути анімований MFM"
doing: "Виконується" doing: "Виконується"
category: "Категорія" category: "Категорія"
tags: "Теги" tags: "Теги"
@ -697,6 +704,7 @@ accentColor: "Акцент"
textColor: "Текст" textColor: "Текст"
saveAs: "Зберегти як…" saveAs: "Зберегти як…"
advanced: "Розширені" advanced: "Розширені"
advancedSettings: "Розширені налаштування"
value: "Значення" value: "Значення"
createdAt: "Створено" createdAt: "Створено"
updatedAt: "Останнє оновлення" updatedAt: "Останнє оновлення"
@ -761,6 +769,7 @@ popularPosts: "Популярні дописи"
shareWithNote: "Поділитися нотаткою" shareWithNote: "Поділитися нотаткою"
ads: "Реклама" ads: "Реклама"
expiration: "Опитування закінчується" expiration: "Опитування закінчується"
startingperiod: "Початковий період"
memo: "Примітка" memo: "Примітка"
priority: "Пріоритет" priority: "Пріоритет"
high: "Високий" high: "Високий"
@ -879,8 +888,17 @@ like: "Вподобати"
unlike: "Не вподобати" unlike: "Не вподобати"
numberOfLikes: "Вподобання" numberOfLikes: "Вподобання"
show: "Відображення" show: "Відображення"
roles: "Ролі"
role: "Роль"
normalUser: "Звичайний користувач"
undefined: "Не визначено"
assign: "Призначити"
unassign: "Скасувати призначення"
color: "Колір" color: "Колір"
achievements: "Досягнення" achievements: "Досягнення"
joinThisServer: "Зареєструватися на цьому сервері"
exploreOtherServers: "Знайти інший сервер"
letsLookAtTimeline: "Перегляд історії"
_achievements: _achievements:
earnedAt: "Відкрито" earnedAt: "Відкрито"
_types: _types:
@ -1102,6 +1120,13 @@ _achievements:
description: "Відправити посилання на \"Brain Diver\"" description: "Відправити посилання на \"Brain Diver\""
flavor: "Misskey-Misskey La-Tu-Ma" flavor: "Misskey-Misskey La-Tu-Ma"
_role: _role:
new: "Нова роль"
edit: "Змінити роль"
name: "Назва ролі"
description: "Опис ролі"
permission: "Права ролі"
assignTarget: "Призначити"
manual: "Вручну"
priority: "Пріоритет" priority: "Пріоритет"
_priority: _priority:
low: "Низький" low: "Низький"
@ -1299,6 +1324,7 @@ _ago:
weeksAgo: "{n} тиж. тому" weeksAgo: "{n} тиж. тому"
monthsAgo: "{n} міс. тому" monthsAgo: "{n} міс. тому"
yearsAgo: "{n} р. тому" yearsAgo: "{n} р. тому"
invalid: "Тут нічого немає"
_time: _time:
second: "с" second: "с"
minute: "х" minute: "х"

View File

@ -1102,6 +1102,7 @@ _ago:
weeksAgo: "{n} tuần trước" weeksAgo: "{n} tuần trước"
monthsAgo: "{n} tháng trước" monthsAgo: "{n} tháng trước"
yearsAgo: "{n} năm trước" yearsAgo: "{n} năm trước"
invalid: "Không có gì ở đây"
_time: _time:
second: "s" second: "s"
minute: "phút" minute: "phút"

View File

@ -393,13 +393,19 @@ about: "关于"
aboutMisskey: "关于 Misskey" aboutMisskey: "关于 Misskey"
administrator: "管理员" administrator: "管理员"
token: "Token (令牌)" token: "Token (令牌)"
2fa: "双因素认证"
totp: "身份验证应用"
totpDescription: "使用认证应用输入一次性密码。"
moderator: "监察员" moderator: "监察员"
moderation: "管理" moderation: "管理"
nUsersMentioned: "{n} 被提到" nUsersMentioned: "{n} 被提到"
securityKeyAndPasskey: "安全密钥/密码"
securityKey: "安全密钥" securityKey: "安全密钥"
lastUsed: "最后使用:" lastUsed: "最后使用:"
lastUsedAt: "最后使用: {t}"
unregister: "删除账户" unregister: "删除账户"
passwordLessLogin: "无密码登录" passwordLessLogin: "无密码登录"
passwordLessLoginDescription: "不使用密码仅使用安全密钥或Passkey登录"
resetPassword: "重置密码" resetPassword: "重置密码"
newPasswordIs: "新的密码是「{password}」" newPasswordIs: "新的密码是「{password}」"
reduceUiAnimation: "减少UI动画" reduceUiAnimation: "减少UI动画"
@ -773,6 +779,7 @@ popularPosts: "热门投稿"
shareWithNote: "在帖子中分享" shareWithNote: "在帖子中分享"
ads: "广告" ads: "广告"
expiration: "截止时间" expiration: "截止时间"
startingperiod: "开始时间"
memo: "便笺" memo: "便笺"
priority: "优先级" priority: "优先级"
high: "高" high: "高"
@ -805,6 +812,7 @@ lastCommunication: "最近通信"
resolved: "已解决" resolved: "已解决"
unresolved: "未解决" unresolved: "未解决"
breakFollow: "移除关注者" breakFollow: "移除关注者"
breakFollowConfirm: "你想取消关注吗?"
itsOn: "已开启" itsOn: "已开启"
itsOff: "已关闭" itsOff: "已关闭"
emailRequiredForSignup: "注册账户需要电子邮件地址" emailRequiredForSignup: "注册账户需要电子邮件地址"
@ -849,7 +857,7 @@ failedToFetchAccountInformation: "获取账户信息失败"
rateLimitExceeded: "已超過速率限制" rateLimitExceeded: "已超過速率限制"
cropImage: "剪裁图像" cropImage: "剪裁图像"
cropImageAsk: "是否要裁剪图像?" cropImageAsk: "是否要裁剪图像?"
cropYes: "裁剪" cropYes: "裁剪"
cropNo: "就这样吧!" cropNo: "就这样吧!"
file: "文件" file: "文件"
recentNHours: "最近{n}小时" recentNHours: "最近{n}小时"
@ -939,6 +947,10 @@ collapseRenotes: "省略显示已经看过的转发内容"
internalServerError: "内部服务器错误" internalServerError: "内部服务器错误"
internalServerErrorDescription: "内部服务器发生了预期外的错误" internalServerErrorDescription: "内部服务器发生了预期外的错误"
copyErrorInfo: "复制错误信息" copyErrorInfo: "复制错误信息"
joinThisServer: "在本实例上注册"
exploreOtherServers: "探索其他实例"
letsLookAtTimeline: "时间线"
disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "{n}周前" weeksAgo: "{n}周前"
monthsAgo: "{n}月前" monthsAgo: "{n}月前"
yearsAgo: "{n}年前" yearsAgo: "{n}年前"
invalid: "没有"
_time: _time:
second: "秒" second: "秒"
minute: "分" minute: "分"
@ -1485,13 +1498,28 @@ _tutorial:
step8_3: "您也可以稍后再更改通知设置。" step8_3: "您也可以稍后再更改通知设置。"
_2fa: _2fa:
alreadyRegistered: "此设备已被注册" alreadyRegistered: "此设备已被注册"
registerTOTP: "开始设置认证应用"
passwordToTOTP: "请输入您的密码"
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。" step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
step2: "然后,扫描屏幕上显示的二维码。" step2: "然后,扫描屏幕上显示的二维码。"
step2Click: "通过点击QR码您可以使用设备上安装的身份验证器应用程序或密钥环进行注册"
step2Url: "在桌面应用程序中输入以下URL" step2Url: "在桌面应用程序中输入以下URL"
step3Title: "输入验证码"
step3: "输入您的应用提供的动态口令以完成设置。" step3: "输入您的应用提供的动态口令以完成设置。"
step4: "从现在开始,任何登录操作都将要求您提供动态口令。" step4: "从现在开始,任何登录操作都将要求您提供动态口令。"
securityKeyNotSupported: "您的浏览器不支持安全密钥。"
registerTOTPBeforeKey: "要注册安全密钥或Passkey请先设置验证器应用程序。"
securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。" securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。"
chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。"
registerSecurityKey: "注册安全密钥或Passkey"
securityKeyName: "输入密钥名称"
tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或Passkey。"
removeKey: "删除安全密钥"
removeKeyConfirm: "您确定要删除 {name} 吗?" removeKeyConfirm: "您确定要删除 {name} 吗?"
whyTOTPOnlyRenew: "如果注册了安全密钥,则无法取消验证器应用程序上的设置。"
renewTOTP: "重置验证器应用程序"
renewTOTPConfirm: "当前验证器应用程序的验证码将不再有效"
renewTOTPOk: "重新配置"
renewTOTPCancel: "不用,谢谢" renewTOTPCancel: "不用,谢谢"
_permissions: _permissions:
"read:account": "查看账户信息" "read:account": "查看账户信息"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "仅发送至关注者" followersDescription: "仅发送至关注者"
specified: "指定用户" specified: "指定用户"
specifiedDescription: "仅发送至指定用户" specifiedDescription: "仅发送至指定用户"
disableFederation: "不参与联合"
disableFederationDescription: "不发送到其他实例"
_postForm: _postForm:
replyPlaceholder: "回复这个帖子..." replyPlaceholder: "回复这个帖子..."
quotePlaceholder: "引用这个帖子..." quotePlaceholder: "引用这个帖子..."
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "问卷调查结束" pollEnded: "问卷调查结束"
receiveFollowRequest: "收到关注请求" receiveFollowRequest: "收到关注请求"
followRequestAccepted: "关注请求已通过" followRequestAccepted: "关注请求已通过"
achievementEarned: "取得的成就"
app: "关联应用的通知" app: "关联应用的通知"
_actions: _actions:
followBack: "回关" followBack: "回关"
@ -1802,3 +1833,6 @@ _deck:
channel: "频道" channel: "频道"
mentions: "提及" mentions: "提及"
direct: "指定用户" direct: "指定用户"
_dialog:
charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}"
charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}"

View File

@ -46,7 +46,7 @@ copyContent: "複製內容"
copyLink: "複製連結" copyLink: "複製連結"
delete: "刪除" delete: "刪除"
deleteAndEdit: "刪除並編輯" deleteAndEdit: "刪除並編輯"
deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有情感、轉發和回覆也將會消失。" deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有反應、轉發和回覆也將會消失。"
addToList: "加入至清單" addToList: "加入至清單"
sendMessage: "發送訊息" sendMessage: "發送訊息"
copyRSS: "複製RSS" copyRSS: "複製RSS"
@ -112,7 +112,7 @@ clickToShow: "按一下以顯示"
sensitive: "敏感內容" sensitive: "敏感內容"
add: "新增" add: "新增"
reaction: "反應" reaction: "反應"
reactions: "情感" reactions: "反應"
reactionSetting: "在選擇器中顯示反應" reactionSetting: "在選擇器中顯示反應"
reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。" reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。"
rememberNoteVisibility: "記住貼文可見性" rememberNoteVisibility: "記住貼文可見性"
@ -213,7 +213,7 @@ default: "預設"
defaultValueIs: "預設值:{value}" defaultValueIs: "預設值:{value}"
noCustomEmojis: "沒有自訂的表情符號" noCustomEmojis: "沒有自訂的表情符號"
noJobs: "沒有任務" noJobs: "沒有任務"
federating: "整合搜索中" federating: "聯邦運作中"
blocked: "已封鎖" blocked: "已封鎖"
suspended: "已凍結" suspended: "已凍結"
all: "全部" all: "全部"
@ -393,13 +393,19 @@ about: "關於"
aboutMisskey: "關於 Misskey" aboutMisskey: "關於 Misskey"
administrator: "管理員" administrator: "管理員"
token: "權杖" token: "權杖"
2fa: "雙因素驗證"
totp: "驗證應用程式"
totpDescription: "以驗證應用程式輸入一次性密碼"
moderator: "審查員" moderator: "審查員"
moderation: "審查" moderation: "審查"
nUsersMentioned: "提到了{n}" nUsersMentioned: "提到了{n}"
securityKeyAndPasskey: "安全金鑰・Passkey"
securityKey: "安全金鑰" securityKey: "安全金鑰"
lastUsed: "上次使用" lastUsed: "上次使用"
lastUsedAt: "最後使用:{t}"
unregister: "註銷帳號" unregister: "註銷帳號"
passwordLessLogin: "設置無密碼登入" passwordLessLogin: "設置無密碼登入"
passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入"
resetPassword: "重置密碼" resetPassword: "重置密碼"
newPasswordIs: "新密碼為「{password}」" newPasswordIs: "新密碼為「{password}」"
reduceUiAnimation: "減少介面的動態視覺" reduceUiAnimation: "減少介面的動態視覺"
@ -773,6 +779,7 @@ popularPosts: "熱門的貼文"
shareWithNote: "在貼文中分享" shareWithNote: "在貼文中分享"
ads: "廣告" ads: "廣告"
expiration: "期限" expiration: "期限"
startingperiod: "開始期間"
memo: "備忘錄" memo: "備忘錄"
priority: "優先級" priority: "優先級"
high: "高" high: "高"
@ -805,6 +812,7 @@ lastCommunication: "最近的通信"
resolved: "已解決" resolved: "已解決"
unresolved: "未解決" unresolved: "未解決"
breakFollow: "移除追蹤者" breakFollow: "移除追蹤者"
breakFollowConfirm: "確定要取消被追隨嗎?"
itsOn: "已開啟" itsOn: "已開啟"
itsOff: "已關閉" itsOff: "已關閉"
emailRequiredForSignup: "註冊帳戶需要電子郵件地址" emailRequiredForSignup: "註冊帳戶需要電子郵件地址"
@ -939,6 +947,10 @@ collapseRenotes: "省略顯示已看過的轉發貼文"
internalServerError: "內部伺服器錯誤" internalServerError: "內部伺服器錯誤"
internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。" internalServerErrorDescription: "內部伺服器發生了非預期的錯誤。"
copyErrorInfo: "複製錯誤資訊" copyErrorInfo: "複製錯誤資訊"
joinThisServer: "在此伺服器上註冊"
exploreOtherServers: "探索其他伺服器"
letsLookAtTimeline: "看看時間軸"
disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:
@ -1083,7 +1095,7 @@ _achievements:
title: "成群結隊" title: "成群結隊"
description: "跟隨者超過50人了" description: "跟隨者超過50人了"
_followers100: _followers100:
title: "紅人" title: "熱門人物"
description: "跟隨者超過100人了" description: "跟隨者超過100人了"
_followers300: _followers300:
title: "請排成一排" title: "請排成一排"
@ -1141,7 +1153,7 @@ _achievements:
description: "試圖遞迴套入雲端硬碟資料夾" description: "試圖遞迴套入雲端硬碟資料夾"
_reactWithoutRead: _reactWithoutRead:
title: "有好好讀過嗎?" title: "有好好讀過嗎?"
description: "對包含100字以上內容的貼文做出情感反應" description: "對包含100字以上內容的貼文在3秒以內做出反應"
_clickedClickHere: _clickedClickHere:
title: "點擊這裡" title: "點擊這裡"
description: "已點擊這裡了" description: "已點擊這裡了"
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "{n}周前" weeksAgo: "{n}周前"
monthsAgo: "{n}個月前" monthsAgo: "{n}個月前"
yearsAgo: "{n}年前" yearsAgo: "{n}年前"
invalid: "未發現"
_time: _time:
second: "秒" second: "秒"
minute: "分鐘" minute: "分鐘"
@ -1485,13 +1498,28 @@ _tutorial:
step8_3: "通知的設定可以在之後變更。" step8_3: "通知的設定可以在之後變更。"
_2fa: _2fa:
alreadyRegistered: "此設備已經被註冊過了" alreadyRegistered: "此設備已經被註冊過了"
registerTOTP: "開始設定驗證應用程式"
passwordToTOTP: "請輸入密碼"
step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。" step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。"
step2: "然後掃描螢幕上的QR code。" step2: "然後掃描螢幕上的QR code。"
step2Click: "點擊QR code可以使用設備上安裝的驗證應用程式或金鑰環進行註冊。"
step2Url: "在桌面版應用中請輸入以下的URL" step2Url: "在桌面版應用中請輸入以下的URL"
step3Title: "輸入驗證碼"
step3: "輸入您的App提供的權杖以完成設定。" step3: "輸入您的App提供的權杖以完成設定。"
step4: "從現在開始,任何登入操作都將要求您提供權杖。" step4: "從現在開始,任何登入操作都將要求您提供權杖。"
securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。"
registerTOTPBeforeKey: "要註冊安全金鑰・Passkey請先設定驗證應用程式。"
securityKeyInfo: "您可以設定使用支援FIDO2的硬體安全鎖、終端設備的指纹認證或者PIN碼來登入。" securityKeyInfo: "您可以設定使用支援FIDO2的硬體安全鎖、終端設備的指纹認證或者PIN碼來登入。"
chromePasskeyNotSupported: "目前不支援Chrome的Passkey。"
registerSecurityKey: "註冊安全金鑰・Passkey"
securityKeyName: "輸入金鑰名稱"
tapSecurityKey: "按照瀏覽器的說明操作註冊安全金鑰和Passkey。"
removeKey: "刪除安全金鑰"
removeKeyConfirm: "要刪除{name}嗎?" removeKeyConfirm: "要刪除{name}嗎?"
whyTOTPOnlyRenew: "如果註冊了安全金鑰,則無法解除驗證應用程式的設定。"
renewTOTP: "重設驗證應用程式"
renewTOTPConfirm: "目前驗證應用程式的驗證碼將無法使用。"
renewTOTPOk: "重設"
renewTOTPCancel: "現在不要" renewTOTPCancel: "現在不要"
_permissions: _permissions:
"read:account": "查看我的帳戶資訊" "read:account": "查看我的帳戶資訊"
@ -1564,7 +1592,7 @@ _widgets:
photos: "照片" photos: "照片"
digitalClock: "電子時鐘" digitalClock: "電子時鐘"
unixClock: "UNIX時間" unixClock: "UNIX時間"
federation: "聯邦宇宙" federation: "站台聯邦"
instanceCloud: "實例雲" instanceCloud: "實例雲"
postForm: "發佈窗口" postForm: "發佈窗口"
slideshow: "幻燈片" slideshow: "幻燈片"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "僅發送至關注者" followersDescription: "僅發送至關注者"
specified: "指定使用者" specified: "指定使用者"
specifiedDescription: "僅發送至指定使用者" specifiedDescription: "僅發送至指定使用者"
disableFederation: "停用聯邦"
disableFederationDescription: "不要傳遞給其他實例"
_postForm: _postForm:
replyPlaceholder: "回覆此貼文..." replyPlaceholder: "回覆此貼文..."
quotePlaceholder: "引用此貼文..." quotePlaceholder: "引用此貼文..."
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "問卷調查結束" pollEnded: "問卷調查結束"
receiveFollowRequest: "已收到追隨請求" receiveFollowRequest: "已收到追隨請求"
followRequestAccepted: "追隨請求已接受" followRequestAccepted: "追隨請求已接受"
achievementEarned: "獲得成就"
app: "應用程式通知" app: "應用程式通知"
_actions: _actions:
followBack: "回關" followBack: "回關"
@ -1802,3 +1833,6 @@ _deck:
channel: "頻道" channel: "頻道"
mentions: "提及" mentions: "提及"
direct: "指定使用者" direct: "指定使用者"
_dialog:
charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}"
charactersBelow: "低於最少字數!現在 {current} / 限制 {max}"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.7.5", "version": "13.8.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -16,10 +16,11 @@
"scripts": { "scripts": {
"build-pre": "node ./scripts/build-pre.js", "build-pre": "node ./scripts/build-pre.js",
"build": "pnpm build-pre && pnpm -r build && pnpm gulp", "build": "pnpm build-pre && pnpm -r build && pnpm gulp",
"start": "cd packages/backend && node ./built/boot/index.js", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js",
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js",
"init": "pnpm migrate", "init": "pnpm migrate",
"migrate": "cd packages/backend && pnpm migrate", "migrate": "cd packages/backend && pnpm migrate",
"check:connect": "cd packages/backend && pnpm check:connect",
"migrateandstart": "pnpm migrate && pnpm start", "migrateandstart": "pnpm migrate && pnpm start",
"gulp": "pnpm exec gulp build", "gulp": "pnpm exec gulp build",
"watch": "pnpm dev", "watch": "pnpm dev",
@ -54,11 +55,11 @@
"devDependencies": { "devDependencies": {
"@types/gulp": "4.0.10", "@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
"@typescript-eslint/eslint-plugin": "5.52.0", "@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.52.0", "@typescript-eslint/parser": "5.53.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.6.0", "cypress": "12.7.0",
"eslint": "8.34.0", "eslint": "8.35.0",
"start-server-and-test": "1.15.4" "start-server-and-test": "1.15.4"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
parserOptions: { parserOptions: {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
project: ['./tsconfig.json'], project: ['./tsconfig.json', './test/tsconfig.json'],
}, },
extends: [ extends: [
'../shared/.eslintrc.js', '../shared/.eslintrc.js',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -0,0 +1,10 @@
import { loadConfig } from './built/config.js';
import { createRedisConnection } from './built/redis.js';
const config = loadConfig();
const redis = createRedisConnection(config);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {
throw e;
});

View File

@ -20,7 +20,7 @@ module.exports = {
// collectCoverage: false, // collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected // An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['src/**/*.ts'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
// The directory where Jest should output its coverage files // The directory where Jest should output its coverage files
coverageDirectory: "coverage", coverageDirectory: "coverage",
@ -159,6 +159,7 @@ module.exports = {
// The glob patterns Jest uses to detect test files // The glob patterns Jest uses to detect test files
testMatch: [ testMatch: [
"<rootDir>/test/unit/**/*.ts", "<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",
//"<rootDir>/test/e2e/**/*.ts" //"<rootDir>/test/e2e/**/*.ts"
], ],

View File

@ -7,6 +7,7 @@
"start": "node ./built/index.js", "start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js",
"check:connect": "node ./check_connect.js",
"build": "swc src -d built -D", "build": "swc src -d built -D",
"watch:swc": "swc src -d built -D -w", "watch:swc": "swc src -d built -D -w",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
@ -14,8 +15,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"", "eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand --detectOpenHandles",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand", "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand --detectOpenHandles",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest", "test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage" "test-and-coverage": "pnpm jest-and-coverage"
@ -53,7 +54,7 @@
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2", "@sinonjs/fake-timers": "10.0.2",
"@swc/cli": "0.1.62", "@swc/cli": "0.1.62",
"@swc/core": "1.3.35", "@swc/core": "1.3.36",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "5.3.1", "archiver": "5.3.1",
@ -79,7 +80,7 @@
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0", "form-data": "4.0.0",
"got": "12.5.3", "got": "12.5.3",
"happy-dom": "^8.7.0", "happy-dom": "8.9.0",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"ioredis": "4.28.5", "ioredis": "4.28.5",
"ip-cidr": "3.1.0", "ip-cidr": "3.1.0",
@ -87,7 +88,7 @@
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "21.1.0", "jsdom": "21.1.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.1.0", "jsonld": "8.1.1",
"jsrsasign": "10.6.1", "jsrsasign": "10.6.1",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"mime-types": "2.1.35", "mime-types": "2.1.35",
@ -126,7 +127,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.17.9", "systeminformation": "5.17.10",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.2", "tsc-alias": "1.8.2",
@ -155,7 +156,7 @@
"@types/color-convert": "2.0.0", "@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5", "@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.20", "@types/fluent-ffmpeg": "2.1.21",
"@types/ioredis": "4.28.10", "@types/ioredis": "4.28.10",
"@types/jest": "29.4.0", "@types/jest": "29.4.0",
"@types/js-yaml": "4.0.5", "@types/js-yaml": "4.0.5",
@ -163,7 +164,7 @@
"@types/jsonld": "1.5.8", "@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.5", "@types/jsrsasign": "10.5.5",
"@types/mime-types": "2.1.1", "@types/mime-types": "2.1.1",
"@types/node": "18.14.0", "@types/node": "18.14.1",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/oauth": "0.9.1", "@types/oauth": "0.9.1",
@ -182,18 +183,18 @@
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3", "@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5", "@types/unzipper": "0.10.5",
"@types/uuid": "9.0.0", "@types/uuid": "9.0.1",
"@types/vary": "1.1.0", "@types/vary": "1.1.0",
"@types/web-push": "3.3.2", "@types/web-push": "3.3.2",
"@types/websocket": "1.0.5", "@types/websocket": "1.0.5",
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.52.0", "@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.52.0", "@typescript-eslint/parser": "5.53.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.34.0", "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"execa": "6.1.0", "execa": "6.1.0",
"jest": "29.4.3", "jest": "29.4.3",
"jest-mock": "29.4.3" "jest-mock": "29.4.3"
} }
} }

View File

@ -450,8 +450,10 @@ export class ApInboxService {
return `skip: delete actor ${actor.uri} !== ${uri}`; return `skip: delete actor ${actor.uri} !== ${uri}`;
} }
const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); const user = await this.usersRepository.findOneBy({ id: actor.id });
if (user.isDeleted) { if (user == null) {
return 'skip: actor not found';
} else if (user.isDeleted) {
return 'skip: already deleted'; return 'skip: already deleted';
} }

View File

@ -28,6 +28,101 @@ type PrivateKey = {
keyId: string; keyId: string;
}; };
export class ApRequestCreator {
static createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
const request: Request = {
url: u.href,
method: 'POST',
headers: this.#objectAssignWithLcKey({
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
'Digest': digestHeader,
}, args.additionalHeaders),
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const request: Request = {
url: u.href,
method: 'GET',
headers: this.#objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
const signingString = this.#genSigningString(request, includeHeaders);
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
request.headers = this.#objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,
signingString,
signature,
signatureHeader,
};
}
static #genSigningString(request: Request, includeHeaders: string[]): string {
request.headers = this.#lcObjectKey(request.headers);
const results: string[] = [];
for (const key of includeHeaders.map(x => x.toLowerCase())) {
if (key === '(request-target)') {
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
} else {
results.push(`${key}: ${request.headers[key]}`);
}
}
return results.join('\n');
}
static #lcObjectKey(src: Record<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
return dst;
}
static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
}
}
@Injectable() @Injectable()
export class ApRequestService { export class ApRequestService {
private logger: Logger; private logger: Logger;
@ -44,112 +139,13 @@ export class ApRequestService {
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
} }
@bindThis
private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
const request: Request = {
url: u.href,
method: 'POST',
headers: this.objectAssignWithLcKey({
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
'Digest': digestHeader,
}, args.additionalHeaders),
};
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
@bindThis
private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const request: Request = {
url: u.href,
method: 'GET',
headers: this.objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
@bindThis
private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
const signingString = this.genSigningString(request, includeHeaders);
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
request.headers = this.objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,
signingString,
signature,
signatureHeader,
};
}
@bindThis
private genSigningString(request: Request, includeHeaders: string[]): string {
request.headers = this.lcObjectKey(request.headers);
const results: string[] = [];
for (const key of includeHeaders.map(x => x.toLowerCase())) {
if (key === '(request-target)') {
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
} else {
results.push(`${key}: ${request.headers[key]}`);
}
}
return results.join('\n');
}
@bindThis
private lcObjectKey(src: Record<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
return dst;
}
@bindThis
private objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b));
}
@bindThis @bindThis
public async signedPost(user: { id: User['id'] }, url: string, object: any) { public async signedPost(user: { id: User['id'] }, url: string, object: any) {
const body = JSON.stringify(object); const body = JSON.stringify(object);
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedPost({ const req = ApRequestCreator.createSignedPost({
key: { key: {
privateKeyPem: keypair.privateKey, privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`, keyId: `${this.config.url}/users/${user.id}#main-key`,
@ -176,7 +172,7 @@ export class ApRequestService {
public async signedGet(url: string, user: { id: User['id'] }) { public async signedGet(url: string, user: { id: User['id'] }) {
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedGet({ const req = ApRequestCreator.createSignedGet({
key: { key: {
privateKeyPem: keypair.privateKey, privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`, keyId: `${this.config.url}/users/${user.id}#main-key`,

View File

@ -116,10 +116,10 @@ export type Obj = Record<string, Schema>;
// https://github.com/misskey-dev/misskey/issues/8535 // https://github.com/misskey-dev/misskey/issues/8535
// To avoid excessive stack depth error, // To avoid excessive stack depth error,
// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it). // deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it).
export type ObjType<s extends Obj, RequiredProps extends keyof s> = export type ObjType<s extends Obj, RequiredProps extends ReadonlyArray<keyof s>> =
UnionToIntersection< UnionToIntersection<
{ -readonly [R in RequiredPropertyNames<s>]-?: SchemaType<s[R]> } & { -readonly [R in RequiredPropertyNames<s>]-?: SchemaType<s[R]> } &
{ -readonly [R in RequiredProps]-?: SchemaType<s[R]> } & { -readonly [R in RequiredProps[number]]-?: SchemaType<s[R]> } &
{ -readonly [P in keyof s]?: SchemaType<s[P]> } { -readonly [P in keyof s]?: SchemaType<s[P]> }
>; >;
@ -136,18 +136,19 @@ type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 // https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
// To get union, we use `Foo extends any ? Hoge<Foo> : never` // To get union, we use `Foo extends any ? Hoge<Foo> : never`
type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? SchemaType<X> : never; type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? SchemaType<X> : never;
type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never; //type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never;
type ArrayUnion<T> = T extends any ? Array<T> : never; type ArrayUnion<T> = T extends any ? Array<T> : never;
type ObjectSchemaTypeDef<p extends Schema> = type ObjectSchemaTypeDef<p extends Schema> =
p['ref'] extends keyof typeof refs ? Packed<p['ref']> : p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
p['properties'] extends NonNullable<Obj> ? p['properties'] extends NonNullable<Obj> ?
p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
ObjType<p['properties'], NonNullable<p['required']>[number]> & UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>> UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
: : never
ObjType<p['properties'], NonNullable<p['required']>[number]> : ObjType<p['properties'], NonNullable<p['required']>>
: :
p['anyOf'] extends ReadonlyArray<Schema> ? UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>> : p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> : p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
any any

View File

@ -2,6 +2,7 @@ import { pipeline } from 'node:stream';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import type { LocalUser, User } from '@/models/entities/User.js'; import type { LocalUser, User } from '@/models/entities/User.js';
@ -320,6 +321,7 @@ export class ApiCallService implements OnApplicationShutdown {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;
} else { } else {
const errId = uuid();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name, ep: ep.name,
ps: data, ps: data,
@ -327,14 +329,15 @@ export class ApiCallService implements OnApplicationShutdown {
message: err.message, message: err.message,
code: err.name, code: err.name,
stack: err.stack, stack: err.stack,
id: errId,
}, },
}); });
console.error(err); console.error(err, errId);
throw new ApiError(null, { throw new ApiError(null, {
e: { e: {
message: err.message, message: err.message,
code: err.name, code: err.name,
stack: err.stack, id: errId,
}, },
}); });
} }

View File

@ -138,19 +138,13 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [ anyOf: [
{ { required: ['fileId'] },
properties: { { required: ['url'] },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
properties: {
url: { type: 'string' },
},
required: ['url'],
},
], ],
} as const; } as const;

View File

@ -16,7 +16,7 @@ export const meta = {
errors: { errors: {
noSuchFile: { noSuchFile: {
message: 'No such file.', message: 'No such file.',
code: 'MO_SUCH_FILE', code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
}, },
}, },

View File

@ -39,19 +39,13 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [ anyOf: [
{ { required: ['fileId'] },
properties: { { required: ['url'] },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
properties: {
url: { type: 'string' },
},
required: ['url'],
},
], ],
} as const; } as const;

View File

@ -0,0 +1,263 @@
process.env.NODE_ENV = 'test';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { describe, test, expect } from '@jest/globals';
import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
import { paramDef } from './create.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const VALID = true;
const INVALID = false;
describe('api:notes/create', () => {
describe('validation', () => {
const v = getValidator(paramDef);
const tooLong = readFile(_dirname + '/../../../../../test/resources/misskey.svg', 'utf-8');
test('reject empty', () => {
const valid = v({ });
expect(valid).toBe(INVALID);
});
describe('text', () => {
test('simple post', () => {
expect(v({ text: 'Hello, world!' }))
.toBe(VALID);
});
test('null post', () => {
expect(v({ text: null }))
.toBe(INVALID);
});
test('0 characters post', () => {
expect(v({ text: '' }))
.toBe(INVALID);
});
test('over 3000 characters post', async () => {
expect(v({ text: await tooLong }))
.toBe(INVALID);
});
});
describe('cw', () => {
test('simple cw', () => {
expect(v({ text: 'Hello, world!', cw: 'Hello, world!' }))
.toBe(VALID);
});
test('null cw', () => {
expect(v({ text: 'Body', cw: null }))
.toBe(VALID);
});
test('0 characters cw', () => {
expect(v({ text: 'Body', cw: '' }))
.toBe(VALID);
});
test('reject only cw', () => {
expect(v({ cw: 'Hello, world!' }))
.toBe(INVALID);
});
test('over 100 characters cw', async () => {
expect(v({ text: 'Body', cw: await tooLong }))
.toBe(INVALID);
});
});
describe('visibility', () => {
test('public', () => {
expect(v({ text: 'Hello, world!', visibility: 'public' }))
.toBe(VALID);
});
test('home', () => {
expect(v({ text: 'Hello, world!', visibility: 'home' }))
.toBe(VALID);
});
test('followers', () => {
expect(v({ text: 'Hello, world!', visibility: 'followers' }))
.toBe(VALID);
});
test('reject only visibility', () => {
expect(v({ visibility: 'public' }))
.toBe(INVALID);
});
test('reject invalid visibility', () => {
expect(v({ text: 'Hello, world!', visibility: 'invalid' }))
.toBe(INVALID);
});
test('reject null visibility', () => {
expect(v({ text: 'Hello, world!', visibility: null }))
.toBe(INVALID);
});
describe('visibility:specified', () => {
test('specified without visibleUserIds', () => {
expect(v({ text: 'Hello, world!', visibility: 'specified' }))
.toBe(VALID);
});
test('specified with empty visibleUserIds', () => {
expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: [] }))
.toBe(VALID);
});
test('reject specified with non unique visibleUserIds', () => {
expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: ['1', '1', '2'] }))
.toBe(INVALID);
});
test('reject specified with null visibleUserIds', () => {
expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: null }))
.toBe(INVALID);
});
});
});
describe('fileIds', () => {
test('only fileIds', () => {
expect(v({ fileIds: ['1', '2', '3'] }))
.toBe(VALID);
});
test('text and fileIds', () => {
expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'] }))
.toBe(VALID);
});
test('reject null fileIds', () => {
expect(v({ fileIds: null }))
.toBe(INVALID);
});
test('reject text and null fileIds 複合的なanyOfのバリデーションが正しく動作する', () => {
expect(v({ text: 'Hello, world!', fileIds: null }))
.toBe(INVALID);
});
test('reject 0 files', () => {
expect(v({ fileIds: [] }))
.toBe(INVALID);
});
test('reject non unique', () => {
expect(v({ fileIds: ['1', '1', '2'] }))
.toBe(INVALID);
});
test('reject invalid id', () => {
expect(v({ fileIds: ['あ'] }))
.toBe(INVALID);
});
test('reject over 17 files', () => {
const valid = v({ text: 'Hello, world!', fileIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18'] });
expect(valid).toBe(INVALID);
});
});
describe('poll', () => {
test('note with poll', () => {
expect(v({ text: 'Hello, world!', poll: { choices: ['a', 'b', 'c'] } }))
.toBe(VALID);
});
test('null poll', () => {
expect(v({ text: 'Hello, world!', poll: null }))
.toBe(VALID);
});
test('allow only poll', () => {
expect(v({ poll: { choices: ['a', 'b', 'c'] } }))
.toBe(VALID);
});
test('poll with expiresAt', async () => {
expect(v({ poll: { choices: ['a', 'b', 'c'], expiresAt: 1 } }))
.toBe(VALID);
});
test('poll with expiredAfter', async () => {
expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 1 } }))
.toBe(VALID);
});
test('reject poll without choices', () => {
expect(v({ poll: { } }))
.toBe(INVALID);
});
test('reject poll with empty choices', () => {
expect(v({ poll: { choices: [] } }))
.toBe(INVALID);
});
test('reject poll with null choices', () => {
expect(v({ poll: { choices: null } }))
.toBe(INVALID);
});
test('reject poll with 1 choice', () => {
expect(v({ poll: { choices: ['a'] } }))
.toBe(INVALID);
});
test('reject poll with too long choice', async () => {
expect(v({ poll: { choices: [await tooLong, '2'] } }))
.toBe(INVALID);
});
test('reject poll with too many choices', () => {
expect(v({ poll: { choices: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] } }))
.toBe(INVALID);
});
test('reject poll with non unique choices', () => {
expect(v({ poll: { choices: ['a', 'a', 'b', 'c'] } }))
.toBe(INVALID);
});
test('reject poll with expiredAfter 0', async () => {
expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 0 } }))
.toBe(INVALID);
});
});
describe('renote', () => {
test('just a renote', () => {
expect(v({ renoteId: '1' }))
.toBe(VALID);
});
test('just a quote', () => {
expect(v({ text: 'Hello, world!', renoteId: '1' }))
.toBe(VALID);
});
test('reject invalid renoteId', () => {
expect(v({ renoteId: 'あ' }))
.toBe(INVALID);
});
});
test('text, fileIds and poll', () => {
expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'], poll: { choices: ['a', 'b', 'c'] } }))
.toBe(VALID);
});
test('text, invalid fileIds and invalid poll', () => {
expect(v({ text: 'Hello, world!', fileIds: ['あ'], poll: { choices: ['a'] } }))
.toBe(INVALID);
});
});
});

View File

@ -79,6 +79,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED', code: 'YOU_HAVE_BEEN_BLOCKED',
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',
},
}, },
} as const; } as const;
@ -95,74 +101,56 @@ export const paramDef = {
noExtractHashtags: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true }, replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true }, channelId: { type: 'string', format: 'misskey:id', nullable: true },
// anyOf内にバリデーションを書いても最初の一つしかチェックされない
// See https://github.com/misskey-dev/misskey/pull/10082
text: {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false
},
fileIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
mediaIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
poll: {
type: 'object',
nullable: true,
properties: {
choices: {
type: 'array',
uniqueItems: true,
minItems: 2,
maxItems: 10,
items: { type: 'string', minLength: 1, maxLength: 50 },
},
multiple: { type: 'boolean' },
expiresAt: { type: 'integer', nullable: true },
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
required: ['choices'],
},
}, },
// (re)note with text, files and poll are optional
anyOf: [ anyOf: [
{ { required: ['text'] },
// (re)note with text, files and poll are optional { required: ['renoteId'] },
properties: { { required: ['fileIds'] },
text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false }, { required: ['mediaIds'] },
}, { required: ['poll'] },
required: ['text'],
},
{
// (re)note with files, text and poll are optional
properties: {
fileIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
},
required: ['fileIds'],
},
{
// (re)note with files, text and poll are optional
properties: {
mediaIds: {
deprecated: true,
description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
},
required: ['mediaIds'],
},
{
// (re)note with poll, text and files are optional
properties: {
poll: {
type: 'object',
nullable: true,
properties: {
choices: {
type: 'array',
uniqueItems: true,
minItems: 2,
maxItems: 10,
items: { type: 'string', minLength: 1, maxLength: 50 },
},
multiple: { type: 'boolean' },
expiresAt: { type: 'integer', nullable: true },
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
required: ['choices'],
},
},
required: ['poll'],
},
{
// pure renote
properties: {
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: ['renoteId'],
},
], ],
} as const; } as const;
@ -207,6 +195,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)') .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds }) .setParameters({ fileIds })
.getMany(); .getMany();
if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchFile);
}
} }
let renote: Note | null = null; let renote: Note | null = null;

View File

@ -28,6 +28,7 @@ export const paramDef = {
properties: { properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 }, offset: { type: 'integer', default: 0 },
channelId: { type: 'string', nullable: true, format: 'misskey:id' },
}, },
required: [], required: [],
} as const; } as const;
@ -63,6 +64,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me);

View File

@ -36,32 +36,25 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
tag: { type: 'string', minLength: 1 },
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
minItems: 1,
},
minItems: 1,
},
}, },
anyOf: [ anyOf: [
{ { required: ['tag'] },
properties: { { required: ['query'] },
tag: { type: 'string', minLength: 1 },
},
required: ['tag'],
},
{
properties: {
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
minItems: 1,
},
minItems: 1,
},
},
required: ['query'],
},
], ],
} as const; } as const;

View File

@ -58,25 +58,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const hasFollowing = (await this.followingsRepository.count({ const followees = await this.followingsRepository.createQueryBuilder('following')
where: { .select('following.followeeId')
followerId: me.id, .where('following.followerId = :followerId', { followerId: me.id })
}, .getMany();
take: 1,
})) !== 0;
//#region Construct query //#region Construct query
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
.andWhere(new Brackets(qb => { qb
.where('note.userId = :meId', { meId: me.id });
if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`);
}))
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar') .leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('user.banner', 'banner')
@ -87,8 +77,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.setParameters(followingQuery.getParameters());
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else {
query.andWhere('note.userId = :meId', { meId: me.id });
}
this.queryService.generateChannelQuery(query, me); this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me); this.queryService.generateRepliesQuery(query, me);

View File

@ -29,20 +29,14 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {
pageId: { type: 'string', format: 'misskey:id' },
name: { type: 'string' },
username: { type: 'string' },
},
anyOf: [ anyOf: [
{ { required: ['pageId'] },
properties: { { required: ['name', 'username'] },
pageId: { type: 'string', format: 'misskey:id' },
},
required: ['pageId'],
},
{
properties: {
name: { type: 'string' },
username: { type: 'string' },
},
required: ['name', 'username'],
},
], ],
} as const; } as const;

View File

@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
}, },
anyOf: [ anyOf: [
{ { required: ['userId'] },
properties: { { required: ['username', 'host'] },
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
},
{
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
], ],
} as const; } as const;

View File

@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
}, },
anyOf: [ anyOf: [
{ { required: ['userId'] },
properties: { { required: ['username', 'host'] },
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
},
{
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
], ],
} as const; } as const;

View File

@ -31,20 +31,13 @@ export const paramDef = {
properties: { properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true }, detail: { type: 'boolean', default: true },
username: { type: 'string', nullable: true },
host: { type: 'string', nullable: true },
}, },
anyOf: [ anyOf: [
{ { required: ['username'] },
properties: { { required: ['host'] },
username: { type: 'string', nullable: true },
},
required: ['username'],
},
{
properties: {
host: { type: 'string', nullable: true },
},
required: ['host'],
},
], ],
} as const; } as const;

View File

@ -54,32 +54,22 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
userIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
anyOf: [ anyOf: [
{ { required: ['userId'] },
properties: { { required: ['userIds'] },
userId: { type: 'string', format: 'misskey:id' }, { required: ['username'] },
},
required: ['userId'],
},
{
properties: {
userIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
},
required: ['userIds'],
},
{
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username'],
},
], ],
} as const; } as const;

View File

@ -17,10 +17,18 @@
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
},
{
"src": "/static-assets/splash.png",
"sizes": "300x300",
"type": "image/png",
"purpose": "any"
} }
], ],
"share_target": { "share_target": {
"action": "/share/", "action": "/share/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": { "params": {
"title": "title", "title": "title",
"text": "text", "text": "text",

View File

@ -0,0 +1,11 @@
import { Schema } from '@/misc/schema';
import Ajv from 'ajv';
export const getValidator = (paramDef: Schema) => {
const ajv = new Ajv({
useDefaults: true,
});
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
return ajv.compile(paramDef);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -33,10 +33,12 @@
"lib": [ "lib": [
"esnext" "esnext"
], ],
"types": ["jest"] "types": ["jest", "node"]
}, },
"compileOnSave": false, "compileOnSave": false,
"include": [ "include": [
"./**/*.ts" "./**/*.ts",
"../src/**/*.test.ts",
"../src/@types/**/*.ts",
] ]
} }

View File

@ -1,7 +1,8 @@
import * as assert from 'assert'; import * as assert from 'assert';
import httpSignature from '@peertube/http-signature'; import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '../../src/misc/gen-key-pair.js';
import { createSignedPost, createSignedGet } from '../../src/activitypub/ap-request.js'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return { return {
@ -9,7 +10,7 @@ export const buildParsedSignature = (signingString: string, signature: string, a
params: { params: {
keyId: 'KeyID', // dummy, not used for verify keyId: 'KeyID', // dummy, not used for verify
algorithm: algorithm, algorithm: algorithm,
headers: [ '(request-target)', 'date', 'host', 'digest' ], // dummy, not used for verify headers: ['(request-target)', 'date', 'host', 'digest'], // dummy, not used for verify
signature: signature, signature: signature,
}, },
signingString: signingString, signingString: signingString,
@ -29,7 +30,7 @@ describe('ap-request', () => {
'User-Agent': 'UA', 'User-Agent': 'UA',
}; };
const req = createSignedPost({ key, url, body, additionalHeaders: headers }); const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers });
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');
@ -45,7 +46,7 @@ describe('ap-request', () => {
'User-Agent': 'UA', 'User-Agent': 'UA',
}; };
const req = createSignedGet({ key, url, additionalHeaders: headers }); const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers });
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');

View File

@ -26,9 +26,7 @@
"rootDir": "./src", "rootDir": "./src",
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
}, },
"outDir": "./built", "outDir": "./built",
"types": [ "types": [
@ -46,4 +44,7 @@
"include": [ "include": [
"./src/**/*.ts" "./src/**/*.ts"
], ],
"exclude": [
"./src/**/*.test.ts"
]
} }

View File

@ -41,12 +41,12 @@
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"misskey-js": "0.0.15", "misskey-js": "0.0.15",
"photoswipe": "5.3.5", "photoswipe": "5.3.6",
"prismjs": "1.29.0", "prismjs": "1.29.0",
"punycode": "2.3.0", "punycode": "2.3.0",
"querystring": "0.2.1", "querystring": "0.2.1",
"rndstr": "1.0.0", "rndstr": "1.0.0",
"rollup": "3.17.2", "rollup": "3.17.3",
"s-age": "1.1.2", "s-age": "1.1.2",
"sanitize-html": "2.10.0", "sanitize-html": "2.10.0",
"sass": "1.58.3", "sass": "1.58.3",
@ -54,7 +54,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1", "syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.149.0", "three": "0.150.0",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.2", "tsc-alias": "1.8.2",
@ -63,7 +63,7 @@
"typescript": "4.9.5", "typescript": "4.9.5",
"uuid": "9.0.0", "uuid": "9.0.0",
"vanilla-tilt": "1.8.0", "vanilla-tilt": "1.8.0",
"vite": "4.1.2", "vite": "4.1.4",
"vue": "3.2.47", "vue": "3.2.47",
"vue-plyr": "7.0.0", "vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
@ -71,29 +71,28 @@
}, },
"devDependencies": { "devDependencies": {
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
"@types/glob": "8.0.1",
"@types/gulp": "4.0.10", "@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2", "@types/matter-js": "0.18.2",
"@types/node": "18.14.0", "@types/node": "18.14.1",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/sanitize-html": "2.8.0", "@types/sanitize-html": "2.8.0",
"@types/seedrandom": "3.0.4", "@types/seedrandom": "3.0.5",
"@types/throttle-debounce": "5.0.0", "@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.0", "@types/uuid": "9.0.1",
"@types/websocket": "1.0.5", "@types/websocket": "1.0.5",
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.52.0", "@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.52.0", "@typescript-eslint/parser": "5.53.0",
"@vue/runtime-core": "3.2.47", "@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.6.0", "cypress": "12.7.0",
"eslint": "8.34.0", "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0", "eslint-plugin-vue": "9.9.0",
"start-server-and-test": "1.15.4", "start-server-and-test": "1.15.4",
"vue-eslint-parser": "9.1.0", "vue-eslint-parser": "9.1.0",
"vue-tsc": "1.1.4" "vue-tsc": "1.2.0"
} }
} }

View File

@ -32,6 +32,8 @@ let rootEl = $shallowRef<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex('high')); let zIndex = $ref<number>(os.claimZIndex('high'));
const SCROLLBAR_THICKNESS = 16;
onMounted(() => { onMounted(() => {
let left = props.ev.pageX + 1; // + 1 let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1 let top = props.ev.pageY + 1; // + 1
@ -39,12 +41,12 @@ onMounted(() => {
const width = rootEl.offsetWidth; const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight; const height = rootEl.offsetHeight;
if (left + width - window.pageXOffset > window.innerWidth) { if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = window.innerWidth - width + window.pageXOffset; left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
} }
if (top + height - window.pageYOffset > window.innerHeight) { if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
top = window.innerHeight - height + window.pageYOffset; top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset;
} }
if (top < 0) { if (top < 0) {

View File

@ -113,6 +113,23 @@ onMounted(() => {
}); });
lightbox.init(); lightbox.init();
window.addEventListener('popstate', () => {
if (lightbox.pswp && lightbox.pswp.isOpen === true) {
lightbox.pswp.close();
return;
}
});
lightbox.on('beforeOpen', () => {
history.pushState(null, '', '#pswp');
});
lightbox.on('close', () => {
if (window.location.hash === '#pswp') {
history.back();
}
});
}); });
const previewable = (file: misskey.entities.DriveFile): boolean => { const previewable = (file: misskey.entities.DriveFile): boolean => {

View File

@ -1,11 +1,11 @@
<template> <template>
<div ref="el" class="sfhdhdhr"> <div ref="el" :class="$style.root">
<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> <MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted, shallowRef, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import MkMenu from './MkMenu.vue'; import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
@ -25,11 +25,21 @@ const emit = defineEmits<{
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const align = 'left'; const align = 'left';
const SCROLLBAR_THICKNESS = 16;
function setPosition() { function setPosition() {
const rootRect = props.rootElement.getBoundingClientRect(); const rootRect = props.rootElement.getBoundingClientRect();
const rect = props.targetElement.getBoundingClientRect(); const parentRect = props.targetElement.getBoundingClientRect();
const left = props.targetElement.offsetWidth; const myRect = el.value.getBoundingClientRect();
const top = (rect.top - rootRect.top) - 8;
let left = props.targetElement.offsetWidth;
let top = (parentRect.top - rootRect.top) - 8;
if (rootRect.left + left + myRect.width >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = -myRect.width;
}
if (rootRect.top + top + myRect.height >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
top = top - ((rootRect.top + top + myRect.height) - (window.innerHeight - SCROLLBAR_THICKNESS));
}
el.value.style.left = left + 'px'; el.value.style.left = left + 'px';
el.value.style.top = top + 'px'; el.value.style.top = top + 'px';
} }
@ -46,13 +56,22 @@ watch(() => props.targetElement, () => {
setPosition(); setPosition();
}); });
const ro = new ResizeObserver((entries, observer) => {
setPosition();
});
onMounted(() => { onMounted(() => {
ro.observe(el.value);
setPosition(); setPosition();
nextTick(() => { nextTick(() => {
setPosition(); setPosition();
}); });
}); });
onUnmounted(() => {
ro.disconnect();
});
defineExpose({ defineExpose({
checkHit: (ev: MouseEvent) => { checkHit: (ev: MouseEvent) => {
return (ev.target === el.value || el.value.contains(ev.target)); return (ev.target === el.value || el.value.contains(ev.target));
@ -60,8 +79,8 @@ defineExpose({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.sfhdhdhr { .root {
position: absolute; position: absolute;
} }
</style> </style>

View File

@ -56,7 +56,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'; import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus'; import { focusPrev, focusNext } from '@/scripts/focus';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
@ -111,11 +111,11 @@ watch(() => props.items, () => {
immediate: true, immediate: true,
}); });
let childMenu = $ref<MenuItem[] | null>(); let childMenu = ref<MenuItem[] | null>();
let childTarget = $shallowRef<HTMLElement | null>(); let childTarget = $shallowRef<HTMLElement | null>();
function closeChild() { function closeChild() {
childMenu = null; childMenu.value = null;
childShowingItem = null; childShowingItem = null;
} }
@ -140,13 +140,31 @@ function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer); if (childCloseTimer) window.clearTimeout(childCloseTimer);
} }
let childrenCache = new WeakMap();
async function showChildren(item: MenuItem, ev: MouseEvent) { async function showChildren(item: MenuItem, ev: MouseEvent) {
const children = ref([]);
if (childrenCache.has(item)) {
children.value = childrenCache.get(item);
} else {
if (typeof item.children === 'function') {
children.value = [{
type: 'pending',
}];
item.children().then(x => {
children.value = x;
childrenCache.set(item, x);
});
} else {
children.value = item.children;
}
}
if (props.asDrawer) { if (props.asDrawer) {
os.popupMenu(item.children, ev.currentTarget ?? ev.target); os.popupMenu(children, ev.currentTarget ?? ev.target);
close(); close();
} else { } else {
childTarget = ev.currentTarget ?? ev.target; childTarget = ev.currentTarget ?? ev.target;
childMenu = item.children; childMenu = children;
childShowingItem = item; childShowingItem = item;
} }
} }

View File

@ -133,6 +133,7 @@ const keymap = {
}; };
const MARGIN = 16; const MARGIN = 16;
const SCROLLBAR_THICKNESS = 16;
const align = () => { const align = () => {
if (props.src == null) return; if (props.src == null) return;
@ -170,15 +171,15 @@ const align = () => {
if (fixed) { if (fixed) {
// //
if (left + width > window.innerWidth) { if (left + width > (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = window.innerWidth - width; left = (window.innerWidth - SCROLLBAR_THICKNESS) - width;
} }
const underSpace = (window.innerHeight - MARGIN) - top; const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - top;
const upperSpace = (srcRect.top - MARGIN); const upperSpace = (srcRect.top - MARGIN);
// //
if (top + height > (window.innerHeight - MARGIN)) { if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') { if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) { if (underSpace >= (upperSpace / 3)) {
maxHeight = underSpace; maxHeight = underSpace;
@ -187,22 +188,22 @@ const align = () => {
top = (upperSpace + MARGIN) - height; top = (upperSpace + MARGIN) - height;
} }
} else { } else {
top = (window.innerHeight - MARGIN) - height; top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height;
} }
} else { } else {
maxHeight = underSpace; maxHeight = underSpace;
} }
} else { } else {
// //
if (left + width - window.pageXOffset > window.innerWidth) { if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = window.innerWidth - width + window.pageXOffset - 1; left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1;
} }
const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset); const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset);
const upperSpace = (srcRect.top - MARGIN); const upperSpace = (srcRect.top - MARGIN);
// //
if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) { if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') { if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) { if (underSpace >= (upperSpace / 3)) {
maxHeight = underSpace; maxHeight = underSpace;
@ -211,7 +212,7 @@ const align = () => {
top = window.pageYOffset + ((upperSpace + MARGIN) - height); top = window.pageYOffset + ((upperSpace + MARGIN) - height);
} }
} else { } else {
top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1;
} }
} else { } else {
maxHeight = underSpace; maxHeight = underSpace;

View File

@ -255,7 +255,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.api('notes/create', { os.apiWithDialog('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
channelId: appearNote.channelId, channelId: appearNote.channelId,
}); });
@ -276,7 +276,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.api('notes/create', { os.apiWithDialog('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
}); });
}, },
@ -673,9 +673,17 @@ function showReactions(): void {
opacity: 0.7; opacity: 0.7;
} }
@container (max-width: 500px) { @container (max-width: 580px) {
.root { .root {
font-size: 0.9em; font-size: 0.95em;
}
.renote {
padding: 12px 26px 0 26px;
}
.article {
padding: 24px 26px 14px;
} }
.avatar { .avatar {
@ -684,7 +692,21 @@ function showReactions(): void {
} }
} }
@container (max-width: 450px) { @container (max-width: 500px) {
.root {
font-size: 0.9em;
}
.renote {
padding: 10px 22px 0 22px;
}
.article {
padding: 20px 22px 12px;
}
}
@container (max-width: 480px) {
.renote { .renote {
padding: 8px 16px 0 16px; padding: 8px 16px 0 16px;
} }
@ -701,7 +723,9 @@ function showReactions(): void {
.article { .article {
padding: 14px 16px 9px; padding: 14px 16px 9px;
} }
}
@container (max-width: 450px) {
.avatar { .avatar {
margin: 0 10px 8px 0; margin: 0 10px 8px 0;
width: 46px; width: 46px;
@ -710,7 +734,7 @@ function showReactions(): void {
} }
} }
@container (max-width: 350px) { @container (max-width: 400px) {
.footerButton { .footerButton {
&:not(:last-child) { &:not(:last-child) {
margin-right: 18px; margin-right: 18px;
@ -718,6 +742,14 @@ function showReactions(): void {
} }
} }
@container (max-width: 350px) {
.footerButton {
&:not(:last-child) {
margin-right: 12px;
}
}
}
@container (max-width: 300px) { @container (max-width: 300px) {
.avatar { .avatar {
width: 44px; width: 44px;
@ -726,7 +758,7 @@ function showReactions(): void {
.footerButton { .footerButton {
&:not(:last-child) { &:not(:last-child) {
margin-right: 12px; margin-right: 8px;
} }
} }
} }

View File

@ -250,7 +250,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.api('notes/create', { os.apiWithDialog('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
channelId: appearNote.channelId, channelId: appearNote.channelId,
}); });
@ -271,7 +271,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.api('notes/create', { os.apiWithDialog('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
}); });
}, },

View File

@ -42,6 +42,7 @@ import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, o
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import * as os from '@/os'; import * as os from '@/os';
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
import { useDocumentVisibility } from '@/scripts/use-document-visibility';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list'; import { MisskeyEntity } from '@/types/date-separated-list';
@ -107,6 +108,12 @@ const {
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
const scrollableElement = $computed(() => getScrollContainer(contentEl)); const scrollableElement = $computed(() => getScrollContainer(contentEl));
const visibility = useDocumentVisibility();
let isPausingUpdate = false;
let timerForSetPause: number | null = null;
const BACKGROUND_PAUSE_WAIT_SEC = 10;
// //
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e // https://qiita.com/mkataigi/items/0154aefd2223ce23398e
let scrollObserver = $ref<IntersectionObserver>(); let scrollObserver = $ref<IntersectionObserver>();
@ -279,6 +286,28 @@ const fetchMoreAhead = async (): Promise<void> => {
}); });
}; };
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
timerForSetPause = window.setTimeout(() => {
isPausingUpdate = true;
timerForSetPause = null;
},
BACKGROUND_PAUSE_WAIT_SEC * 1000);
} else { // 'visible'
if (timerForSetPause) {
clearTimeout(timerForSetPause);
timerForSetPause = null;
} else {
isPausingUpdate = false;
if (isTop()) {
executeQueue();
}
}
}
});
const prepend = (item: MisskeyEntity): void => { const prepend = (item: MisskeyEntity): void => {
// unshiftOK // unshiftOK
if (!rootEl) { if (!rootEl) {
@ -286,9 +315,7 @@ const prepend = (item: MisskeyEntity): void => {
return; return;
} }
const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); if (isTop() && !isPausingUpdate) unshiftItems([item]);
if (isTop) unshiftItems([item]);
else prependQueue(item); else prependQueue(item);
}; };
@ -357,6 +384,10 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timerForSetPause) {
clearTimeout(timerForSetPause);
timerForSetPause = null;
}
scrollObserver.disconnect(); scrollObserver.disconnect();
}); });

View File

@ -1,12 +1,25 @@
<template> <template>
<div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> <template v-if="playerEnabled">
<button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button> <div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
<span v-else>invalid url</span> <span v-else>invalid url</span>
</div> </div>
<div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter"> <div :class="$style.action">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe> <MkButton :small="true" inline @click="playerEnabled = false">
</div> <i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }}
</MkButton>
</div>
</template>
<template v-else-if="tweetId && tweetExpanded">
<div ref="twitter" :class="$style.twitter">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div>
<div :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = false">
<i class="ti ti-x"></i> {{ i18n.ts.close }}
</MkButton>
</div>
</template>
<div v-else :class="$style.urlPreview"> <div v-else :class="$style.urlPreview">
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`"> <div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`">

View File

@ -103,7 +103,7 @@ function onTabWheel(ev: WheelEvent) {
ev.stopPropagation(); ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({ (ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY, left: ev.deltaY,
behavior: 'smooth', behavior: 'instant',
}); });
} }
return false; return false;

View File

@ -147,10 +147,7 @@ onUnmounted(() => {
.tabs:first-child { .tabs:first-child {
margin-left: auto; margin-left: auto;
} padding: 0 12px;
.tabs:not(:first-child) {
padding-left: 16px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%);
} }
.tabs { .tabs {
margin-right: auto; margin-right: auto;

View File

@ -1,6 +1,7 @@
<template> <template>
<time :title="absolute"> <time :title="absolute">
<template v-if="mode === 'relative'">{{ relative }}</template> <template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
<template v-else-if="mode === 'relative'">{{ relative }}</template>
<template v-else-if="mode === 'absolute'">{{ absolute }}</template> <template v-else-if="mode === 'absolute'">{{ absolute }}</template>
<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template> <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
</time> </time>
@ -12,18 +13,24 @@ import { i18n } from '@/i18n';
import { dateTimeFormat } from '@/scripts/intl-const'; import { dateTimeFormat } from '@/scripts/intl-const';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
time: Date | string; time: Date | string | number | null;
mode?: 'relative' | 'absolute' | 'detail'; mode?: 'relative' | 'absolute' | 'detail';
}>(), { }>(), {
mode: 'relative', mode: 'relative',
}); });
const _time = typeof props.time === 'string' ? new Date(props.time) : props.time; const _time = props.time == null ? NaN :
const absolute = dateTimeFormat.format(_time); typeof props.time === 'number' ? props.time :
(props.time instanceof Date ? props.time : new Date(props.time)).getTime();
const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
let now = $shallowRef(new Date()); let now = $ref((new Date()).getTime());
const relative = $computed(() => { const relative = $computed<string>(() => {
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; if (props.mode === 'absolute') return ''; // absoluterelative使
if (invalid) return i18n.ts._ago.invalid;
const ago = (now - _time) / 1000/*ms*/;
return ( return (
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
@ -39,8 +46,8 @@ const relative = $computed(() => {
let tickId: number; let tickId: number;
function tick() { function tick() {
now = new Date(); now = (new Date()).getTime();
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; const ago = (now - _time) / 1000/*ms*/;
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
tickId = window.setTimeout(tick, next); tickId = window.setTimeout(tick, next);

View File

@ -36,7 +36,6 @@ import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
import { defaultStore, ColdDeviceStorage } from '@/store'; import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance'; import { fetchInstance, instance } from '@/instance';
import { makeHotkey } from '@/scripts/hotkey'; import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from '@/scripts/device-kind';
import { initializeSw } from '@/scripts/initialize-sw'; import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload'; import { reloadChannel } from '@/scripts/unison-reload';
@ -47,6 +46,7 @@ import { deckStore } from './ui/deck/deck-store';
import { miLocalStorage } from './local-storage'; import { miLocalStorage } from './local-storage';
import { claimAchievement, claimedAchievements } from './scripts/achievements'; import { claimAchievement, claimedAchievements } from './scripts/achievements';
import { fetchCustomEmojis } from './custom-emojis'; import { fetchCustomEmojis } from './custom-emojis';
import { mainRouter } from './router';
console.info(`Misskey v${version}`); console.info(`Misskey v${version}`);
@ -352,7 +352,9 @@ const hotkeys = {
'd': (): void => { 'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode); defaultStore.set('darkMode', !defaultStore.state.darkMode);
}, },
's': search, 's': (): void => {
mainRouter.push('/search');
}
}; };
if ($i) { if ($i) {

View File

@ -1,7 +1,7 @@
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue';
import { $i } from './account'; import { $i } from './account';
import { miLocalStorage } from './local-storage'; import { miLocalStorage } from './local-storage';
import { search } from '@/scripts/search'; import { openInstanceMenu } from './ui/_common_/common';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { ui } from '@/config'; import { ui } from '@/config';
@ -42,7 +42,7 @@ export const navbarItemDef = reactive({
search: { search: {
title: i18n.ts.search, title: i18n.ts.search,
icon: 'ti ti-search', icon: 'ti ti-search',
action: () => search(), to: '/search',
}, },
lists: { lists: {
title: i18n.ts.lists, title: i18n.ts.lists,
@ -122,6 +122,13 @@ export const navbarItemDef = reactive({
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
}, },
}, },
about: {
title: i18n.ts.about,
icon: 'ti ti-info-circle',
action: (ev) => {
openInstanceMenu(ev);
},
},
reload: { reload: {
title: i18n.ts.reload, title: i18n.ts.reload,
icon: 'ti ti-refresh', icon: 'ti ti-refresh',

View File

@ -203,6 +203,7 @@ const patrons = [
'ThatOneCalculator', 'ThatOneCalculator',
'pixeldesu', 'pixeldesu',
'あめ玉', 'あめ玉',
'氷月氷華里',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@ -1,9 +1,9 @@
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700"> <MkSpacer :content-max="700">
<div v-if="channel"> <div v-if="channel && tab === 'timeline'" class="_gaps">
<div class="wpgynlbz _panel _margin" :class="{ hide: !showBanner }"> <div class="wpgynlbz _panel" :class="{ hide: !showBanner }">
<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
<button class="_button toggle" @click="() => showBanner = !showBanner"> <button class="_button toggle" @click="() => showBanner = !showBanner">
<template v-if="showBanner"><i class="ti ti-chevron-up"></i></template> <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
@ -24,9 +24,12 @@
</div> </div>
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる --> <!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i" :channel="channel" class="post-form _panel _margin" fixed :autofocus="deviceKind === 'desktop'"/> <MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" class="_margin" src="channel" :channel="channelId" @before="before" @after="after"/> <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
</div>
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -43,6 +46,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
const router = useRouter(); const router = useRouter();
@ -50,15 +54,17 @@ const props = defineProps<{
channelId: string; channelId: string;
}>(); }>();
let tab = $ref('timeline');
let channel = $ref(null); let channel = $ref(null);
let showBanner = $ref(true); let showBanner = $ref(true);
const pagination = { const featuredPagination = $computed(() => ({
endpoint: 'channels/timeline' as const, endpoint: 'notes/featured' as const,
limit: 10, limit: 10,
params: computed(() => ({ offsetMode: true,
params: {
channelId: props.channelId, channelId: props.channelId,
})), },
}; }));
watch(() => props.channelId, async () => { watch(() => props.channelId, async () => {
channel = await os.api('channels/show', { channel = await os.api('channels/show', {
@ -76,7 +82,15 @@ const headerActions = $computed(() => channel && channel.userId ? [{
handler: edit, handler: edit,
}] : null); }] : null);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => [{
key: 'timeline',
title: i18n.ts.timeline,
icon: 'ti ti-home',
}, {
key: 'featured',
title: i18n.ts.featured,
icon: 'ti ti-bolt',
}]);
definePageMetadata(computed(() => channel ? { definePageMetadata(computed(() => channel ? {
title: channel.name, title: channel.name,

View File

@ -11,23 +11,6 @@
<div v-else-if="tab === 'roles'"> <div v-else-if="tab === 'roles'">
<XRoles/> <XRoles/>
</div> </div>
<div v-else-if="tab === 'search'">
<MkSpacer :content-max="1200">
<div>
<MkInput v-model="searchQuery" :debounce="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.searchUser }}</template>
</MkInput>
<MkRadios v-model="searchOrigin">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkRadios>
</div>
<MkUserList v-if="searchQuery" ref="searchEl" class="_margin" :pagination="searchPagination"/>
</MkSpacer>
</div>
</div> </div>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -38,11 +21,8 @@ import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue'; import XUsers from './explore.users.vue';
import XRoles from './explore.roles.vue'; import XRoles from './explore.roles.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import MkUserList from '@/components/MkUserList.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tag?: string; tag?: string;
@ -53,22 +33,11 @@ const props = withDefaults(defineProps<{
let tab = $ref(props.initialTab); let tab = $ref(props.initialTab);
let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>(); let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>();
let searchQuery = $ref(null);
let searchOrigin = $ref('combined');
watch(() => props.tag, () => { watch(() => props.tag, () => {
if (tagsEl) tagsEl.toggleContent(props.tag == null); if (tagsEl) tagsEl.toggleContent(props.tag == null);
}); });
const searchPagination = {
endpoint: 'users/search' as const,
limit: 10,
params: computed(() => (searchQuery && searchQuery !== '') ? {
query: searchQuery,
origin: searchOrigin,
} : null),
};
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => [{ const headerTabs = $computed(() => [{
@ -83,10 +52,6 @@ const headerTabs = $computed(() => [{
key: 'roles', key: 'roles',
icon: 'ti ti-badges', icon: 'ti ti-badges',
title: i18n.ts.roles, title: i18n.ts.roles,
}, {
key: 'search',
icon: 'ti ti-search',
title: i18n.ts.search,
}]); }]);
definePageMetadata(computed(() => ({ definePageMetadata(computed(() => ({

View File

@ -2,33 +2,104 @@
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<MkNotes ref="notes" :pagination="pagination"/> <MkInput v-model="searchQuery" :large="true" :autofocus="true" :debounce="true" type="search" style="margin-bottom: var(--margin);" @update:model-value="search()">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkTab v-model="searchType" style="margin-bottom: var(--margin);" @update:model-value="search()">
<option value="note">{{ i18n.ts.note }}</option>
<option value="user">{{ i18n.ts.user }}</option>
</MkTab>
<div v-if="searchType === 'note'">
<MkNotes v-if="searchQuery" ref="notes" :pagination="notePagination"/>
</div>
<div v-else>
<MkRadios v-model="searchOrigin" style="margin-bottom: var(--margin);" @update:model-value="search()">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkRadios>
<MkUserList v-if="searchQuery" ref="users" :pagination="userPagination"/>
</div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, onMounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import MkUserList from '@/components/MkUserList.vue';
import MkInput from '@/components/MkInput.vue';
import MkTab from '@/components/MkTab.vue';
import MkRadios from '@/components/MkRadios.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import * as os from '@/os'; import * as os from '@/os';
import { useRouter } from '@/router'; import { useRouter, mainRouter } from '@/router';
import { $i } from '@/account';
const router = useRouter(); const router = useRouter();
const props = defineProps<{ const props = defineProps<{
query: string; query: string;
channel?: string; channel?: string;
type?: string;
origin?: string;
}>(); }>();
const query = props.query; let searchQuery = $ref('');
let searchType = $ref('note');
let searchOrigin = $ref('combined');
if ($i != null) { onMounted(() => {
if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) { searchQuery = props.query ?? '';
searchType = props.type ?? 'note';
searchOrigin = props.origin ?? 'combined';
if (searchQuery) {
search();
}
});
const search = async () => {
const query = searchQuery.toString().trim();
if (query == null || query === '') return;
if (query.startsWith('@') && !query.includes(' ')) {
mainRouter.push(`/${query}`);
return;
}
if (query.startsWith('#')) {
mainRouter.push(`/tags/${encodeURIComponent(query.substr(1))}`);
return;
}
// like 2018/03/12
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(query.replace(/-/g, '/'))) {
const date = new Date(query.replace(/-/g, '/'));
// 2018/03/12
// 2018/03/12
// 2359( 2018/03/12 00:00:00
// 2018/03/12 )
if (query.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
date.setHours(23, 59, 59, 999);
}
// TODO
//v.$root.$emit('warp', date);
os.alert({
icon: 'ti ti-history',
iconOnly: true, autoClose: true,
});
return;
}
if (query.startsWith('https://')) {
const promise = os.api('ap/show', { const promise = os.api('ap/show', {
uri: props.query, uri: query,
}); });
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
@ -36,28 +107,40 @@ if ($i != null) {
const res = await promise; const res = await promise;
if (res.type === 'User') { if (res.type === 'User') {
router.replace(`/@${res.object.username}@${res.object.host}`); mainRouter.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') { } else if (res.type === 'Note') {
router.replace(`/notes/${res.object.id}`); mainRouter.push(`/notes/${res.object.id}`);
} }
}
}
const pagination = { return;
}
window.history.replaceState('', '', `/search?q=${encodeURIComponent(query)}&type=${searchType}${searchType === 'user' ? `&origin=${searchOrigin}` : ''}`);
};
const notePagination = {
endpoint: 'notes/search' as const, endpoint: 'notes/search' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
query: props.query, query: searchQuery,
channelId: props.channel, channelId: props.channel,
})), })),
}; };
const userPagination = {
endpoint: 'users/search' as const,
limit: 10,
params: computed(() => ({
query: searchQuery,
origin: searchOrigin,
})),
};
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({ definePageMetadata(computed(() => ({
title: i18n.t('searchWith', { q: props.query }), title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search,
icon: 'ti ti-search', icon: 'ti ti-search',
}))); })));
</script> </script>

View File

@ -4,27 +4,29 @@
<FormSection> <FormSection>
<template #label>{{ i18n.ts.manage }}</template> <template #label>{{ i18n.ts.manage }}</template>
<div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;"> <div class="_gaps_s">
<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;">
<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
<MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
<MkKeyValue> <MkKeyValue>
<template #key>{{ i18n.ts.author }}</template> <template #key>{{ i18n.ts.author }}</template>
<template #value>{{ plugin.author }}</template> <template #value>{{ plugin.author }}</template>
</MkKeyValue> </MkKeyValue>
<MkKeyValue> <MkKeyValue>
<template #key>{{ i18n.ts.description }}</template> <template #key>{{ i18n.ts.description }}</template>
<template #value>{{ plugin.description }}</template> <template #value>{{ plugin.description }}</template>
</MkKeyValue> </MkKeyValue>
<MkKeyValue> <MkKeyValue>
<template #key>{{ i18n.ts.permission }}</template> <template #key>{{ i18n.ts.permission }}</template>
<template #value>{{ plugin.permission }}</template> <template #value>{{ plugin.permission }}</template>
</MkKeyValue> </MkKeyValue>
<div class="_buttons"> <div class="_buttons">
<MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton>
<MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton>
</div>
</div> </div>
</div> </div>
</FormSection> </FormSection>

View File

@ -213,6 +213,8 @@ export const routes = [{
query: { query: {
q: 'query', q: 'query',
channel: 'channel', channel: 'channel',
type: 'type',
origin: 'origin',
}, },
}, { }, {
path: '/authorize-follow', path: '/authorize-follow',

View File

@ -9,6 +9,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config'; import { url } from '@/config';
import { noteActions } from '@/store'; import { noteActions } from '@/store';
import { miLocalStorage } from '@/local-storage'; import { miLocalStorage } from '@/local-storage';
import { getUserMenu } from '@/scripts/get-user-menu';
export function getNoteMenu(props: { export function getNoteMenu(props: {
note: misskey.entities.Note; note: misskey.entities.Note;
@ -99,66 +100,6 @@ export function getNoteMenu(props: {
}); });
} }
async function clip(): Promise<void> {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
multiline: true,
label: i18n.ts.description,
},
isPublic: {
type: 'boolean',
label: i18n.ts.public,
default: false,
},
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
claimAchievement('noteClipped1');
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,
async (err) => {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
}
} else {
os.alert({
type: 'error',
text: err.message + '\n' + err.id,
});
}
},
);
},
}))], props.menuButton.value, {
}).then(focus);
}
async function unclip(): Promise<void> { async function unclip(): Promise<void> {
os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
props.isDeleted.value = true; props.isDeleted.value = true;
@ -264,9 +205,67 @@ export function getNoteMenu(props: {
action: () => toggleFavorite(true), action: () => toggleFavorite(true),
}), }),
{ {
type: 'parent',
icon: 'ti ti-paperclip', icon: 'ti ti-paperclip',
text: i18n.ts.clip, text: i18n.ts.clip,
action: () => clip(), children: async () => {
const clips = await os.api('clips/list');
return [{
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
multiline: true,
label: i18n.ts.description,
},
isPublic: {
type: 'boolean',
label: i18n.ts.public,
default: false,
},
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
claimAchievement('noteClipped1');
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,
async (err) => {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
}
} else {
os.alert({
type: 'error',
text: err.message + '\n' + err.id,
});
}
},
);
},
}))];
},
}, },
statePromise.then(state => state.isMutedThread ? { statePromise.then(state => state.isMutedThread ? {
icon: 'ti ti-message-off', icon: 'ti ti-message-off',
@ -286,6 +285,15 @@ export function getNoteMenu(props: {
text: i18n.ts.pin, text: i18n.ts.pin,
action: () => togglePin(true), action: () => togglePin(true),
} : undefined, } : undefined,
appearNote.userId !== $i.id ? {
type: 'parent',
icon: 'ti ti-user',
text: i18n.ts.user,
children: async () => {
const user = await os.api('users/show', { userId: appearNote.userId });
return getUserMenu(user);
},
} : undefined,
/* /*
...($i.isModerator || $i.isAdmin ? [ ...($i.isModerator || $i.isAdmin ? [
null, null,

View File

@ -1,4 +1,5 @@
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import * as misskey from 'misskey-js';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config'; import { host } from '@/config';
@ -8,32 +9,9 @@ import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router'; import { mainRouter } from '@/router';
import { Router } from '@/nirax'; import { Router } from '@/nirax';
export function getUserMenu(user, router: Router = mainRouter) { export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null; const meId = $i ? $i.id : null;
async function pushList() {
const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
const lists = await os.api('users/lists/list');
if (lists.length === 0) {
os.alert({
type: 'error',
text: i18n.ts.youHaveNoLists,
});
return;
}
const { canceled, result: listId } = await os.select({
title: t,
items: lists.map(list => ({
value: list.id, text: list.name,
})),
});
if (canceled) return;
os.apiWithDialog('users/lists/push', {
listId: listId,
userId: user.id,
});
}
async function toggleMute() { async function toggleMute() {
if (user.isMuted) { if (user.isMuted) {
os.apiWithDialog('mute/delete', { os.apiWithDialog('mute/delete', {
@ -102,6 +80,8 @@ export function getUserMenu(user, router: Router = mainRouter) {
} }
async function invalidateFollow() { async function invalidateFollow() {
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
os.apiWithDialog('following/invalidate', { os.apiWithDialog('following/invalidate', {
userId: user.id, userId: user.id,
}).then(() => { }).then(() => {
@ -113,7 +93,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: 'ti ti-at', icon: 'ti ti-at',
text: i18n.ts.copyUsername, text: i18n.ts.copyUsername,
action: () => { action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`); copyToClipboard(`@${user.username}@${user.host ?? host}`);
}, },
}, { }, {
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
@ -134,12 +114,43 @@ export function getUserMenu(user, router: Router = mainRouter) {
os.post({ specified: user }); os.post({ specified: user });
}, },
}, null, { }, null, {
type: 'parent',
icon: 'ti ti-list', icon: 'ti ti-list',
text: i18n.ts.addToList, text: i18n.ts.addToList,
action: pushList, children: async () => {
const lists = await os.api('users/lists/list');
return lists.map(list => ({
text: list.name,
action: () => {
os.apiWithDialog('users/lists/push', {
listId: list.id,
userId: user.id,
});
},
}));
},
}] as any; }] as any;
if ($i && meId !== user.id) { if ($i && meId !== user.id) {
if (iAmModerator) {
menu = menu.concat([{
type: 'parent',
icon: 'ti ti-badges',
text: i18n.ts.roles,
children: async () => {
const roles = await os.api('admin/roles/list');
return roles.filter(r => r.target === 'manual').map(r => ({
text: r.name,
action: () => {
os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id });
},
}));
},
}]);
}
menu = menu.concat([null, { menu = menu.concat([null, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
@ -163,30 +174,6 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts.reportAbuse, text: i18n.ts.reportAbuse,
action: reportAbuse, action: reportAbuse,
}]); }]);
if (iAmModerator) {
menu = menu.concat([null, {
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
router.push('/user-info/' + user.id + '#moderation');
},
}, {
icon: 'ti ti-badges',
text: i18n.ts.roles,
action: async () => {
const roles = await os.api('admin/roles/list');
const { canceled, result: roleId } = await os.select({
title: i18n.ts._role.chooseRoleToAssign,
items: roles.map(r => ({ text: r.name, value: r.id })),
});
if (canceled) return;
await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id });
},
}]);
}
} }
if ($i && meId === user.id) { if ($i && meId === user.id) {

View File

@ -1,63 +0,0 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
export async function search() {
const { canceled, result: query } = await os.inputText({
title: i18n.ts.search,
});
if (canceled || query == null || query === '') return;
const q = query.trim();
if (q.startsWith('@') && !q.includes(' ')) {
mainRouter.push(`/${q}`);
return;
}
if (q.startsWith('#')) {
mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
return;
}
// like 2018/03/12
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
const date = new Date(q.replace(/-/g, '/'));
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
date.setHours(23, 59, 59, 999);
}
// TODO
//v.$root.$emit('warp', date);
os.alert({
icon: 'ti ti-history',
iconOnly: true, autoClose: true,
});
return;
}
if (q.startsWith('https://')) {
const promise = os.api('ap/show', {
uri: q,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise;
if (res.type === 'User') {
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
mainRouter.push(`/notes/${res.object.id}`);
}
return;
}
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
}

View File

@ -0,0 +1,19 @@
import { onMounted, onUnmounted, ref, Ref } from 'vue';
export function useDocumentVisibility(): Ref<DocumentVisibilityState> {
const visibility = ref(document.visibilityState);
const onChange = (): void => {
visibility.value = document.visibilityState;
};
onMounted(() => {
document.addEventListener('visibilitychange', onChange);
});
onUnmounted(() => {
document.removeEventListener('visibilitychange', onChange);
});
return visibility;
}

View File

@ -45,11 +45,11 @@
import { defineAsyncComponent, defineComponent } from 'vue'; import { defineAsyncComponent, defineComponent } from 'vue';
import { openInstanceMenu } from './_common_/common'; import { openInstanceMenu } from './_common_/common';
import { host } from '@/config'; import { host } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from '@/navbar';
import { openAccountMenu } from '@/account'; import { openAccountMenu } from '@/account';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { mainRouter } from '@/router';
export default defineComponent({ export default defineComponent({
components: { components: {
@ -103,7 +103,7 @@ export default defineComponent({
}, },
search() { search() {
search(); mainRouter.push('/search');
}, },
more(ev) { more(ev) {

View File

@ -44,12 +44,12 @@
import { defineAsyncComponent, defineComponent } from 'vue'; import { defineAsyncComponent, defineComponent } from 'vue';
import { openInstanceMenu } from './_common_/common'; import { openInstanceMenu } from './_common_/common';
import { host } from '@/config'; import { host } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from '@/navbar';
import { openAccountMenu } from '@/account'; import { openAccountMenu } from '@/account';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { StickySidebar } from '@/scripts/sticky-sidebar'; import { StickySidebar } from '@/scripts/sticky-sidebar';
import { mainRouter } from '@/router';
//import MisskeyLogo from '@assets/client/misskey.svg'; //import MisskeyLogo from '@assets/client/misskey.svg';
export default defineComponent({ export default defineComponent({
@ -120,7 +120,7 @@ export default defineComponent({
}, },
search() { search() {
search(); mainRouter.push('/search');
}, },
more(ev) { more(ev) {

View File

@ -40,7 +40,6 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import XHeader from './header.vue'; import XHeader from './header.vue';
import { host, instanceName } from '@/config'; import { host, instanceName } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { ColdDeviceStorage } from '@/store'; import { ColdDeviceStorage } from '@/store';
@ -77,7 +76,9 @@ export default defineComponent({
if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
this.$store.set('darkMode', !this.$store.state.darkMode); this.$store.set('darkMode', !this.$store.state.darkMode);
}, },
's': search, 's': () => {
mainRouter.push('/search');
},
'h|/': this.help, 'h|/': this.help,
}; };
}, },

View File

@ -58,7 +58,6 @@ import { ComputedRef, onMounted, provide } from 'vue';
import XHeader from './header.vue'; import XHeader from './header.vue';
import XKanban from './kanban.vue'; import XKanban from './kanban.vue';
import { host, instanceName } from '@/config'; import { host, instanceName } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import { instance } from '@/instance'; import { instance } from '@/instance';
import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue';
@ -97,7 +96,9 @@ const keymap = $computed(() => {
if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
defaultStore.set('darkMode', !defaultStore.state.darkMode); defaultStore.set('darkMode', !defaultStore.state.darkMode);
}, },
's': search, 's': () => {
mainRouter.push('/search');
},
}; };
}); });

View File

@ -27,7 +27,7 @@ import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue';
import * as os from '@/os'; import * as os from '@/os';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { search } from '@/scripts/search'; import { mainRouter } from '@/router';
export default defineComponent({ export default defineComponent({
data() { data() {
@ -55,7 +55,9 @@ export default defineComponent({
}, {}, 'closed'); }, {}, 'closed');
}, },
search, search() {
mainRouter.push('/search');
},
}, },
}); });
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
packages: packages:
- 'packages/backend' - 'packages/backend'
- 'packages/frontend' - 'packages/frontend'
- 'packages/sw' - 'packages/sw'