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
end_of_line = lf
[*.yml]
[*.{yml,yaml}]
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:
destroy-preview-environment:
runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
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
if: steps.check-conclusion.outputs.result == 'success'
uses: okteto/context@latest
with:
token: ${{ secrets.OKTETO_TOKEN }}
- name: Destroy preview environment
if: steps.check-conclusion.outputs.result == 'success'
uses: okteto/destroy-preview@latest
with:
name: pr-${{ github.event.number }}-syuilo

View File

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

View File

@ -9,6 +9,34 @@
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)
### Note

View File

@ -299,6 +299,27 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
- 生成後、ファイルを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`せよ
**Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。

View File

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

View File

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

View File

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

View File

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

View File

@ -393,13 +393,19 @@ about: "Über"
aboutMisskey: "Über Misskey"
administrator: "Administrator"
token: "Token"
2fa: "Zwei-Faktor-Authentifizierung"
totp: "Authentifizierungs-App"
totpDescription: "Logge dich via Authentifizierungs-App mit Einmalpasswort ein"
moderator: "Moderator"
moderation: "Moderation"
nUsersMentioned: "Von {n} Benutzern erwähnt"
securityKeyAndPasskey: "Security-Tokens und Passkeys"
securityKey: "Sicherheitsschlüssel"
lastUsed: "Zuletzt benutzt"
lastUsedAt: "Zuletzt verwendet: {t}"
unregister: "Deaktivieren"
passwordLessLogin: "Passwortloses Anmelden einrichten"
passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey"
resetPassword: "Passwort zurücksetzen"
newPasswordIs: "Das neue Passwort ist „{password}“"
reduceUiAnimation: "Animationen der Benutzeroberfläche reduzieren"
@ -773,6 +779,7 @@ popularPosts: "Beliebte Beiträge"
shareWithNote: "Mit Notiz teilen"
ads: "Werbung"
expiration: "Frist"
startingperiod: "Start"
memo: "Merkzettel"
priority: "Priorität"
high: "Hoch"
@ -805,6 +812,7 @@ lastCommunication: "Letzte Kommunikation"
resolved: "Gelöst"
unresolved: "Ungelöst"
breakFollow: "Follower entfernen"
breakFollowConfirm: "Diesen Follower wirklich entfernen?"
itsOn: "Eingeschaltet"
itsOff: "Ausgeschaltet"
emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren"
@ -939,6 +947,10 @@ collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen"
internalServerError: "Serverinterner Fehler"
internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten."
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:
earnedAt: "Freigeschaltet am"
_types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "vor {n} Woche(n)"
monthsAgo: "vor {n} Monat(en)"
yearsAgo: "vor {n} Jahr(en)"
invalid: "Ungültig"
_time:
second: "Sekunde(n)"
minute: "Minute(n)"
@ -1485,14 +1498,29 @@ _tutorial:
step8_3: "Diese Einstellung kannst du jederzeit ändern."
_2fa:
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."
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:"
step3Title: "Authentifizierungsscode eingeben"
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."
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."
removeKeyConfirm: "Das Backup {name} löschen?"
renewTOTPCancel: "Nein, danke"
chromePasskeyNotSupported: "Chrome-Passkeys werden zur Zeit nicht unterstützt."
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:
"read:account": "Deine Benutzerkontoinformationen lesen"
"write:account": "Deine Benutzerkontoinformationen bearbeiten"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "Nur für Follower sichtbar"
specified: "Direkt"
specifiedDescription: "Nur für bestimmte Benutzer sichtbar"
disableFederation: "Deförderiert"
disableFederationDescription: "Nicht an andere Instanzen übertragen"
_postForm:
replyPlaceholder: "Dieser Notiz antworten …"
quotePlaceholder: "Diese Notiz zitieren …"
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "Ende von Umfragen"
receiveFollowRequest: "Erhaltene Follow-Anfragen"
followRequestAccepted: "Akzeptierte Follow-Anfragen"
achievementEarned: "Errungenschaft freigeschaltet"
app: "Benachrichtigungen von Apps"
_actions:
followBack: "folgt dir nun auch"
@ -1802,3 +1833,6 @@ _deck:
channel: "Kanal"
mentions: "Erwähnungen"
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"
administrator: "Administrator"
token: "Token"
2fa: "Two-factor authentication"
totp: "Authenticator App"
totpDescription: "Use an authenticator app to enter one-time passwords"
moderator: "Moderator"
moderation: "Moderation"
nUsersMentioned: "Mentioned by {n} users"
securityKeyAndPasskey: "Security- and passkeys"
securityKey: "Security key"
lastUsed: "Last used"
lastUsedAt: "Last used: {t}"
unregister: "Unregister"
passwordLessLogin: "Password-less login"
passwordLessLoginDescription: "Allows password-less login using a security- or passkey only"
resetPassword: "Reset password"
newPasswordIs: "The new password is \"{password}\""
reduceUiAnimation: "Reduce UI animations"
@ -773,6 +779,7 @@ popularPosts: "Popular posts"
shareWithNote: "Share with note"
ads: "Advertisements"
expiration: "Deadline"
startingperiod: "Start"
memo: "Memo"
priority: "Priority"
high: "High"
@ -805,6 +812,7 @@ lastCommunication: "Last communication"
resolved: "Resolved"
unresolved: "Unresolved"
breakFollow: "Remove follower"
breakFollowConfirm: "Really remove this follower?"
itsOn: "Enabled"
itsOff: "Disabled"
emailRequiredForSignup: "Require email address for sign-up"
@ -939,6 +947,10 @@ collapseRenotes: "Collapse renotes you've already seen"
internalServerError: "Internal Server Error"
internalServerErrorDescription: "The server has run into an unexpected error."
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:
earnedAt: "Unlocked at"
_types:
@ -1452,6 +1464,7 @@ _ago:
weeksAgo: "{n}w ago"
monthsAgo: "{n}mo ago"
yearsAgo: "{n}y ago"
invalid: "None"
_time:
second: "Second(s)"
minute: "Minute(s)"
@ -1485,14 +1498,29 @@ _tutorial:
step8_3: "You can always change this setting later."
_2fa:
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."
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:"
step3Title: "Enter an authentication code"
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."
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."
removeKeyConfirm: "Delete the {name} backup?"
renewTOTPCancel: "Not now"
chromePasskeyNotSupported: "Chrome passkeys are currently not supported."
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:
"read:account": "View your account information"
"write:account": "Edit your account information"
@ -1615,6 +1643,8 @@ _visibility:
followersDescription: "Make visible to your followers only"
specified: "Direct"
specifiedDescription: "Make visible for specified users only"
disableFederation: "Unfederated"
disableFederationDescription: "Don't transmit to other instances"
_postForm:
replyPlaceholder: "Reply to this note..."
quotePlaceholder: "Quote this note..."
@ -1770,6 +1800,7 @@ _notification:
pollEnded: "Polls ending"
receiveFollowRequest: "Received follow requests"
followRequestAccepted: "Accepted follow requests"
achievementEarned: "Achievement unlocked"
app: "Notifications from linked apps"
_actions:
followBack: "followed you back"
@ -1802,3 +1833,6 @@ _deck:
channel: "Channel"
mentions: "Mentions"
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"
cantReRenote: "No se puede renotar una renota"
quote: "Citar"
inChannelRenote: "Renota sólo del canal"
inChannelQuote: "Cita sólo del canal"
pinnedNote: "Nota fijada"
pinned: "Fijar al perfil"
you: "Tú"
@ -391,13 +393,19 @@ about: "Información"
aboutMisskey: "Sobre Misskey"
administrator: "Administrador"
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"
moderation: "Moderación"
nUsersMentioned: "{n} usuarios mencionados"
securityKeyAndPasskey: "Clave de seguridad / clave de paso"
securityKey: "Clave de seguridad"
lastUsed: "Última vez usado"
lastUsedAt: "Último uso: {t}"
unregister: "Cancelar registro"
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"
newPasswordIs: "La nueva contraseña es \"{password}\""
reduceUiAnimation: "Reducir la animación de la UI"
@ -451,6 +459,8 @@ native: "Nativo"
disableDrawer: "No mostrar los menús en cajones"
noHistory: "No hay datos en el historial"
signinHistory: "Historial de ingresos"
enableAdvancedMfm: "Habilitar MFM avanzado"
enableAnimatedMfm: "Habilitar MFM con movimiento"
doing: "Voy en camino"
category: "Categoría"
tags: "Etiqueta"
@ -769,6 +779,7 @@ popularPosts: "Más vistos"
shareWithNote: "Compartir con una nota"
ads: "Anuncios"
expiration: "Termina el"
startingperiod: "periodo de inicio"
memo: "Notas"
priority: "Prioridad"
high: "Alta"
@ -801,6 +812,7 @@ lastCommunication: "Última comunicación"
resolved: "Resuelto"
unresolved: "Sin resolver"
breakFollow: "Dejar de seguir"
breakFollowConfirm: "¿Quieres dejar de seguir?"
itsOn: "¡Está encendido!"
itsOff: "¡Está apagado!"
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"
cropImage: "Recortar imágen"
cropImageAsk: "¿Desea recortar la imagen?"
cropYes: "Recortar"
cropNo: "Usar como está"
file: "Archivos"
recentNHours: "Últimas {n} horas"
recentNDays: "Últimos {n} días"
@ -925,6 +939,18 @@ selectFromPresets: "Escoger desde predefinidos"
achievements: "Logros"
gotInvalidResponseError: "Respuesta del servidor inválida"
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:
earnedAt: "Desbloqueado el"
_types:
@ -1438,6 +1464,7 @@ _ago:
weeksAgo: "Hace {n} semanas"
monthsAgo: "Hace {n} meses"
yearsAgo: "Hace {n} años"
invalid: "No hay nada que ver aqui"
_time:
second: "Segundos"
minute: "Minutos"
@ -1471,13 +1498,28 @@ _tutorial:
step8_3: "La configuración de las notificaciones puede modificarse posteriormente."
_2fa:
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."
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:"
step3Title: "Ingresa un código de autenticación"
step3: "Para terminar, ingrese el token mostrado en la aplicación."
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"
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}\"?"
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"
_permissions:
"read:account": "Ver información de la cuenta"
@ -1601,6 +1643,8 @@ _visibility:
followersDescription: "Visible sólo para tus seguidores"
specified: "Mensaje directo"
specifiedDescription: "Visible sólo para los usuarios elegidos"
disableFederation: "No federado"
disableFederationDescription: "No enviar a otras instancias"
_postForm:
replyPlaceholder: "Responder a esta nota"
quotePlaceholder: "Citar esta nota"
@ -1756,6 +1800,7 @@ _notification:
pollEnded: "La encuesta terminó"
receiveFollowRequest: "Recibió una solicitud de seguimiento"
followRequestAccepted: "El seguimiento fue aceptado"
achievementEarned: "Logro desbloqueado"
app: "Notificaciones desde aplicaciones"
_actions:
followBack: "Te sigue de vuelta"
@ -1788,3 +1833,6 @@ _deck:
channel: "Canal"
mentions: "Menciones"
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é."
cantReRenote: "Impossible de renoter une Renote."
quote: "Citer"
inChannelRenote: "Renoter dans le canal"
inChannelQuote: "Citer dans le canal"
pinnedNote: "Note épinglée"
pinned: "Épingler sur le profil"
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 ?"
unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?"
selectList: "Sélectionner une liste"
selectChannel: "Sélectionner un canal"
selectAntenna: "Sélectionner une antenne"
selectWidget: "Sélectionner un widget"
editWidgets: "Modifier les widgets"
@ -898,6 +901,17 @@ show: "Affichage"
neverShow: "Ne plus afficher"
remindMeLater: "Peut-être plus tard"
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:
priority: "Priorité"
_priority:
@ -1121,6 +1135,7 @@ _ago:
weeksAgo: "Il y a {n} semaines"
monthsAgo: "Il y a {n} mois"
yearsAgo: "Il y a {n} ans"
invalid: "Il n'y a rien à voir ici"
_time:
second: "s"
minute: "min"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
project: ['./tsconfig.json', './test/tsconfig.json'],
},
extends: [
'../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,
// 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
coverageDirectory: "coverage",
@ -159,6 +159,7 @@ module.exports = {
// The glob patterns Jest uses to detect test files
testMatch: [
"<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",
//"<rootDir>/test/e2e/**/*.ts"
],

View File

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

View File

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

View File

@ -28,6 +28,101 @@ type PrivateKey = {
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()
export class ApRequestService {
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') と言われる
}
@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
public async signedPost(user: { id: User['id'] }, url: string, object: any) {
const body = JSON.stringify(object);
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedPost({
const req = ApRequestCreator.createSignedPost({
key: {
privateKeyPem: keypair.privateKey,
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'] }) {
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedGet({
const req = ApRequestCreator.createSignedGet({
key: {
privateKeyPem: keypair.privateKey,
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
// To avoid excessive stack depth error,
// 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<
{ -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]> }
>;
@ -136,18 +136,19 @@ type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
// 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 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 ObjectSchemaTypeDef<p extends Schema> =
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
p['properties'] extends NonNullable<Obj> ?
p['anyOf'] extends ReadonlyArray<Schema> ?
ObjType<p['properties'], NonNullable<p['required']>[number]> & UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>>
:
ObjType<p['properties'], NonNullable<p['required']>[number]>
p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
: never
: 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']>> :
any

View File

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

View File

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

View File

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

View File

@ -39,19 +39,13 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [
{
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
properties: {
url: { type: 'string' },
},
required: ['url'],
},
{ required: ['fileId'] },
{ required: ['url'] },
],
} 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',
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;
@ -95,74 +101,56 @@ export const paramDef = {
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { 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: [
{
// (re)note with text, files and poll are optional
properties: {
text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
},
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'],
},
{ required: ['text'] },
{ required: ['renoteId'] },
{ required: ['fileIds'] },
{ required: ['mediaIds'] },
{ required: ['poll'] },
],
} as const;
@ -207,6 +195,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchFile);
}
}
let renote: Note | null = null;

View File

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

View File

@ -36,32 +36,25 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
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: [
{
properties: {
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'],
},
{ required: ['tag'] },
{ required: ['query'] },
],
} as const;

View File

@ -58,25 +58,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
const hasFollowing = (await this.followingsRepository.count({
where: {
followerId: me.id,
},
take: 1,
})) !== 0;
const followees = await this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id })
.getMany();
//#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'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.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')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
@ -87,8 +77,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.setParameters(followingQuery.getParameters());
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
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.generateRepliesQuery(query, me);

View File

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

View File

@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
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: [
{
properties: {
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'],
},
{ required: ['userId'] },
{ required: ['username', 'host'] },
],
} as const;

View File

@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
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: [
{
properties: {
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'],
},
{ required: ['userId'] },
{ required: ['username', 'host'] },
],
} as const;

View File

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

View File

@ -54,32 +54,22 @@ export const meta = {
export const paramDef = {
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: [
{
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
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'],
},
{ required: ['userId'] },
{ required: ['userIds'] },
{ required: ['username'] },
],
} as const;

View File

@ -17,10 +17,18 @@
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static-assets/splash.png",
"sizes": "300x300",
"type": "image/png",
"purpose": "any"
}
],
"share_target": {
"action": "/share/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"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": [
"esnext"
],
"types": ["jest"]
"types": ["jest", "node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts"
"./**/*.ts",
"../src/**/*.test.ts",
"../src/@types/**/*.ts",
]
}

View File

@ -1,7 +1,8 @@
import * as assert from 'assert';
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) => {
return {
@ -9,7 +10,7 @@ export const buildParsedSignature = (signingString: string, signature: string, a
params: {
keyId: 'KeyID', // dummy, not used for verify
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,
},
signingString: signingString,
@ -29,7 +30,7 @@ describe('ap-request', () => {
'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');
@ -45,7 +46,7 @@ describe('ap-request', () => {
'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');

View File

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

View File

@ -41,12 +41,12 @@
"matter-js": "0.19.0",
"mfm-js": "0.23.3",
"misskey-js": "0.0.15",
"photoswipe": "5.3.5",
"photoswipe": "5.3.6",
"prismjs": "1.29.0",
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
"rollup": "3.17.2",
"rollup": "3.17.3",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"sass": "1.58.3",
@ -54,7 +54,7 @@
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.149.0",
"three": "0.150.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.2",
@ -63,7 +63,7 @@
"typescript": "4.9.5",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "4.1.2",
"vite": "4.1.4",
"vue": "3.2.47",
"vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
@ -71,29 +71,28 @@
},
"devDependencies": {
"@types/escape-regexp": "0.0.1",
"@types/glob": "8.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
"@types/node": "18.14.0",
"@types/node": "18.14.1",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.8.0",
"@types/seedrandom": "3.0.4",
"@types/seedrandom": "3.0.5",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.0",
"@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.52.0",
"@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.53.0",
"@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3",
"cypress": "12.6.0",
"eslint": "8.34.0",
"cypress": "12.7.0",
"eslint": "8.35.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0",
"start-server-and-test": "1.15.4",
"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'));
const SCROLLBAR_THICKNESS = 16;
onMounted(() => {
let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1
@ -39,12 +41,12 @@ onMounted(() => {
const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight;
if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset;
if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset;
}
if (top + height - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - height + window.pageYOffset;
if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) {
top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset;
}
if (top < 0) {

View File

@ -113,6 +113,23 @@ onMounted(() => {
});
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 => {

View File

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

View File

@ -56,7 +56,7 @@
</template>
<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 MkSwitch from '@/components/MkSwitch.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
@ -111,11 +111,11 @@ watch(() => props.items, () => {
immediate: true,
});
let childMenu = $ref<MenuItem[] | null>();
let childMenu = ref<MenuItem[] | null>();
let childTarget = $shallowRef<HTMLElement | null>();
function closeChild() {
childMenu = null;
childMenu.value = null;
childShowingItem = null;
}
@ -140,13 +140,31 @@ function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
let childrenCache = new WeakMap();
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) {
os.popupMenu(item.children, ev.currentTarget ?? ev.target);
os.popupMenu(children, ev.currentTarget ?? ev.target);
close();
} else {
childTarget = ev.currentTarget ?? ev.target;
childMenu = item.children;
childMenu = children;
childShowingItem = item;
}
}

View File

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

View File

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

View File

@ -250,7 +250,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
os.apiWithDialog('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
});
@ -271,7 +271,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
os.apiWithDialog('notes/create', {
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 os from '@/os';
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 { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
@ -107,6 +108,12 @@ const {
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
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
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 => {
// unshiftOK
if (!rootEl) {
@ -286,9 +315,7 @@ const prepend = (item: MisskeyEntity): void => {
return;
}
const isTop = isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
if (isTop) unshiftItems([item]);
if (isTop() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
};
@ -357,6 +384,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (timerForSetPause) {
clearTimeout(timerForSetPause);
timerForSetPause = null;
}
scrollObserver.disconnect();
});

View File

@ -1,12 +1,25 @@
<template>
<div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
<button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
<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>
</div>
<div v-else-if="tweetId && tweetExpanded" 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>
<template v-if="playerEnabled">
<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/>
<span v-else>invalid url</span>
</div>
<div :class="$style.action">
<MkButton :small="true" inline @click="playerEnabled = false">
<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">
<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}')`">

View File

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

View File

@ -147,10 +147,7 @@ onUnmounted(() => {
.tabs:first-child {
margin-left: auto;
}
.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%);
padding: 0 12px;
}
.tabs {
margin-right: auto;

View File

@ -1,6 +1,7 @@
<template>
<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 === 'detail'">{{ absolute }} ({{ relative }})</template>
</time>
@ -12,18 +13,24 @@ import { i18n } from '@/i18n';
import { dateTimeFormat } from '@/scripts/intl-const';
const props = withDefaults(defineProps<{
time: Date | string;
time: Date | string | number | null;
mode?: 'relative' | 'absolute' | 'detail';
}>(), {
mode: 'relative',
});
const _time = typeof props.time === 'string' ? new Date(props.time) : props.time;
const absolute = dateTimeFormat.format(_time);
const _time = props.time == null ? NaN :
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());
const relative = $computed(() => {
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
let now = $ref((new Date()).getTime());
const relative = $computed<string>(() => {
if (props.mode === 'absolute') return ''; // absoluterelative使
if (invalid) return i18n.ts._ago.invalid;
const ago = (now - _time) / 1000/*ms*/;
return (
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
@ -39,8 +46,8 @@ const relative = $computed(() => {
let tickId: number;
function tick() {
now = new Date();
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
now = (new Date()).getTime();
const ago = (now - _time) / 1000/*ms*/;
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
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 { fetchInstance, instance } from '@/instance';
import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
import { deviceKind } from '@/scripts/device-kind';
import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload';
@ -47,6 +46,7 @@ import { deckStore } from './ui/deck/deck-store';
import { miLocalStorage } from './local-storage';
import { claimAchievement, claimedAchievements } from './scripts/achievements';
import { fetchCustomEmojis } from './custom-emojis';
import { mainRouter } from './router';
console.info(`Misskey v${version}`);
@ -352,7 +352,9 @@ const hotkeys = {
'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': search,
's': (): void => {
mainRouter.push('/search');
}
};
if ($i) {

View File

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

View File

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

View File

@ -1,9 +1,9 @@
<template>
<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">
<div v-if="channel">
<div class="wpgynlbz _panel _margin" :class="{ hide: !showBanner }">
<div v-if="channel && tab === 'timeline'" class="_gaps">
<div class="wpgynlbz _panel" :class="{ hide: !showBanner }">
<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
<button class="_button toggle" @click="() => showBanner = !showBanner">
<template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
@ -24,9 +24,12 @@
</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>
</MkSpacer>
</MkStickyContainer>
@ -43,6 +46,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
const router = useRouter();
@ -50,15 +54,17 @@ const props = defineProps<{
channelId: string;
}>();
let tab = $ref('timeline');
let channel = $ref(null);
let showBanner = $ref(true);
const pagination = {
endpoint: 'channels/timeline' as const,
const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
params: computed(() => ({
offsetMode: true,
params: {
channelId: props.channelId,
})),
};
},
}));
watch(() => props.channelId, async () => {
channel = await os.api('channels/show', {
@ -76,7 +82,15 @@ const headerActions = $computed(() => channel && channel.userId ? [{
handler: edit,
}] : 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 ? {
title: channel.name,

View File

@ -11,23 +11,6 @@
<div v-else-if="tab === 'roles'">
<XRoles/>
</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>
</MkStickyContainer>
</template>
@ -38,11 +21,8 @@ import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue';
import XRoles from './explore.roles.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 { i18n } from '@/i18n';
import MkUserList from '@/components/MkUserList.vue';
const props = withDefaults(defineProps<{
tag?: string;
@ -53,22 +33,11 @@ const props = withDefaults(defineProps<{
let tab = $ref(props.initialTab);
let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>();
let searchQuery = $ref(null);
let searchOrigin = $ref('combined');
watch(() => props.tag, () => {
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 headerTabs = $computed(() => [{
@ -83,10 +52,6 @@ const headerTabs = $computed(() => [{
key: 'roles',
icon: 'ti ti-badges',
title: i18n.ts.roles,
}, {
key: 'search',
icon: 'ti ti-search',
title: i18n.ts.search,
}]);
definePageMetadata(computed(() => ({

View File

@ -2,33 +2,104 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<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>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted } from '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 { definePageMetadata } from '@/scripts/page-metadata';
import * as os from '@/os';
import { useRouter } from '@/router';
import { $i } from '@/account';
import { useRouter, mainRouter } from '@/router';
const router = useRouter();
const props = defineProps<{
query: 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) {
if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) {
onMounted(() => {
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', {
uri: props.query,
uri: query,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
@ -36,28 +107,40 @@ if ($i != null) {
const res = await promise;
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') {
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,
limit: 10,
params: computed(() => ({
query: props.query,
query: searchQuery,
channelId: props.channel,
})),
};
const userPagination = {
endpoint: 'users/search' as const,
limit: 10,
params: computed(() => ({
query: searchQuery,
origin: searchOrigin,
})),
};
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: i18n.t('searchWith', { q: props.query }),
title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search,
icon: 'ti ti-search',
})));
</script>

View File

@ -4,27 +4,29 @@
<FormSection>
<template #label>{{ i18n.ts.manage }}</template>
<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>
<div class="_gaps_s">
<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>
<template #key>{{ i18n.ts.author }}</template>
<template #value>{{ plugin.author }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ plugin.description }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.permission }}</template>
<template #value>{{ plugin.permission }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.author }}</template>
<template #value>{{ plugin.author }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ plugin.description }}</template>
</MkKeyValue>
<MkKeyValue>
<template #key>{{ i18n.ts.permission }}</template>
<template #value>{{ plugin.permission }}</template>
</MkKeyValue>
<div class="_buttons">
<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>
<div class="_buttons">
<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>
</div>
</div>
</div>
</FormSection>

View File

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

View File

@ -9,6 +9,7 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
import { noteActions } from '@/store';
import { miLocalStorage } from '@/local-storage';
import { getUserMenu } from '@/scripts/get-user-menu';
export function getNoteMenu(props: {
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> {
os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
props.isDeleted.value = true;
@ -264,9 +205,67 @@ export function getNoteMenu(props: {
action: () => toggleFavorite(true),
}),
{
type: 'parent',
icon: 'ti ti-paperclip',
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 ? {
icon: 'ti ti-message-off',
@ -286,6 +285,15 @@ export function getNoteMenu(props: {
text: i18n.ts.pin,
action: () => togglePin(true),
} : 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 ? [
null,

View File

@ -1,4 +1,5 @@
import { defineAsyncComponent } from 'vue';
import * as misskey from 'misskey-js';
import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config';
@ -8,32 +9,9 @@ import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router';
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;
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() {
if (user.isMuted) {
os.apiWithDialog('mute/delete', {
@ -102,6 +80,8 @@ export function getUserMenu(user, router: Router = mainRouter) {
}
async function invalidateFollow() {
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
os.apiWithDialog('following/invalidate', {
userId: user.id,
}).then(() => {
@ -113,7 +93,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`);
copyToClipboard(`@${user.username}@${user.host ?? host}`);
},
}, {
icon: 'ti ti-info-circle',
@ -134,12 +114,43 @@ export function getUserMenu(user, router: Router = mainRouter) {
os.post({ specified: user });
},
}, null, {
type: 'parent',
icon: 'ti ti-list',
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;
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, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
@ -163,30 +174,6 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts.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) {

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

View File

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

View File

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

View File

@ -58,7 +58,6 @@ import { ComputedRef, onMounted, provide } from 'vue';
import XHeader from './header.vue';
import XKanban from './kanban.vue';
import { host, instanceName } from '@/config';
import { search } from '@/scripts/search';
import * as os from '@/os';
import { instance } from '@/instance';
import XSigninDialog from '@/components/MkSigninDialog.vue';
@ -97,7 +96,9 @@ const keymap = $computed(() => {
if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
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 * as os from '@/os';
import { instance } from '@/instance';
import { search } from '@/scripts/search';
import { mainRouter } from '@/router';
export default defineComponent({
data() {
@ -55,7 +55,9 @@ export default defineComponent({
}, {}, 'closed');
},
search,
search() {
mainRouter.push('/search');
},
},
});
</script>

File diff suppressed because it is too large Load Diff