Merge pull request #10177 from misskey-dev/develop

Release: 13.9.0
This commit is contained in:
syuilo 2023-03-03 15:35:40 +09:00 committed by GitHub
commit 5bd68aa3e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
142 changed files with 2284 additions and 1624 deletions

View file

@ -10,6 +10,7 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release
- 他にこのリリースに含めなければならない変更は無いか - 他にこのリリースに含めなければならない変更は無いか
- 全体的な変更内容を俯瞰し問題は無いか - 全体的な変更内容を俯瞰し問題は無いか
- レビューされていないコミットがある場合は、それが問題ないか - レビューされていないコミットがある場合は、それが問題ないか
- 最終的な動作確認を行い問題は無いか
などを確認し、リリースする準備が整っていると思われる場合は approve してください。 などを確認し、リリースする準備が整っていると思われる場合は approve してください。

10
.github/reviewer-lottery.yml vendored Normal file
View file

@ -0,0 +1,10 @@
groups:
- name: devs
reviewers: 2
internal_reviewers: 1
usernames:
- syuilo
- acid-chicken
- EbiseLutica
- rinsuki
- tamaina

13
.github/workflows/reviewer_lottery.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: "Reviewer lottery"
on:
pull_request_target:
types: [opened, ready_for_review, reopened]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: uesteibar/reviewer-lottery@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -10,6 +10,21 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.9.0 (2023/03/03)
### Improvements
- 時限ロール
- アンテナでCWも検索対象にするように
- ノートの操作部をホバー時のみ表示するオプションを追加
- サウンドを追加
- サーバーのパフォーマンスを改善
### Bugfixes
- 外部メディアプロキシ使用時にアバタークロップができない問題を修正
- fix(server): メールアドレス更新時にバリデーションが正しく行われていないのを修正
- fix(server): チャンネルでミュートが正しく機能していないのを修正
- プッシュ通知でカスタム絵文字リアクションを表示できなかった問題を修正
## 13.8.1 (2023/02/26) ## 13.8.1 (2023/02/26)
### Bugfixes ### Bugfixes

View file

@ -10,14 +10,14 @@ describe('Before setup instance', () => {
}); });
it('successfully loads', () => { it('successfully loads', () => {
cy.visit('/'); cy.visitHome();
}); });
it('setup instance', () => { it('setup instance', () => {
cy.visit('/'); cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup'); cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-username] input').type('admin'); cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234'); cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click(); cy.get('[data-cy-admin-ok]').click();
@ -43,11 +43,11 @@ describe('After setup instance', () => {
}); });
it('successfully loads', () => { it('successfully loads', () => {
cy.visit('/'); cy.visitHome();
}); });
it('signup', () => { it('signup', () => {
cy.visit('/'); cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup'); cy.intercept('POST', '/api/signup').as('signup');
@ -79,11 +79,11 @@ describe('After user signup', () => {
}); });
it('successfully loads', () => { it('successfully loads', () => {
cy.visit('/'); cy.visitHome();
}); });
it('signin', () => { it('signin', () => {
cy.visit('/'); cy.visitHome();
cy.intercept('POST', '/api/signin').as('signin'); cy.intercept('POST', '/api/signin').as('signin');
@ -101,7 +101,7 @@ describe('After user signup', () => {
userId: this.alice.id, userId: this.alice.id,
}); });
cy.visit('/'); cy.visitHome();
cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type('alice'); cy.get('[data-cy-signin-username] input').type('alice');
@ -112,7 +112,7 @@ describe('After user signup', () => {
}); });
}); });
describe('After user singed in', () => { describe('After user signed in', () => {
beforeEach(() => { beforeEach(() => {
cy.resetState(); cy.resetState();
@ -141,6 +141,19 @@ describe('After user singed in', () => {
cy.get('[data-cy-open-post-form-submit]').click(); cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!'); cy.contains('Hello, Misskey!');
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
}); });
}); });

View file

@ -24,6 +24,11 @@
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => { Cypress.Commands.add('resetState', () => {
cy.window(win => { cy.window(win => {
win.indexedDB.deleteDatabase('keyval-store'); win.indexedDB.deleteDatabase('keyval-store');
@ -43,7 +48,7 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
}); });
Cypress.Commands.add('login', (username, password) => { Cypress.Commands.add('login', (username, password) => {
cy.visit('/'); cy.visitHome();
cy.intercept('POST', '/api/signin').as('signin'); cy.intercept('POST', '/api/signin').as('signin');

View file

@ -785,6 +785,7 @@ size: "الحجم"
numberOfColumn: "عدد الأعمدة" numberOfColumn: "عدد الأعمدة"
searchByGoogle: "غوغل" searchByGoogle: "غوغل"
mutePeriod: "مدة الكتم" mutePeriod: "مدة الكتم"
period: "ينتهي استطلاع الرأي في"
indefinitely: "أبدًا" indefinitely: "أبدًا"
tenMinutes: "10 دقائق" tenMinutes: "10 دقائق"
oneHour: "ساعة" oneHour: "ساعة"

View file

@ -819,6 +819,7 @@ instanceDefaultLightTheme: "ইন্সট্যান্সের ডিফল
instanceDefaultDarkTheme: "ইন্সট্যান্সের ডিফল্ট ডার্ক থিম" instanceDefaultDarkTheme: "ইন্সট্যান্সের ডিফল্ট ডার্ক থিম"
instanceDefaultThemeDescription: "অবজেক্ট ফরম্যাটে থিম কোড লিখুন" instanceDefaultThemeDescription: "অবজেক্ট ফরম্যাটে থিম কোড লিখুন"
mutePeriod: "মিউটের সময়কাল" mutePeriod: "মিউটের সময়কাল"
period: "পোলের সময়সীমা"
indefinitely: "অনির্দিষ্ট" indefinitely: "অনির্দিষ্ট"
tenMinutes: "১০ মিনিট" tenMinutes: "১০ মিনিট"
oneHour: "১ ঘণ্টা" oneHour: "১ ঘণ্টা"

View file

@ -457,6 +457,7 @@ aboutX: "Über {x}"
emojiStyle: "Emoji-Stil" emojiStyle: "Emoji-Stil"
native: "Nativ" native: "Nativ"
disableDrawer: "Keine ausfahrbaren Menüs verwenden" disableDrawer: "Keine ausfahrbaren Menüs verwenden"
showNoteActionsOnlyHover: "Aktionen für Notizen nur bei Mouseover anzeigen"
noHistory: "Kein Verlauf gefunden" noHistory: "Kein Verlauf gefunden"
signinHistory: "Anmeldungsverlauf" signinHistory: "Anmeldungsverlauf"
enableAdvancedMfm: "Erweitertes MFM aktivieren" enableAdvancedMfm: "Erweitertes MFM aktivieren"
@ -847,11 +848,13 @@ instanceDefaultLightTheme: "Instanzweites Standardfarbschema (Hell)"
instanceDefaultDarkTheme: "Instanzweites Standardfarbschema (Dunkel)" instanceDefaultDarkTheme: "Instanzweites Standardfarbschema (Dunkel)"
instanceDefaultThemeDescription: "Gib den Farbschemencode im Objektformat ein." instanceDefaultThemeDescription: "Gib den Farbschemencode im Objektformat ein."
mutePeriod: "Stummschaltungsdauer" mutePeriod: "Stummschaltungsdauer"
period: "Zeitlimit"
indefinitely: "Dauerhaft" indefinitely: "Dauerhaft"
tenMinutes: "10 Minuten" tenMinutes: "10 Minuten"
oneHour: "Eine Stunde" oneHour: "Eine Stunde"
oneDay: "Einen Tag" oneDay: "Einen Tag"
oneWeek: "Eine Woche" oneWeek: "Eine Woche"
oneMonth: "1 Monat"
reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt." reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt."
failedToFetchAccountInformation: "Benutzerkontoinformationen konnten nicht abgefragt werden" failedToFetchAccountInformation: "Benutzerkontoinformationen konnten nicht abgefragt werden"
rateLimitExceeded: "Versuchsanzahl überschritten" rateLimitExceeded: "Versuchsanzahl überschritten"
@ -951,6 +954,7 @@ joinThisServer: "Bei dieser Instanz registrieren"
exploreOtherServers: "Eine andere Instanz finden" exploreOtherServers: "Eine andere Instanz finden"
letsLookAtTimeline: "Die Chronik durchstöbern" 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." disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt."
invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren."
_achievements: _achievements:
earnedAt: "Freigeschaltet am" earnedAt: "Freigeschaltet am"
_types: _types:

View file

@ -457,6 +457,7 @@ aboutX: "About {x}"
emojiStyle: "Emoji style" emojiStyle: "Emoji style"
native: "Native" native: "Native"
disableDrawer: "Don't use drawer-style menus" disableDrawer: "Don't use drawer-style menus"
showNoteActionsOnlyHover: "Only show note actions on hover"
noHistory: "No history available" noHistory: "No history available"
signinHistory: "Login history" signinHistory: "Login history"
enableAdvancedMfm: "Enable advanced MFM" enableAdvancedMfm: "Enable advanced MFM"
@ -847,11 +848,13 @@ instanceDefaultLightTheme: "Instance-wide default light theme"
instanceDefaultDarkTheme: "Instance-wide default dark theme" instanceDefaultDarkTheme: "Instance-wide default dark theme"
instanceDefaultThemeDescription: "Enter the theme code in object format." instanceDefaultThemeDescription: "Enter the theme code in object format."
mutePeriod: "Mute duration" mutePeriod: "Mute duration"
period: "Time limit"
indefinitely: "Permanently" indefinitely: "Permanently"
tenMinutes: "10 minutes" tenMinutes: "10 minutes"
oneHour: "One hour" oneHour: "One hour"
oneDay: "One day" oneDay: "One day"
oneWeek: "One week" oneWeek: "One week"
oneMonth: "One month"
reflectMayTakeTime: "It may take some time for this to be reflected." reflectMayTakeTime: "It may take some time for this to be reflected."
failedToFetchAccountInformation: "Could not fetch account information" failedToFetchAccountInformation: "Could not fetch account information"
rateLimitExceeded: "Rate limit exceeded" rateLimitExceeded: "Rate limit exceeded"
@ -951,6 +954,7 @@ joinThisServer: "Sign up at this instance"
exploreOtherServers: "Look for another instance" exploreOtherServers: "Look for another instance"
letsLookAtTimeline: "Have a look at the timeline" 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." disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting."
invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up."
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:

View file

@ -457,6 +457,7 @@ aboutX: "Acerca de {x}"
emojiStyle: "Estilo de emoji" emojiStyle: "Estilo de emoji"
native: "Nativo" native: "Nativo"
disableDrawer: "No mostrar los menús en cajones" disableDrawer: "No mostrar los menús en cajones"
showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor"
noHistory: "No hay datos en el historial" noHistory: "No hay datos en el historial"
signinHistory: "Historial de ingresos" signinHistory: "Historial de ingresos"
enableAdvancedMfm: "Habilitar MFM avanzado" enableAdvancedMfm: "Habilitar MFM avanzado"
@ -847,6 +848,7 @@ instanceDefaultLightTheme: "Tema claro por defecto de la instancia"
instanceDefaultDarkTheme: "Tema oscuro por defecto de la instancia" instanceDefaultDarkTheme: "Tema oscuro por defecto de la instancia"
instanceDefaultThemeDescription: "Ingrese el código del tema en formato objeto" instanceDefaultThemeDescription: "Ingrese el código del tema en formato objeto"
mutePeriod: "Período de silenciamiento" mutePeriod: "Período de silenciamiento"
period: "Termina el"
indefinitely: "Sin límite de tiempo" indefinitely: "Sin límite de tiempo"
tenMinutes: "10 minutos" tenMinutes: "10 minutos"
oneHour: "1 hora" oneHour: "1 hora"
@ -951,6 +953,7 @@ joinThisServer: "Registrarse en esta instancia"
exploreOtherServers: "Buscar otra instancia" exploreOtherServers: "Buscar otra instancia"
letsLookAtTimeline: "Mirar la línea de tiempo local" 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." 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."
invitationRequiredToRegister: "Esta instancia está configurada sólo por invitación, tienes que ingresar un código de invitación válido."
_achievements: _achievements:
earnedAt: "Desbloqueado el" earnedAt: "Desbloqueado el"
_types: _types:

View file

@ -833,6 +833,7 @@ instanceDefaultLightTheme: "Thème clair par défaut sur toute linstance"
instanceDefaultDarkTheme: "Thème sombre par défaut sur toute linstance" instanceDefaultDarkTheme: "Thème sombre par défaut sur toute linstance"
instanceDefaultThemeDescription: "Saisissez le code du thème en format objet." instanceDefaultThemeDescription: "Saisissez le code du thème en format objet."
mutePeriod: "Durée de mise en sourdine" mutePeriod: "Durée de mise en sourdine"
period: "Fin du sondage"
indefinitely: "Illimité" indefinitely: "Illimité"
tenMinutes: "10 minutes" tenMinutes: "10 minutes"
oneHour: "1 heure" oneHour: "1 heure"

View file

@ -839,6 +839,7 @@ instanceDefaultLightTheme: "Bawaan instan tema terang"
instanceDefaultDarkTheme: "Bawaan instan tema gelap" instanceDefaultDarkTheme: "Bawaan instan tema gelap"
instanceDefaultThemeDescription: "Masukkan kode tema di format obyek." instanceDefaultThemeDescription: "Masukkan kode tema di format obyek."
mutePeriod: "Batas waktu bisu" mutePeriod: "Batas waktu bisu"
period: "Batas akhir"
indefinitely: "Selamanya" indefinitely: "Selamanya"
tenMinutes: "10 Menit" tenMinutes: "10 Menit"
oneHour: "1 Jam" oneHour: "1 Jam"

View file

@ -75,7 +75,7 @@ lists: "Liste"
noLists: "Nessuna lista" noLists: "Nessuna lista"
note: "Nota" note: "Nota"
notes: "Note" notes: "Note"
following: "Lǝ segui" following: "Follow"
followers: "Followers" followers: "Followers"
followsYou: "Ti segue" followsYou: "Ti segue"
createList: "Aggiungi una nuova lista" createList: "Aggiungi una nuova lista"
@ -94,7 +94,7 @@ defaultNoteVisibility: "Privacy predefinita delle note"
follow: "Segui" follow: "Segui"
followRequest: "Richiesta di follow" followRequest: "Richiesta di follow"
followRequests: "Richieste di follow" followRequests: "Richieste di follow"
unfollow: "Smetti di seguire" unfollow: "Non seguire"
followRequestPending: "Richiesta in approvazione" followRequestPending: "Richiesta in approvazione"
enterEmoji: "Inserisci emoji" enterEmoji: "Inserisci emoji"
renote: "Rinota" renote: "Rinota"
@ -154,14 +154,14 @@ flagShowTimelineRepliesDescription: "Se è attiva, la timeline mostra le rispost
autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui" autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui"
addAccount: "Aggiungi profilo" addAccount: "Aggiungi profilo"
loginFailed: "Accesso non riuscito" loginFailed: "Accesso non riuscito"
showOnRemote: "Visualizza sull'istanza remota" showOnRemote: "Leggi sull'istanza remota"
general: "Generali" general: "Generali"
wallpaper: "Sfondo" wallpaper: "Sfondo"
setWallpaper: "Imposta sfondo" setWallpaper: "Imposta sfondo"
removeWallpaper: "Elimina lo sfondo" removeWallpaper: "Elimina lo sfondo"
searchWith: "Cerca: {q}" searchWith: "Cerca: {q}"
youHaveNoLists: "Non hai ancora creato nessuna lista" youHaveNoLists: "Non hai ancora creato nessuna lista"
followConfirm: "Sei sicur@ di voler seguire {name}?" followConfirm: "Vuoi seguire {name}?"
proxyAccount: "Profilo proxy" proxyAccount: "Profilo proxy"
proxyAccountDescription: "Un profilo proxy funziona come follower per i profili remoti, sotto certe condizioni. Ad esempio, quando un profilo locale ne inserisce uno remoto in una lista (senza seguirlo), se nessun altro segue quel profilo remoto, le attività non possono essere distribuite. Dunque, il profilo proxy le seguirà per tutti." proxyAccountDescription: "Un profilo proxy funziona come follower per i profili remoti, sotto certe condizioni. Ad esempio, quando un profilo locale ne inserisce uno remoto in una lista (senza seguirlo), se nessun altro segue quel profilo remoto, le attività non possono essere distribuite. Dunque, il profilo proxy le seguirà per tutti."
host: "Server remoto" host: "Server remoto"
@ -221,7 +221,7 @@ subscribing: "Iscrizione"
publishing: "Pubblicazione" publishing: "Pubblicazione"
notResponding: "Nessuna risposta" notResponding: "Nessuna risposta"
instanceFollowing: "Seguiti dall'istanza" instanceFollowing: "Seguiti dall'istanza"
instanceFollowers: "Followers dell'istanza" instanceFollowers: "Follower dell'istanza"
instanceUsers: "Utenti dell'istanza" instanceUsers: "Utenti dell'istanza"
changePassword: "Aggiorna Password" changePassword: "Aggiorna Password"
security: "Sicurezza" security: "Sicurezza"
@ -393,13 +393,19 @@ about: "Informazioni"
aboutMisskey: "Informazioni di Misskey" aboutMisskey: "Informazioni di Misskey"
administrator: "Amministratore" administrator: "Amministratore"
token: "Token" token: "Token"
2fa: "Autenticazione a due fattori"
totp: "App di autenticazione"
totpDescription: "Inserisci un codice OTP tramite un'app di autenticazione"
moderator: "Moderatore" moderator: "Moderatore"
moderation: "moderazione" moderation: "moderazione"
nUsersMentioned: "{n} profili menzionati" nUsersMentioned: "{n} profili menzionati"
securityKeyAndPasskey: "Chiave di sicurezza e accesso"
securityKey: "Chiave di sicurezza" securityKey: "Chiave di sicurezza"
lastUsed: "Ultima attività" lastUsed: "Ultima attività"
lastUsedAt: "Uso più recente: {t}"
unregister: "Annulla l'iscrizione" unregister: "Annulla l'iscrizione"
passwordLessLogin: "Accedi senza password" passwordLessLogin: "Accedi senza password"
passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza"
resetPassword: "Reimposta password" resetPassword: "Reimposta password"
newPasswordIs: "La tua nuova password è「{password}」" newPasswordIs: "La tua nuova password è「{password}」"
reduceUiAnimation: "Ridurre le animazioni dell'interfaccia" reduceUiAnimation: "Ridurre le animazioni dell'interfaccia"
@ -451,6 +457,7 @@ aboutX: "Informazioni su {x}"
emojiStyle: "Stile emoji" emojiStyle: "Stile emoji"
native: "Nativo" native: "Nativo"
disableDrawer: "Non mostrare il menù sul drawer" disableDrawer: "Non mostrare il menù sul drawer"
showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse"
noHistory: "Nessuna cronologia" noHistory: "Nessuna cronologia"
signinHistory: "Storico degli accessi al profilo" signinHistory: "Storico degli accessi al profilo"
enableAdvancedMfm: "Attiva MFM avanzati" enableAdvancedMfm: "Attiva MFM avanzati"
@ -773,6 +780,7 @@ popularPosts: "Le più visualizzate"
shareWithNote: "Condividere in nota" shareWithNote: "Condividere in nota"
ads: "Pubblicità" ads: "Pubblicità"
expiration: "Scadenza" expiration: "Scadenza"
startingperiod: "Periodo di inizio"
memo: "Promemoria" memo: "Promemoria"
priority: "Priorità" priority: "Priorità"
high: "Alta" high: "Alta"
@ -804,7 +812,8 @@ pubSub: "Publish/Subscribe del profilo"
lastCommunication: "La comunicazione più recente" lastCommunication: "La comunicazione più recente"
resolved: "Risolto" resolved: "Risolto"
unresolved: "Non risolto" unresolved: "Non risolto"
breakFollow: "Smetti di seguire" breakFollow: "Non seguire"
breakFollowConfirm: "Vuoi davvero togliere follower?"
itsOn: "Abilitato" itsOn: "Abilitato"
itsOff: "Disabilitato" itsOff: "Disabilitato"
emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi" emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi"
@ -839,11 +848,13 @@ instanceDefaultLightTheme: "Istanza, tema luminoso predefinito."
instanceDefaultDarkTheme: "Istanza, tema scuro predefinito." instanceDefaultDarkTheme: "Istanza, tema scuro predefinito."
instanceDefaultThemeDescription: "Compilare il codice del tema nel modulo dell'oggetto." instanceDefaultThemeDescription: "Compilare il codice del tema nel modulo dell'oggetto."
mutePeriod: "Durata del mute" mutePeriod: "Durata del mute"
period: "Scadenza"
indefinitely: "Non scade" indefinitely: "Non scade"
tenMinutes: "10 minuti" tenMinutes: "10 minuti"
oneHour: "1 ora" oneHour: "1 ora"
oneDay: "1 giorno" oneDay: "1 giorno"
oneWeek: "1 settimana" oneWeek: "1 settimana"
oneMonth: "Un mese"
reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto." reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto."
failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo" failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo"
rateLimitExceeded: "Superato il limite di richieste." rateLimitExceeded: "Superato il limite di richieste."
@ -939,6 +950,11 @@ collapseRenotes: "Comprimi i Rinota già letti"
internalServerError: "Errore interno del server" internalServerError: "Errore interno del server"
internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server" internalServerErrorDescription: "Si è verificato un errore imprevisto all'interno del server"
copyErrorInfo: "Copia le informazioni sull'errore" copyErrorInfo: "Copia le informazioni sull'errore"
joinThisServer: "Registrati su questa istanza"
exploreOtherServers: "Trova altre istanze"
letsLookAtTimeline: "Sbircia la timeline"
disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione."
invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore."
_achievements: _achievements:
earnedAt: "Data di conseguimento" earnedAt: "Data di conseguimento"
_types: _types:
@ -1075,7 +1091,7 @@ _achievements:
description: "Hai seguito 300 profili" description: "Hai seguito 300 profili"
_followers1: _followers1:
title: "Il primo profilo tuo Follower" title: "Il primo profilo tuo Follower"
description: "Hai ottenuto il tuo primo Follower" description: "Hai ottenuto il tuo primo profilo Follower"
_followers10: _followers10:
title: "Follow me!" title: "Follow me!"
description: "Hai ottenuto 10 profili Follower" description: "Hai ottenuto 10 profili Follower"
@ -1486,13 +1502,28 @@ _tutorial:
step8_3: "Potrai modificare questa impostazione." step8_3: "Potrai modificare questa impostazione."
_2fa: _2fa:
alreadyRegistered: "La configurazione è stata già completata." alreadyRegistered: "La configurazione è stata già completata."
registerTOTP: "Registra un'app di autenticazione"
passwordToTOTP: "Inserire la password"
step1: "Innanzitutto, installare sul dispositivo un'applicazione di autenticazione come {a} o {b}." step1: "Innanzitutto, installare sul dispositivo un'applicazione di autenticazione come {a} o {b}."
step2: "Quindi, scansionare il codice QR visualizzato con l'app." step2: "Quindi, scansionare il codice QR visualizzato con l'app."
step2Click: "Cliccando sul codice QR, puoi registrarlo con l'app di autenticazione o il portachiavi installato sul tuo dispositivo."
step2Url: "Nell'applicazione desktop inserire il seguente URL: " step2Url: "Nell'applicazione desktop inserire il seguente URL: "
step3Title: "Inserisci il codice di verifica"
step3: "Inserite il token visualizzato nell'app e il gioco è fatto." step3: "Inserite il token visualizzato nell'app e il gioco è fatto."
step4: "D'ora in poi, quando si accede, si inserisce il token nello stesso modo." step4: "D'ora in poi, quando si accede, si inserisce il token nello stesso modo."
securityKeyNotSupported: "Il tuo browser non supporta le chiavi di sicurezza."
registerTOTPBeforeKey: "Ti occorre un'app di autenticazione con OTP, prima di registrare la chiave di sicurezza."
securityKeyInfo: "È possibile impostare il dispositivo per accedere utilizzando una chiave di sicurezza hardware che supporta FIDO2 o un'impronta digitale o un PIN sul dispositivo." securityKeyInfo: "È possibile impostare il dispositivo per accedere utilizzando una chiave di sicurezza hardware che supporta FIDO2 o un'impronta digitale o un PIN sul dispositivo."
chromePasskeyNotSupported: "Le passkey di Chrome non sono attualmente supportate."
registerSecurityKey: "Registra la chiave di sicurezza"
securityKeyName: "Inserisci il nome della chiave"
tapSecurityKey: "Segui le istruzioni del browser e registra la chiave di sicurezza."
removeKey: "Elimina la chiave di sicurezza"
removeKeyConfirm: "Vuoi davvero eliminare \"{name}\"?" removeKeyConfirm: "Vuoi davvero eliminare \"{name}\"?"
whyTOTPOnlyRenew: "Se c'è una chiave di sicurezza attiva, non è possibile rimuovere l'app di autenticazione."
renewTOTP: "Riconfigura l'app di autenticazione"
renewTOTPConfirm: "I codici di verifica nelle app di autenticazione esistenti smetteranno di funzionare"
renewTOTPOk: "Ripristina"
renewTOTPCancel: "No grazie" renewTOTPCancel: "No grazie"
_permissions: _permissions:
"read:account": "Visualizza le informazioni sul profilo" "read:account": "Visualizza le informazioni sul profilo"
@ -1504,7 +1535,7 @@ _permissions:
"read:favorites": "Visualizza i tuoi preferiti" "read:favorites": "Visualizza i tuoi preferiti"
"write:favorites": "Gestisci i tuoi preferiti" "write:favorites": "Gestisci i tuoi preferiti"
"read:following": "Vedi le informazioni di follow" "read:following": "Vedi le informazioni di follow"
"write:following": "Seguiti/ Smetti di seguire" "write:following": "Seguire / Non seguire altri profili"
"read:messaging": "Visualizzare la chat" "read:messaging": "Visualizzare la chat"
"write:messaging": "Gestire la chat" "write:messaging": "Gestire la chat"
"read:mutes": "Vedi i profili silenziati" "read:mutes": "Vedi i profili silenziati"
@ -1612,10 +1643,12 @@ _visibility:
publicDescription: "Visibile per tutti sul Fediverso" publicDescription: "Visibile per tutti sul Fediverso"
home: "Home" home: "Home"
homeDescription: "Visibile solo sulla timeline \"Home\"" homeDescription: "Visibile solo sulla timeline \"Home\""
followers: "Followers" followers: "Follower"
followersDescription: "Visibile solo per i tuoi followers" followersDescription: "Visibile solo per i tuoi follower"
specified: "Nota diretta" specified: "Nota diretta"
specifiedDescription: "Visibile solo ai profili menzionati" specifiedDescription: "Visibile solo ai profili menzionati"
disableFederation: "Interrompi la federazione"
disableFederationDescription: "Non spedire attività alle altre istanze remote"
_postForm: _postForm:
replyPlaceholder: "Rispondi a questa nota..." replyPlaceholder: "Rispondi a questa nota..."
quotePlaceholder: "Cita questa nota..." quotePlaceholder: "Cita questa nota..."
@ -1642,7 +1675,7 @@ _profile:
_exportOrImport: _exportOrImport:
allNotes: "Tutte le note" allNotes: "Tutte le note"
favoritedNotes: "Note preferite" favoritedNotes: "Note preferite"
followingList: "Follows" followingList: "Follow"
muteList: "Elenco profili silenziati" muteList: "Elenco profili silenziati"
blockingList: "Elenco profili bloccati" blockingList: "Elenco profili bloccati"
userLists: "Liste" userLists: "Liste"
@ -1771,6 +1804,7 @@ _notification:
pollEnded: "Sondaggio chiuso." pollEnded: "Sondaggio chiuso."
receiveFollowRequest: "Richiesta di follow ricevuta" receiveFollowRequest: "Richiesta di follow ricevuta"
followRequestAccepted: "Richiesta di follow accettata" followRequestAccepted: "Richiesta di follow accettata"
achievementEarned: "Risultato raggiunto"
app: "Notifiche da applicazioni" app: "Notifiche da applicazioni"
_actions: _actions:
followBack: "Segui" followBack: "Segui"
@ -1803,3 +1837,6 @@ _deck:
channel: "Canale" channel: "Canale"
mentions: "Menzioni" mentions: "Menzioni"
direct: "Diretta" direct: "Diretta"
_dialog:
charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})"
charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})"

View file

@ -2,7 +2,7 @@ _lang_: "日本語"
headlineMisskey: "ノートでつながるネットワーク" headlineMisskey: "ノートでつながるネットワーク"
introMisskey: "ようこそMisskeyは、オープンソースの分散型マイクロブログサービスです。\n「ート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀" introMisskey: "ようこそMisskeyは、オープンソースの分散型マイクロブログサービスです。\n「ート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀"
poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>を使ったサービス(Misskeyインスタンスと呼ばれます)のひとつです。" poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>のサーバーのひとつです。"
monthAndDay: "{month}月 {day}日" monthAndDay: "{month}月 {day}日"
search: "検索" search: "検索"
notifications: "通知" notifications: "通知"
@ -18,7 +18,7 @@ enterUsername: "ユーザー名を入力"
renotedBy: "{user}がRenote" renotedBy: "{user}がRenote"
noNotes: "ノートはありません" noNotes: "ノートはありません"
noNotifications: "通知はありません" noNotifications: "通知はありません"
instance: "インスタンス" instance: "サーバー"
settings: "設定" settings: "設定"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "その他の設定" otherSettings: "その他の設定"
@ -163,13 +163,13 @@ searchWith: "検索: {q}"
youHaveNoLists: "リストがありません" youHaveNoLists: "リストがありません"
followConfirm: "{name}をフォローしますか?" followConfirm: "{name}をフォローしますか?"
proxyAccount: "プロキシアカウント" proxyAccount: "プロキシアカウント"
proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがインスタンスに配達されないため、代わりにプロキシアカウントがフォローするようにします。" proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。"
host: "ホスト" host: "ホスト"
selectUser: "ユーザーを選択" selectUser: "ユーザーを選択"
recipient: "宛先" recipient: "宛先"
annotation: "注釈" annotation: "注釈"
federation: "連合" federation: "連合"
instances: "インスタンス" instances: "サーバー"
registeredAt: "初観測" registeredAt: "初観測"
latestRequestReceivedAt: "直近のリクエスト受信" latestRequestReceivedAt: "直近のリクエスト受信"
latestStatus: "直近のステータス" latestStatus: "直近のステータス"
@ -178,7 +178,7 @@ charts: "チャート"
perHour: "1時間ごと" perHour: "1時間ごと"
perDay: "1日ごと" perDay: "1日ごと"
stopActivityDelivery: "アクティビティの配送を停止" stopActivityDelivery: "アクティビティの配送を停止"
blockThisInstance: "このインスタンスをブロック" blockThisInstance: "このサーバーをブロック"
operations: "操作" operations: "操作"
software: "ソフトウェア" software: "ソフトウェア"
version: "バージョン" version: "バージョン"
@ -189,15 +189,15 @@ jobQueue: "ジョブキュー"
cpuAndMemory: "CPUとメモリ" cpuAndMemory: "CPUとメモリ"
network: "ネットワーク" network: "ネットワーク"
disk: "ディスク" disk: "ディスク"
instanceInfo: "インスタンス情報" instanceInfo: "サーバー情報"
statistics: "統計" statistics: "統計"
clearQueue: "キューをクリア" clearQueue: "キューをクリア"
clearQueueConfirmTitle: "キューをクリアしますか?" clearQueueConfirmTitle: "キューをクリアしますか?"
clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。"
clearCachedFiles: "キャッシュをクリア" clearCachedFiles: "キャッシュをクリア"
clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?"
blockedInstances: "ブロックしたインスタンス" blockedInstances: "ブロックしたサーバー"
blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。サブドメインもブロックされます。" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このサーバーとやり取りできなくなります。サブドメインもブロックされます。"
muteAndBlock: "ミュートとブロック" muteAndBlock: "ミュートとブロック"
mutedUsers: "ミュートしたユーザー" mutedUsers: "ミュートしたユーザー"
blockedUsers: "ブロックしたユーザー" blockedUsers: "ブロックしたユーザー"
@ -220,9 +220,9 @@ all: "全て"
subscribing: "購読中" subscribing: "購読中"
publishing: "配信中" publishing: "配信中"
notResponding: "応答なし" notResponding: "応答なし"
instanceFollowing: "インスタンスのフォロー" instanceFollowing: "サーバーのフォロー"
instanceFollowers: "インスタンスのフォロワー" instanceFollowers: "サーバーのフォロワー"
instanceUsers: "インスタンスのユーザー" instanceUsers: "サーバーのユーザー"
changePassword: "パスワードを変更" changePassword: "パスワードを変更"
security: "セキュリティ" security: "セキュリティ"
retypedNotMatch: "入力が一致しません。" retypedNotMatch: "入力が一致しません。"
@ -314,8 +314,8 @@ unwatch: "ウォッチ解除"
accept: "許可" accept: "許可"
reject: "拒否" reject: "拒否"
normal: "正常" normal: "正常"
instanceName: "インスタンス名" instanceName: "サーバー名"
instanceDescription: "インスタンスの紹介" instanceDescription: "サーバーの紹介"
maintainerName: "管理者の名前" maintainerName: "管理者の名前"
maintainerEmail: "管理者のメールアドレス" maintainerEmail: "管理者のメールアドレス"
tosUrl: "利用規約URL" tosUrl: "利用規約URL"
@ -345,7 +345,7 @@ basicInfo: "基本情報"
pinnedUsers: "ピン留めユーザー" pinnedUsers: "ピン留めユーザー"
pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。" pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。"
pinnedPages: "ピン留めページ" pinnedPages: "ピン留めページ"
pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。" pinnedPagesDescription: "サーバーのトップページにピン留めしたいページのパスを改行で区切って記述します。"
pinnedClipId: "ピン留めするクリップのID" pinnedClipId: "ピン留めするクリップのID"
pinnedNotes: "ピン留めされたノート" pinnedNotes: "ピン留めされたノート"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
@ -457,6 +457,7 @@ aboutX: "{x}について"
emojiStyle: "絵文字のスタイル" emojiStyle: "絵文字のスタイル"
native: "ネイティブ" native: "ネイティブ"
disableDrawer: "メニューをドロワーで表示しない" disableDrawer: "メニューをドロワーで表示しない"
showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示する"
noHistory: "履歴はありません" noHistory: "履歴はありません"
signinHistory: "ログイン履歴" signinHistory: "ログイン履歴"
enableAdvancedMfm: "高度なMFMを有効にする" enableAdvancedMfm: "高度なMFMを有効にする"
@ -537,7 +538,7 @@ updateRemoteUser: "リモートユーザー情報の更新"
deleteAllFiles: "すべてのファイルを削除" deleteAllFiles: "すべてのファイルを削除"
deleteAllFilesConfirm: "すべてのファイルを削除しますか?" deleteAllFilesConfirm: "すべてのファイルを削除しますか?"
removeAllFollowing: "フォローを全解除" removeAllFollowing: "フォローを全解除"
removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのインスタンスがもう存在しなくなった場合などに実行してください。" removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのサーバーがもう存在しなくなった場合などに実行してください。"
userSuspended: "このユーザーは凍結されています。" userSuspended: "このユーザーは凍結されています。"
userSilenced: "このユーザーはサイレンスされています。" userSilenced: "このユーザーはサイレンスされています。"
yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedTitle: "アカウントが凍結されています"
@ -603,7 +604,7 @@ testEmail: "配信テスト"
wordMute: "ワードミュート" wordMute: "ワードミュート"
regexpError: "正規表現エラー" regexpError: "正規表現エラー"
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
instanceMute: "インスタンスミュート" instanceMute: "サーバーミュート"
userSaysSomething: "{name}が何かを言いました" userSaysSomething: "{name}が何かを言いました"
makeActive: "アクティブにする" makeActive: "アクティブにする"
display: "表示" display: "表示"
@ -634,15 +635,15 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ
reporter: "通報者" reporter: "通報者"
reporteeOrigin: "通報先" reporteeOrigin: "通報先"
reporterOrigin: "通報元" reporterOrigin: "通報元"
forwardReport: "リモートインスタンスに通報を転送する" forwardReport: "リモートサーバーに通報を転送する"
forwardReportIsAnonymous: "リモートインスタンスからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。"
send: "送信" send: "送信"
abuseMarkAsResolved: "対応済みにする" abuseMarkAsResolved: "対応済みにする"
openInNewTab: "新しいタブで開く" openInNewTab: "新しいタブで開く"
openInSideView: "サイドビューで開く" openInSideView: "サイドビューで開く"
defaultNavigationBehaviour: "デフォルトのナビゲーション" defaultNavigationBehaviour: "デフォルトのナビゲーション"
editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。"
instanceTicker: "ノートのインスタンス情報" instanceTicker: "ノートのサーバー情報"
waitingFor: "{x}を待っています" waitingFor: "{x}を待っています"
random: "ランダム" random: "ランダム"
system: "システム" system: "システム"
@ -731,7 +732,7 @@ capacity: "容量"
inUse: "使用中" inUse: "使用中"
editCode: "コードを編集" editCode: "コードを編集"
apply: "適用" apply: "適用"
receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る"
emailNotification: "メール通知" emailNotification: "メール通知"
publish: "公開" publish: "公開"
inChannelSearch: "チャンネル内検索" inChannelSearch: "チャンネル内検索"
@ -759,7 +760,7 @@ active: "アクティブ"
offline: "オフライン" offline: "オフライン"
notRecommended: "非推奨" notRecommended: "非推奨"
botProtection: "Botプロテクション" botProtection: "Botプロテクション"
instanceBlocking: "インスタンスブロック" instanceBlocking: "サーバーブロック"
selectAccount: "アカウントを選択" selectAccount: "アカウントを選択"
switchAccount: "アカウントを切り替え" switchAccount: "アカウントを切り替え"
enabled: "有効" enabled: "有効"
@ -843,15 +844,17 @@ themeColor: "テーマカラー"
size: "サイズ" size: "サイズ"
numberOfColumn: "列の数" numberOfColumn: "列の数"
searchByGoogle: "検索" searchByGoogle: "検索"
instanceDefaultLightTheme: "インスタンスデフォルトのライトテーマ" instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ"
instanceDefaultDarkTheme: "インスタンスデフォルトのダークテーマ" instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ"
instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。"
mutePeriod: "ミュートする期限" mutePeriod: "ミュートする期限"
period: "期限"
indefinitely: "無期限" indefinitely: "無期限"
tenMinutes: "10分" tenMinutes: "10分"
oneHour: "1時間" oneHour: "1時間"
oneDay: "1日" oneDay: "1日"
oneWeek: "1週間" oneWeek: "1週間"
oneMonth: "1ヶ月"
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
rateLimitExceeded: "レート制限を超えました" rateLimitExceeded: "レート制限を超えました"
@ -895,7 +898,7 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある
cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。"
beta: "ベータ" beta: "ベータ"
enableAutoSensitive: "自動NSFW判定" enableAutoSensitive: "自動NSFW判定"
enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、インスタンスによっては自動で設定されることがあります。" enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。"
activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。"
navbar: "ナビゲーションバー" navbar: "ナビゲーションバー"
shuffle: "シャッフル" shuffle: "シャッフル"
@ -905,7 +908,7 @@ pushNotification: "プッシュ通知"
subscribePushNotification: "プッシュ通知を有効化" subscribePushNotification: "プッシュ通知を有効化"
unsubscribePushNotification: "プッシュ通知を停止する" unsubscribePushNotification: "プッシュ通知を停止する"
pushNotificationAlreadySubscribed: "プッシュ通知は有効です" pushNotificationAlreadySubscribed: "プッシュ通知は有効です"
pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に非対応" pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応"
sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。"
windowMaximize: "最大化" windowMaximize: "最大化"
@ -951,6 +954,7 @@ joinThisServer: "このサーバーに登録する"
exploreOtherServers: "他のサーバーを探す" exploreOtherServers: "他のサーバーを探す"
letsLookAtTimeline: "タイムラインを見てみる" letsLookAtTimeline: "タイムラインを見てみる"
disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。"
invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"
@ -1142,7 +1146,7 @@ _achievements:
description: "ホームタイムラインの流速が20npmを越す" description: "ホームタイムラインの流速が20npmを越す"
_viewInstanceChart: _viewInstanceChart:
title: "アナリスト" title: "アナリスト"
description: "インスタンスのチャートを表示した" description: "サーバーのチャートを表示した"
_outputHelloWorldOnScratchpad: _outputHelloWorldOnScratchpad:
title: "Hello, world!" title: "Hello, world!"
description: "スクラッチパッドで hello world を出力した" description: "スクラッチパッドで hello world を出力した"
@ -1179,7 +1183,7 @@ _achievements:
_loggedInOnNewYearsDay: _loggedInOnNewYearsDay:
title: "あけましておめでとうございます" title: "あけましておめでとうございます"
description: "元日にログインした" description: "元日にログインした"
flavor: "今年も弊インスタンスをよろしくお願いします" flavor: "今年も弊サーバーをよろしくお願いします"
_cookieClicked: _cookieClicked:
title: "クッキーをクリックするゲーム" title: "クッキーをクリックするゲーム"
description: "クッキーをクリックした" description: "クッキーをクリックした"
@ -1195,7 +1199,7 @@ _role:
name: "ロール名" name: "ロール名"
description: "ロールの説明" description: "ロールの説明"
permission: "ロールの権限" permission: "ロールの権限"
descriptionOfPermission: "<b>モデレーター</b>は基本的なモデレーションに関する操作を行えます。\n<b>管理者</b>はインスタンスの全ての設定を変更できます。" descriptionOfPermission: "<b>モデレーター</b>は基本的なモデレーションに関する操作を行えます。\n<b>管理者</b>はサーバーの全ての設定を変更できます。"
assignTarget: "アサイン" assignTarget: "アサイン"
descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。" descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。"
manual: "マニュアル" manual: "マニュアル"
@ -1223,7 +1227,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧" gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可" canPublicNote: "パブリック投稿の許可"
canInvite: "インスタンス招待コードの発行" canInvite: "サーバー招待コードの発行"
canManageCustomEmojis: "カスタム絵文字の管理" canManageCustomEmojis: "カスタム絵文字の管理"
driveCapacity: "ドライブ容量" driveCapacity: "ドライブ容量"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"
@ -1292,7 +1296,7 @@ _ad:
_forgotPassword: _forgotPassword:
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"
ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。" ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。"
contactAdmin: "このインスタンスではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" contactAdmin: "このサーバーではメールがサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。"
_gallery: _gallery:
my: "自分の投稿" my: "自分の投稿"
@ -1390,10 +1394,10 @@ _wordMute:
mutedNotes: "ミュートされたノート" mutedNotes: "ミュートされたノート"
_instanceMute: _instanceMute:
instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのートとRenoteをミュートします。" instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全てのートとRenoteをミュートします。"
instanceMuteDescription2: "改行で区切って設定します" instanceMuteDescription2: "改行で区切って設定します"
title: "設定したインスタンスのノートを隠します。" title: "設定したサーバーのノートを隠します。"
heading: "ミュートするインスタンス" heading: "ミュートするサーバー"
_theme: _theme:
explore: "テーマを探す" explore: "テーマを探す"
@ -1613,7 +1617,7 @@ _weekday:
_widgets: _widgets:
profile: "プロフィール" profile: "プロフィール"
instanceInfo: "インスタンス情報" instanceInfo: "サーバー情報"
memo: "付箋" memo: "付箋"
notifications: "通知" notifications: "通知"
timeline: "タイムライン" timeline: "タイムライン"
@ -1627,7 +1631,7 @@ _widgets:
digitalClock: "デジタル時計" digitalClock: "デジタル時計"
unixClock: "UNIX時計" unixClock: "UNIX時計"
federation: "連合" federation: "連合"
instanceCloud: "インスタンスクラウド" instanceCloud: "サーバークラウド"
postForm: "投稿フォーム" postForm: "投稿フォーム"
slideshow: "スライドショー" slideshow: "スライドショー"
button: "ボタン" button: "ボタン"
@ -1681,7 +1685,7 @@ _visibility:
specified: "ダイレクト" specified: "ダイレクト"
specifiedDescription: "指定したユーザーのみに公開" specifiedDescription: "指定したユーザーのみに公開"
disableFederation: "連合なし" disableFederation: "連合なし"
disableFederationDescription: "他インスタンスへの配信を行いません" disableFederationDescription: "他サーバーへの配信を行いません"
_postForm: _postForm:
replyPlaceholder: "このノートに返信..." replyPlaceholder: "このノートに返信..."

View file

@ -457,6 +457,7 @@ aboutX: "{x}について"
emojiStyle: "絵文字のスタイル" emojiStyle: "絵文字のスタイル"
native: "ネイティブ" native: "ネイティブ"
disableDrawer: "メニューをドロワーで表示せぇへん" disableDrawer: "メニューをドロワーで表示せぇへん"
showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで"
noHistory: "履歴はあらへんねぇ。" noHistory: "履歴はあらへんねぇ。"
signinHistory: "ログイン履歴" signinHistory: "ログイン履歴"
enableAdvancedMfm: "ややこしいMFMもありにする" enableAdvancedMfm: "ややこしいMFMもありにする"
@ -847,11 +848,13 @@ instanceDefaultLightTheme: "インスタンスの最初の明るいテーマ"
instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ" instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ"
instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。"
mutePeriod: "ミュートする期間" mutePeriod: "ミュートする期間"
period: "期限"
indefinitely: "無期限" indefinitely: "無期限"
tenMinutes: "10分" tenMinutes: "10分"
oneHour: "1時間" oneHour: "1時間"
oneDay: "1日" oneDay: "1日"
oneWeek: "1週間" oneWeek: "1週間"
oneMonth: "1ヶ月"
reflectMayTakeTime: "反映されるまで時間がかかることがあるで" reflectMayTakeTime: "反映されるまで時間がかかることがあるで"
failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…"
rateLimitExceeded: "レート制限が超えたみたいやで" rateLimitExceeded: "レート制限が超えたみたいやで"
@ -951,6 +954,7 @@ joinThisServer: "このサーバーに登録するわ"
exploreOtherServers: "他のサーバー見てみる" exploreOtherServers: "他のサーバー見てみる"
letsLookAtTimeline: "タイムライン見てみーや" letsLookAtTimeline: "タイムライン見てみーや"
disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。"
invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:

View file

@ -393,13 +393,19 @@ about: "정보"
aboutMisskey: "Misskey에 대하여" aboutMisskey: "Misskey에 대하여"
administrator: "관리자" administrator: "관리자"
token: "토큰" token: "토큰"
2fa: "2단계 인증"
totp: "인증 앱"
totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력"
moderator: "모더레이터" moderator: "모더레이터"
moderation: "모더레이션" moderation: "모더레이션"
nUsersMentioned: "{n}명이 언급함" nUsersMentioned: "{n}명이 언급함"
securityKeyAndPasskey: "보안 키 또는 패스 키"
securityKey: "보안 키" securityKey: "보안 키"
lastUsed: "마지막 사용" lastUsed: "마지막 사용"
lastUsedAt: "마지막 사용: {t}"
unregister: "등록 해제" unregister: "등록 해제"
passwordLessLogin: "비밀번호 없이 로그인" passwordLessLogin: "비밀번호 없이 로그인"
passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인합니다."
resetPassword: "비밀번호 재설정" resetPassword: "비밀번호 재설정"
newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다" newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다"
reduceUiAnimation: "UI의 애니메이션을 줄이기" reduceUiAnimation: "UI의 애니메이션을 줄이기"
@ -773,6 +779,7 @@ popularPosts: "인기 포스트"
shareWithNote: "노트로 공유" shareWithNote: "노트로 공유"
ads: "광고" ads: "광고"
expiration: "기한" expiration: "기한"
startingperiod: "시작 기간"
memo: "메모" memo: "메모"
priority: "우선순위" priority: "우선순위"
high: "높음" high: "높음"
@ -805,6 +812,7 @@ lastCommunication: "마지막 통신"
resolved: "해결됨" resolved: "해결됨"
unresolved: "해결되지 않음" unresolved: "해결되지 않음"
breakFollow: "팔로워 해제" breakFollow: "팔로워 해제"
breakFollowConfirm: "팔로우를 해제하시겠습니까?"
itsOn: "켜짐" itsOn: "켜짐"
itsOff: "꺼짐" itsOff: "꺼짐"
emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기" emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기"
@ -839,11 +847,13 @@ instanceDefaultLightTheme: "인스턴스 기본 라이트 테마"
instanceDefaultDarkTheme: "인스턴스 기본 다크 테마" instanceDefaultDarkTheme: "인스턴스 기본 다크 테마"
instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요." instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요."
mutePeriod: "뮤트할 기간" mutePeriod: "뮤트할 기간"
period: "투표 기한"
indefinitely: "무기한" indefinitely: "무기한"
tenMinutes: "10분" tenMinutes: "10분"
oneHour: "1시간" oneHour: "1시간"
oneDay: "1일" oneDay: "1일"
oneWeek: "일주일" oneWeek: "일주일"
oneMonth: "1개월"
reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있습니다." reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있습니다."
failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다" failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다"
rateLimitExceeded: "요청 제한 횟수를 초과하였습니다" rateLimitExceeded: "요청 제한 횟수를 초과하였습니다"
@ -939,6 +949,11 @@ collapseRenotes: "이미 본 리노트를 간략화하기"
internalServerError: "내부 서버 오류" internalServerError: "내부 서버 오류"
internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다."
copyErrorInfo: "오류 정보 복사" copyErrorInfo: "오류 정보 복사"
joinThisServer: "이 서버에 가입"
exploreOtherServers: "다른 서버 둘러보기"
letsLookAtTimeline: "타임라인 구경하기"
disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다."
invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대번호가 필요합니다."
_achievements: _achievements:
earnedAt: "달성 일시" earnedAt: "달성 일시"
_types: _types:
@ -1452,7 +1467,7 @@ _ago:
weeksAgo: "{n}주 전" weeksAgo: "{n}주 전"
monthsAgo: "{n}개월 전" monthsAgo: "{n}개월 전"
yearsAgo: "{n}년 전" yearsAgo: "{n}년 전"
invalid: "아무것도 없습니다" invalid: "없음"
_time: _time:
second: "초" second: "초"
minute: "분" minute: "분"
@ -1486,14 +1501,29 @@ _tutorial:
step8_3: "알림 설정은 나중에도 변경할 수 있습니다." step8_3: "알림 설정은 나중에도 변경할 수 있습니다."
_2fa: _2fa:
alreadyRegistered: "이미 설정이 완료되었습니다." alreadyRegistered: "이미 설정이 완료되었습니다."
registerTOTP: "인증 앱 설정 시작"
passwordToTOTP: "비밀번호를 입력하세요."
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다." step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다." step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있습니다."
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:" step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"
step3Title: "인증 코드 입력"
step3: "앱에 표시된 토큰을 입력하시면 완료됩니다." step3: "앱에 표시된 토큰을 입력하시면 완료됩니다."
step4: "다음 로그인부터는 토큰을 입력해야 합니다." step4: "다음 로그인부터는 토큰을 입력해야 합니다."
securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않습니다."
registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록하십시오."
securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있습니다." securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있습니다."
chromePasskeyNotSupported: "현재 Chrome의 패스키는 지원되지 않습니다."
registerSecurityKey: "보안 키 또는 패스키 등록"
securityKeyName: "키 이름 입력"
tapSecurityKey: "브라우저의 지시에 따라 보안 키 또는 패스키를 등록하여 주십시오"
removeKey: "보안 키를 삭제"
removeKeyConfirm: "{name} 을(를) 삭제하시겠습니까?" removeKeyConfirm: "{name} 을(를) 삭제하시겠습니까?"
renewTOTPCancel: "나중에" whyTOTPOnlyRenew: "보안 키가 등록되어 있는 경우 인증 앱을 해제할 수 없습니다."
renewTOTP: "인증 앱 재설정"
renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 사용하지 못하게 됩니다."
renewTOTPOk: "재설정"
renewTOTPCancel: "취소"
_permissions: _permissions:
"read:account": "계정의 정보를 봅니다" "read:account": "계정의 정보를 봅니다"
"write:account": "계정의 정보를 변경합니다" "write:account": "계정의 정보를 변경합니다"
@ -1616,6 +1646,8 @@ _visibility:
followersDescription: "팔로워에게만 공개" followersDescription: "팔로워에게만 공개"
specified: "다이렉트" specified: "다이렉트"
specifiedDescription: "지정한 유저에게만 공개" specifiedDescription: "지정한 유저에게만 공개"
disableFederation: "연합에 보내지 않기"
disableFederationDescription: "다른 인스턴스로 보내지 않습니다"
_postForm: _postForm:
replyPlaceholder: "이 노트에 답글..." replyPlaceholder: "이 노트에 답글..."
quotePlaceholder: "이 노트를 인용..." quotePlaceholder: "이 노트를 인용..."
@ -1771,6 +1803,7 @@ _notification:
pollEnded: "투표가 종료됨" pollEnded: "투표가 종료됨"
receiveFollowRequest: "팔로우 요청을 받았을 때" receiveFollowRequest: "팔로우 요청을 받았을 때"
followRequestAccepted: "팔로우 요청이 승인되었을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때"
achievementEarned: "도전 과제 획득"
app: "연동된 앱을 통한 알림" app: "연동된 앱을 통한 알림"
_actions: _actions:
followBack: "팔로우" followBack: "팔로우"
@ -1803,3 +1836,6 @@ _deck:
channel: "채널" channel: "채널"
mentions: "받은 멘션" mentions: "받은 멘션"
direct: "다이렉트" direct: "다이렉트"
_dialog:
charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}"
charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}"

View file

@ -810,6 +810,7 @@ auto: "Automatycznie"
size: "Rozmiar" size: "Rozmiar"
numberOfColumn: "Liczba kolumn" numberOfColumn: "Liczba kolumn"
searchByGoogle: "Szukaj" searchByGoogle: "Szukaj"
period: "Ankieta kończy się"
indefinitely: "Nigdy" indefinitely: "Nigdy"
tenMinutes: "10 minut" tenMinutes: "10 minut"
oneHour: "1 godzina" oneHour: "1 godzina"

View file

@ -393,13 +393,19 @@ about: "Описание"
aboutMisskey: "О Misskey" aboutMisskey: "О Misskey"
administrator: "Администратор" administrator: "Администратор"
token: "Токен" token: "Токен"
2fa: "2-х факторная аутентификация"
totp: "Приложение-аутентификатор"
totpDescription: "Описание приложения-аутентификатора"
moderator: "Модератор" moderator: "Модератор"
moderation: "Модерация" moderation: "Модерация"
nUsersMentioned: "Упомянуло пользователей: {n}" nUsersMentioned: "Упомянуло пользователей: {n}"
securityKeyAndPasskey: "Ключ безопасности и парольная фраза"
securityKey: "Ключ безопасности" securityKey: "Ключ безопасности"
lastUsed: "Последнее использование" lastUsed: "Последнее использование"
lastUsedAt: "Последнее использование: {t}"
unregister: "Отписаться" unregister: "Отписаться"
passwordLessLogin: "Настроить вход без пароля" passwordLessLogin: "Настроить вход без пароля"
passwordLessLoginDescription: "Вход без пароля"
resetPassword: "Сброс пароля:" resetPassword: "Сброс пароля:"
newPasswordIs: "Новый пароль — «{password}»." newPasswordIs: "Новый пароль — «{password}»."
reduceUiAnimation: "Уменьшить анимацию в пользовательском интерфейсе" reduceUiAnimation: "Уменьшить анимацию в пользовательском интерфейсе"
@ -773,6 +779,7 @@ popularPosts: "Популярные публикации"
shareWithNote: "Поделиться заметкой" shareWithNote: "Поделиться заметкой"
ads: "Реклама" ads: "Реклама"
expiration: "Опрос длится" expiration: "Опрос длится"
startingperiod: "Начальный период"
memo: "Памятка" memo: "Памятка"
priority: "Приоритет" priority: "Приоритет"
high: "Высокий" high: "Высокий"
@ -805,6 +812,7 @@ lastCommunication: "Последнее сообщение"
resolved: "Решено" resolved: "Решено"
unresolved: "Без решения" unresolved: "Без решения"
breakFollow: "Отписка" breakFollow: "Отписка"
breakFollowConfirm: "Удалить из подписок пользователя ?"
itsOn: "Включено" itsOn: "Включено"
itsOff: "Выключено" itsOff: "Выключено"
emailRequiredForSignup: "Для регистрации учётной записи нужен адрес электронной почты" emailRequiredForSignup: "Для регистрации учётной записи нужен адрес электронной почты"
@ -839,6 +847,7 @@ instanceDefaultLightTheme: "Светлая тема по умолчанию"
instanceDefaultDarkTheme: "Темная тема по умолчанию" instanceDefaultDarkTheme: "Темная тема по умолчанию"
instanceDefaultThemeDescription: "Описание темы по умолчанию для инстанса" instanceDefaultThemeDescription: "Описание темы по умолчанию для инстанса"
mutePeriod: "Продолжительность скрытия" mutePeriod: "Продолжительность скрытия"
period: "Опрос длится"
indefinitely: "вечно" indefinitely: "вечно"
tenMinutes: "10 минут" tenMinutes: "10 минут"
oneHour: "1 час" oneHour: "1 час"
@ -939,6 +948,10 @@ collapseRenotes: "Свернуть репосты"
internalServerError: "Внутренняя ошибка сервера" internalServerError: "Внутренняя ошибка сервера"
internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка."
copyErrorInfo: "Скопировать код ошибки" copyErrorInfo: "Скопировать код ошибки"
joinThisServer: "Присоединяйтесь к этому серверу"
exploreOtherServers: "Искать другие сервера"
letsLookAtTimeline: "Давайте посмотрим на ленту"
disableFederationWarn: "Объединение отключено. Если вы отключите это, сообщение не будет приватным. В большинстве случаев вам не нужно включать эту опцию."
_achievements: _achievements:
earnedAt: "Разблокировано в" earnedAt: "Разблокировано в"
_types: _types:
@ -1486,13 +1499,28 @@ _tutorial:
step8_3: "Эту настройку вы всегда сможете поменять" step8_3: "Эту настройку вы всегда сможете поменять"
_2fa: _2fa:
alreadyRegistered: "Двухфакторная аутентификация уже настроена." alreadyRegistered: "Двухфакторная аутентификация уже настроена."
registerTOTP: "Начните настраивать приложение-аутентификатор"
passwordToTOTP: "Пожалуйста, введите свой пароль"
step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}." step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}."
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения." step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."
step2Click: "Нажав на QR-код, вы можете зарегистрироваться с помощью приложения для аутентификации или брелка для ключей, установленного на вашем устройстве."
step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):" step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):"
step3Title: "Введите проверочный код"
step3: "И наконец, введите код, который покажет приложение." step3: "И наконец, введите код, который покажет приложение."
step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом." step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом."
securityKeyNotSupported: "Ваш браузер не поддерживает ключи безопасности."
registerTOTPBeforeKey: "Чтобы зарегистрировать ключ безопасности и пароль, сначала настройте приложение аутентификации."
securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве." securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве."
chromePasskeyNotSupported: "В настоящее время Chrome не поддерживает пароль-ключи."
registerSecurityKey: "Зарегистрируйте ключ безопасности ・Passkey"
securityKeyName: "Введите имя для ключа"
tapSecurityKey: "Пожалуйста, следуйте инструкциям в вашем браузере, чтобы зарегистрировать свой ключ безопасности или пароль"
removeKey: "Удалить ключ безопасности"
removeKeyConfirm: "Удалить резервную копию «{name}»?" removeKeyConfirm: "Удалить резервную копию «{name}»?"
whyTOTPOnlyRenew: "Если ключ безопасности зарегистрирован, вы не сможете отключить приложение аутентификации."
renewTOTP: "Перенастроите приложение аутентификации"
renewTOTPConfirm: "Проверочный код предыдущего приложения для аутентификации больше не будет доступен"
renewTOTPOk: "Настроить"
renewTOTPCancel: "Нет, спасибо" renewTOTPCancel: "Нет, спасибо"
_permissions: _permissions:
"read:account": "Просматривать данные учётной записи" "read:account": "Просматривать данные учётной записи"
@ -1616,6 +1644,8 @@ _visibility:
followersDescription: "Только вашим подписчикам" followersDescription: "Только вашим подписчикам"
specified: "Личное" specified: "Личное"
specifiedDescription: "Тем, кого укажете" specifiedDescription: "Тем, кого укажете"
disableFederation: "Отключить федерацию"
disableFederationDescription: "Не доставляет в другие экземпляры"
_postForm: _postForm:
replyPlaceholder: "Ответ на заметку..." replyPlaceholder: "Ответ на заметку..."
quotePlaceholder: "Пояснение к цитате..." quotePlaceholder: "Пояснение к цитате..."
@ -1771,6 +1801,7 @@ _notification:
pollEnded: "Окончания опросов" pollEnded: "Окончания опросов"
receiveFollowRequest: "Получен запрос на подписку" receiveFollowRequest: "Получен запрос на подписку"
followRequestAccepted: "Запрос на подписку одобрен" followRequestAccepted: "Запрос на подписку одобрен"
achievementEarned: "Получение достижений"
app: "Уведомления из приложений" app: "Уведомления из приложений"
_actions: _actions:
followBack: "отвечает взаимной подпиской" followBack: "отвечает взаимной подпиской"
@ -1803,3 +1834,6 @@ _deck:
channel: "Каналы" channel: "Каналы"
mentions: "Упоминания" mentions: "Упоминания"
direct: "Личное" direct: "Личное"
_dialog:
charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}"
charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}"

View file

@ -103,6 +103,8 @@ renoted: "Preposlané."
cantRenote: "Tento príspevok sa nedá preposlať." cantRenote: "Tento príspevok sa nedá preposlať."
cantReRenote: "Odpoveď nemôže byť odstránená." cantReRenote: "Odpoveď nemôže byť odstránená."
quote: "Citovať" quote: "Citovať"
inChannelRenote: "Preposlania v kanáli"
inChannelQuote: "Citácie v kanáli"
pinnedNote: "Pripnuté poznámky" pinnedNote: "Pripnuté poznámky"
pinned: "Pripnúť" pinned: "Pripnúť"
you: "Vy" you: "Vy"
@ -129,6 +131,7 @@ unblockConfirm: "Naozaj chcete odblokovať tento účet?"
suspendConfirm: "Naozaj chcete zmraziť tento účet?" suspendConfirm: "Naozaj chcete zmraziť tento účet?"
unsuspendConfirm: "Naozaj chcete odmraziť tento účet?" unsuspendConfirm: "Naozaj chcete odmraziť tento účet?"
selectList: "Vyberte zoznam" selectList: "Vyberte zoznam"
selectChannel: "Zvoľte kanál"
selectAntenna: "Vyberte anténu" selectAntenna: "Vyberte anténu"
selectWidget: "Vyberte widget" selectWidget: "Vyberte widget"
editWidgets: "Upraviť widget" editWidgets: "Upraviť widget"
@ -256,6 +259,8 @@ noMoreHistory: "To je všetko"
startMessaging: "Začať chat" startMessaging: "Začať chat"
nUsersRead: "prečítané {n} používateľmi" nUsersRead: "prečítané {n} používateľmi"
agreeTo: "Súhlasím s {0}" agreeTo: "Súhlasím s {0}"
agreeBelow: "Súhlasím s nasledovným"
basicNotesBeforeCreateAccount: "Základné bezpečnostné opatrenia"
tos: "Podmienky používania" tos: "Podmienky používania"
start: "Začať" start: "Začať"
home: "Domov" home: "Domov"
@ -388,13 +393,19 @@ about: "Informácie"
aboutMisskey: "O Misskey" aboutMisskey: "O Misskey"
administrator: "Administrátor" administrator: "Administrátor"
token: "Token" token: "Token"
2fa: "Dvojfaktorové overenie (2FA)"
totp: "Overovacia aplikácia"
totpDescription: "Zadajte jednorazové heslo z overovacej aplikácie"
moderator: "Moderátor" moderator: "Moderátor"
moderation: "Moderovanie" moderation: "Moderovanie"
nUsersMentioned: "{n} používateľov spomenulo" nUsersMentioned: "{n} používateľov spomenulo"
securityKeyAndPasskey: "Bezpečnostný kľúč/heslo"
securityKey: "Bezpečnostný kľúč" securityKey: "Bezpečnostný kľúč"
lastUsed: "Naposledy použité" lastUsed: "Naposledy použité"
lastUsedAt: "Naposledy použité: {t}"
unregister: "Odregistrovať" unregister: "Odregistrovať"
passwordLessLogin: "Nastaviť bezheslové prihlásenie" passwordLessLogin: "Nastaviť bezheslové prihlásenie"
passwordLessLoginDescription: "Prihlásenie bez hesla, len bezpečnostným kľúčom alebo prístupovým kľúčom"
resetPassword: "Resetovať heslo" resetPassword: "Resetovať heslo"
newPasswordIs: "Nové heslo je \"{password}\"" newPasswordIs: "Nové heslo je \"{password}\""
reduceUiAnimation: "Menej UI animácií" reduceUiAnimation: "Menej UI animácií"
@ -446,8 +457,11 @@ aboutX: "O {x}"
emojiStyle: "Štýl emoji" emojiStyle: "Štýl emoji"
native: "Natívne" native: "Natívne"
disableDrawer: "Nepoužívať šuflíkové menu" disableDrawer: "Nepoužívať šuflíkové menu"
showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši"
noHistory: "Žiadna história" noHistory: "Žiadna história"
signinHistory: "História prihlásení" signinHistory: "História prihlásení"
enableAdvancedMfm: "Povolenie pokročilého MFM"
enableAnimatedMfm: "Povoliť animované MFM"
doing: "Pracujem..." doing: "Pracujem..."
category: "Kategórie" category: "Kategórie"
tags: "Značky" tags: "Značky"
@ -556,6 +570,7 @@ manage: "Administrácia"
plugins: "Pluginy" plugins: "Pluginy"
preferencesBackups: "Zálohy nastavení" preferencesBackups: "Zálohy nastavení"
deck: "Deck" deck: "Deck"
undeck: "Oddokovať"
useBlurEffectForModal: "Použiť efekt rozmazania na okná" useBlurEffectForModal: "Použiť efekt rozmazania na okná"
useFullReactionPicker: "Použiť plnú veľkosť výberu reakcií" useFullReactionPicker: "Použiť plnú veľkosť výberu reakcií"
width: "Šírka" width: "Šírka"
@ -765,6 +780,7 @@ popularPosts: "Populárne príspevky"
shareWithNote: "Zdieľať s poznámkou" shareWithNote: "Zdieľať s poznámkou"
ads: "Reklamy" ads: "Reklamy"
expiration: "Ukončiť hlasovanie" expiration: "Ukončiť hlasovanie"
startingperiod: "Začiatok"
memo: "Memo" memo: "Memo"
priority: "Priorita" priority: "Priorita"
high: "Vysoká" high: "Vysoká"
@ -831,11 +847,13 @@ instanceDefaultLightTheme: "Predvolená svetlá téma"
instanceDefaultDarkTheme: "Predvolená tmavá téma" instanceDefaultDarkTheme: "Predvolená tmavá téma"
instanceDefaultThemeDescription: "Vložte kód témy v objektovom formáte" instanceDefaultThemeDescription: "Vložte kód témy v objektovom formáte"
mutePeriod: "Trvanie stíšenia" mutePeriod: "Trvanie stíšenia"
period: "Ukončiť hlasovanie"
indefinitely: "Navždy" indefinitely: "Navždy"
tenMinutes: "10 minút" tenMinutes: "10 minút"
oneHour: "1 hodina" oneHour: "1 hodina"
oneDay: "1 deň" oneDay: "1 deň"
oneWeek: "1 týždeň" oneWeek: "1 týždeň"
oneMonth: "1 mesiac"
reflectMayTakeTime: "Zmeny môžu chvíľu trvať kým sa prejavia." reflectMayTakeTime: "Zmeny môžu chvíľu trvať kým sa prejavia."
failedToFetchAccountInformation: "Nepodarilo sa načítať informácie o účte." failedToFetchAccountInformation: "Nepodarilo sa načítať informácie o účte."
rateLimitExceeded: "Prekročený limit rýchlosti" rateLimitExceeded: "Prekročený limit rýchlosti"

View file

@ -457,6 +457,7 @@ aboutX: "เกี่ยวกับ {x}"
emojiStyle: "สไตล์อิโมจิ" emojiStyle: "สไตล์อิโมจิ"
native: "ภาษาแม่" native: "ภาษาแม่"
disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู" disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู"
showNoteActionsOnlyHover: "แสดงการดำเนินการเฉพาะโน้ตเมื่อโฮเวอร์"
noHistory: "ไม่มีรายการ" noHistory: "ไม่มีรายการ"
signinHistory: "ประวัติการเข้าสู่ระบบ" signinHistory: "ประวัติการเข้าสู่ระบบ"
enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง"
@ -847,11 +848,13 @@ instanceDefaultLightTheme: "ธีมสว่างค่าเริ่มต
instanceDefaultDarkTheme: "ธีมมืดค่าเริ่มต้นอินสแตนซ์" instanceDefaultDarkTheme: "ธีมมืดค่าเริ่มต้นอินสแตนซ์"
instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์" instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์"
mutePeriod: "ระยะเวลาปิดเสียง" mutePeriod: "ระยะเวลาปิดเสียง"
period: "สิ้นสุดการสำรวจความคิดเห็น"
indefinitely: "ตลอดไป" indefinitely: "ตลอดไป"
tenMinutes: "10 นาที" tenMinutes: "10 นาที"
oneHour: "1 ชั่วโมง" oneHour: "1 ชั่วโมง"
oneDay: "1 วัน" oneDay: "1 วัน"
oneWeek: "1 สัปดาห์" oneWeek: "1 สัปดาห์"
oneMonth: "หนึ่งเดือน"
reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ" reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ"
failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้" failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้"
rateLimitExceeded: "เกินขีดจำกัดอัตรา" rateLimitExceeded: "เกินขีดจำกัดอัตรา"
@ -951,6 +954,7 @@ joinThisServer: "ลงชื่อสมัครใช้ในอินสแ
exploreOtherServers: "มองหาอินสแตนซ์อื่น" exploreOtherServers: "มองหาอินสแตนซ์อื่น"
letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์"
disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ"
invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ"
_achievements: _achievements:
earnedAt: "ได้รับเมื่อ" earnedAt: "ได้รับเมื่อ"
_types: _types:

View file

@ -831,6 +831,7 @@ searchByGoogle: "Пошук"
instanceDefaultLightTheme: "Світла тема за промовчанням" instanceDefaultLightTheme: "Світла тема за промовчанням"
instanceDefaultDarkTheme: "Темна тема за промовчанням" instanceDefaultDarkTheme: "Темна тема за промовчанням"
mutePeriod: "Тривалість приховування" mutePeriod: "Тривалість приховування"
period: "Опитування закінчується"
indefinitely: "Ніколи" indefinitely: "Ніколи"
tenMinutes: "10 хвилин" tenMinutes: "10 хвилин"
oneHour: "1 година" oneHour: "1 година"

View file

@ -824,6 +824,7 @@ instanceDefaultLightTheme: "Theme máy chủ Sáng-Rộng"
instanceDefaultDarkTheme: "Theme máy chủ Tối-Rộng" instanceDefaultDarkTheme: "Theme máy chủ Tối-Rộng"
instanceDefaultThemeDescription: "Nhập mã theme trong định dạng đối tượng." instanceDefaultThemeDescription: "Nhập mã theme trong định dạng đối tượng."
mutePeriod: "Thời hạn ẩn" mutePeriod: "Thời hạn ẩn"
period: "Thời hạn"
indefinitely: "Vĩnh viễn" indefinitely: "Vĩnh viễn"
tenMinutes: "10 phút" tenMinutes: "10 phút"
oneHour: "1 giờ" oneHour: "1 giờ"

View file

@ -457,6 +457,7 @@ aboutX: "关于 {x}"
emojiStyle: "emoji 的样式" emojiStyle: "emoji 的样式"
native: "原生" native: "原生"
disableDrawer: "不显示抽屉菜单" disableDrawer: "不显示抽屉菜单"
showNoteActionsOnlyHover: "仅在悬停时显示帖子操作"
noHistory: "没有历史记录" noHistory: "没有历史记录"
signinHistory: "登录历史" signinHistory: "登录历史"
enableAdvancedMfm: "启用扩展MFM" enableAdvancedMfm: "启用扩展MFM"
@ -806,7 +807,7 @@ translatedFrom: "从 {x} 翻译"
accountDeletionInProgress: "正在删除账户" accountDeletionInProgress: "正在删除账户"
usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。" usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。"
aiChanMode: "小蓝模式" aiChanMode: "小蓝模式"
keepCw: "保留CW" keepCw: "回复时维持隐藏内容"
pubSub: "Pub/Sub账户" pubSub: "Pub/Sub账户"
lastCommunication: "最近通信" lastCommunication: "最近通信"
resolved: "已解决" resolved: "已解决"
@ -847,11 +848,13 @@ instanceDefaultLightTheme: "实例默认浅色主题"
instanceDefaultDarkTheme: "实例默认深色主题" instanceDefaultDarkTheme: "实例默认深色主题"
instanceDefaultThemeDescription: "以对象格式键入主题代码" instanceDefaultThemeDescription: "以对象格式键入主题代码"
mutePeriod: "屏蔽期限" mutePeriod: "屏蔽期限"
period: "截止时间"
indefinitely: "永久" indefinitely: "永久"
tenMinutes: "10分钟" tenMinutes: "10分钟"
oneHour: "1小时" oneHour: "1小时"
oneDay: "1天" oneDay: "1天"
oneWeek: "1周" oneWeek: "1周"
oneMonth: "1 个月"
reflectMayTakeTime: "可能需要一些时间才能体现出效果。" reflectMayTakeTime: "可能需要一些时间才能体现出效果。"
failedToFetchAccountInformation: "获取账户信息失败" failedToFetchAccountInformation: "获取账户信息失败"
rateLimitExceeded: "已超過速率限制" rateLimitExceeded: "已超過速率限制"
@ -951,6 +954,7 @@ joinThisServer: "在本实例上注册"
exploreOtherServers: "探索其他实例" exploreOtherServers: "探索其他实例"
letsLookAtTimeline: "时间线" letsLookAtTimeline: "时间线"
disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。"
invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:
@ -1123,7 +1127,7 @@ _achievements:
title: "休息一下!" title: "休息一下!"
description: "启动客户端超过30分钟" description: "启动客户端超过30分钟"
_noteDeletedWithin1min: _noteDeletedWithin1min:
title: "无话可说" title: "欲言又止"
description: "发帖后一分钟内就将其删除" description: "发帖后一分钟内就将其删除"
_postedAtLateNight: _postedAtLateNight:
title: "夜猫子" title: "夜猫子"
@ -1430,9 +1434,9 @@ _theme:
infoFg: "信息文本" infoFg: "信息文本"
infoWarnBg: "警告背景" infoWarnBg: "警告背景"
infoWarnFg: "警告文本" infoWarnFg: "警告文本"
cwBg: "CW 按钮背景" cwBg: "隐藏内容按钮背景"
cwFg: "CW 按钮文本" cwFg: "隐藏内容按钮文本"
cwHoverBg: "CW 按钮背景(悬停)" cwHoverBg: "隐藏内容按钮背景(悬停)"
toastBg: "Toast通知背景" toastBg: "Toast通知背景"
toastFg: "Toast通知文本" toastFg: "Toast通知文本"
buttonBg: "按钮背景" buttonBg: "按钮背景"
@ -1660,7 +1664,7 @@ _profile:
name: "昵称" name: "昵称"
username: "用户名" username: "用户名"
description: "个人简介" description: "个人简介"
youCanIncludeHashtags: "你可以在个人简介中包含一#标签。" youCanIncludeHashtags: "你可以在个人简介中包含一#标签。"
metadata: "附加信息" metadata: "附加信息"
metadataEdit: "附加信息编辑" metadataEdit: "附加信息编辑"
metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。" metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。"

View file

@ -847,6 +847,7 @@ instanceDefaultLightTheme: "實例預設的淺色主題"
instanceDefaultDarkTheme: "實例預設的深色主題" instanceDefaultDarkTheme: "實例預設的深色主題"
instanceDefaultThemeDescription: "輸入物件形式的主题代碼" instanceDefaultThemeDescription: "輸入物件形式的主题代碼"
mutePeriod: "靜音的期限" mutePeriod: "靜音的期限"
period: "期限"
indefinitely: "無期限" indefinitely: "無期限"
tenMinutes: "10分鐘" tenMinutes: "10分鐘"
oneHour: "1小時" oneHour: "1小時"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.8.1", "version": "13.9.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -91,7 +91,7 @@ module.exports = {
// See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225 // See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225
// TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can // TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can
// directly import `.ts` files without this hack. // directly import `.ts` files without this hack.
'^(\\.{1,2}/.*)\\.js$': '$1', '^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1',
}, },
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
@ -160,7 +160,7 @@ module.exports = {
testMatch: [ testMatch: [
"<rootDir>/test/unit/**/*.ts", "<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts", "<rootDir>/src/**/*.test.ts",
//"<rootDir>/test/e2e/**/*.ts" "<rootDir>/test/e2e/**/*.ts",
], ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
@ -207,4 +207,13 @@ module.exports = {
// watchman: true, // watchman: true,
extensionsToTreatAsEsm: ['.ts'], extensionsToTreatAsEsm: ['.ts'],
testTimeout: 60000,
// Let Jest kill the test worker whenever it grows too much
// (It seems there's a known memory leak issue in Node.js' vm.Script used by Jest)
// https://github.com/facebook/jest/issues/11956
maxWorkers: 1, // Make it use worker (that can be killed and restarted)
logHeapUsage: true, // To debug when out-of-memory happens on CI
workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
}; };

View file

@ -0,0 +1,13 @@
export class roleAssignmentExpiresAt1677570181236 {
name = 'roleAssignmentExpiresAt1677570181236'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "role_assignment" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`CREATE INDEX "IDX_539b6c08c05067599743bb6389" ON "role_assignment" ("expiresAt") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_539b6c08c05067599743bb6389"`);
await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "expiresAt"`);
}
}

View file

@ -15,8 +15,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"", "eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand --detectOpenHandles", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit",
"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-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest", "test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage" "test-and-coverage": "pnpm jest-and-coverage"
@ -146,7 +146,6 @@
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.4.3", "@jest/globals": "29.4.3",
"@redocly/openapi-core": "1.0.0-beta.123",
"@swc/jest": "0.2.24", "@swc/jest": "0.2.24",
"@types/accepts": "1.3.5", "@types/accepts": "1.3.5",
"@types/archiver": "5.3.1", "@types/archiver": "5.3.1",

View file

@ -1,3 +1,4 @@
import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common'; import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ -57,6 +58,14 @@ export class GlobalModule implements OnApplicationShutdown {
) {} ) {}
async onApplicationShutdown(signal: string): Promise<void> { async onApplicationShutdown(signal: string): Promise<void> {
if (process.env.NODE_ENV === 'test') {
// XXX:
// Shutting down the existing connections causes errors on Jest as
// Misskey has asynchronous postgres/redis connections that are not
// awaited.
// Let's wait for some random time for them to finish.
await setTimeout(5000);
}
await Promise.all([ await Promise.all([
this.db.destroy(), this.db.destroy(),
this.redisClient.disconnect(), this.redisClient.disconnect(),

View file

@ -16,12 +16,14 @@ export async function server() {
app.enableShutdownHooks(); app.enableShutdownHooks();
const serverService = app.get(ServerService); const serverService = app.get(ServerService);
serverService.launch(); await serverService.launch();
app.get(ChartManagementService).start(); app.get(ChartManagementService).start();
app.get(JanitorService).start(); app.get(JanitorService).start();
app.get(QueueStatsService).start(); app.get(QueueStatsService).start();
app.get(ServerStatsService).start(); app.get(ServerStatsService).start();
return app;
} }
export async function jobQueue() { export async function jobQueue() {

View file

@ -171,13 +171,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0); .filter(xs => xs.length > 0);
if (keywords.length > 0) { if (keywords.length > 0) {
if (note.text == null) return false; if (note.text == null && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = keywords.some(and => const matched = keywords.some(and =>
and.every(keyword => and.every(keyword =>
antenna.caseSensitive antenna.caseSensitive
? note.text!.includes(keyword) ? _text.includes(keyword)
: note.text!.toLowerCase().includes(keyword.toLowerCase()), : _text.toLowerCase().includes(keyword.toLowerCase()),
)); ));
if (!matched) return false; if (!matched) return false;
@ -189,13 +191,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0); .filter(xs => xs.length > 0);
if (excludeKeywords.length > 0) { if (excludeKeywords.length > 0) {
if (note.text == null) return false; if (note.text == null && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = excludeKeywords.some(and => const matched = excludeKeywords.some(and =>
and.every(keyword => and.every(keyword =>
antenna.caseSensitive antenna.caseSensitive
? note.text!.includes(keyword) ? _text.includes(keyword)
: note.text!.toLowerCase().includes(keyword.toLowerCase()), : _text.toLowerCase().includes(keyword.toLowerCase()),
)); ));
if (matched) return false; if (matched) return false;

View file

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js'; import type { Notification } from '@/models/entities/Notification.js';
@ -10,7 +11,9 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
export class CreateNotificationService { export class CreateNotificationService implements OnApplicationShutdown {
#shutdownController = new AbortController();
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -40,11 +43,11 @@ export class CreateNotificationService {
if (data.notifierId && (notifieeId === data.notifierId)) { if (data.notifierId && (notifieeId === data.notifierId)) {
return null; return null;
} }
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
const isMuted = profile?.mutingNotificationTypes.includes(type); const isMuted = profile?.mutingNotificationTypes.includes(type);
// Create notification // Create notification
const notification = await this.notificationsRepository.insert({ const notification = await this.notificationsRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
@ -56,18 +59,18 @@ export class CreateNotificationService {
...data, ...data,
} as Partial<Notification>) } as Partial<Notification>)
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.notificationEntityService.pack(notification, {}); const packed = await this.notificationEntityService.pack(notification, {});
// Publish notification event // Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed); this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(async () => { setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
if (fresh == null) return; // 既に削除されているかもしれない if (fresh == null) return; // 既に削除されているかもしれない
if (fresh.isRead) return; if (fresh.isRead) return;
//#region ただしミュートしているユーザーからの通知なら無視 //#region ただしミュートしているユーザーからの通知なら無視
const mutings = await this.mutingsRepository.findBy({ const mutings = await this.mutingsRepository.findBy({
muterId: notifieeId, muterId: notifieeId,
@ -76,14 +79,14 @@ export class CreateNotificationService {
return; return;
} }
//#endregion //#endregion
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
}, 2000); }, () => { /* aborted, ignore it */ });
return notification; return notification;
} }
@ -103,7 +106,7 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/ */
} }
@bindThis @bindThis
private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
/* /*
@ -115,4 +118,8 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/ */
} }
onApplicationShutdown(signal?: string | undefined): void {
this.#shutdownController.abort();
}
} }

View file

@ -1,6 +1,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm'; import { In, DataSource } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js'; import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
@ -137,7 +138,9 @@ type Option = {
}; };
@Injectable() @Injectable()
export class NoteCreateService { export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -313,7 +316,10 @@ export class NoteCreateService {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return note; return note;
} }
@ -756,4 +762,8 @@ export class NoteCreateService {
return mentionedUsers; return mentionedUsers;
} }
onApplicationShutdown(signal?: string | undefined) {
this.#shutdownController.abort();
}
} }

View file

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In, IsNull, Not } from 'typeorm'; import { In, IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
@ -15,7 +16,9 @@ import { AntennaService } from './AntennaService.js';
import { PushNotificationService } from './PushNotificationService.js'; import { PushNotificationService } from './PushNotificationService.js';
@Injectable() @Injectable()
export class NoteReadService { export class NoteReadService implements OnApplicationShutdown {
#shutdownController = new AbortController();
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -60,14 +63,14 @@ export class NoteReadService {
}); });
if (mute.map(m => m.muteeId).includes(note.userId)) return; if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion //#endregion
// スレッドミュート // スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({ const threadMute = await this.noteThreadMutingsRepository.findOneBy({
userId: userId, userId: userId,
threadId: note.threadId ?? note.id, threadId: note.threadId ?? note.id,
}); });
if (threadMute) return; if (threadMute) return;
const unread = { const unread = {
id: this.idService.genId(), id: this.idService.genId(),
noteId: note.id, noteId: note.id,
@ -77,15 +80,15 @@ export class NoteReadService {
noteChannelId: note.channelId, noteChannelId: note.channelId,
noteUserId: note.userId, noteUserId: note.userId,
}; };
await this.noteUnreadsRepository.insert(unread); await this.noteUnreadsRepository.insert(unread);
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => { setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
if (exist == null) return; if (exist == null) return;
if (params.isMentioned) { if (params.isMentioned) {
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
} }
@ -95,8 +98,8 @@ export class NoteReadService {
if (note.channelId) { if (note.channelId) {
this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id);
} }
}, 2000); }, () => { /* aborted, ignore it */ });
} }
@bindThis @bindThis
public async read( public async read(
@ -113,24 +116,24 @@ export class NoteReadService {
}, },
select: ['followeeId'], select: ['followeeId'],
})).map(x => x.followeeId)); })).map(x => x.followeeId));
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const readMentions: (Note | Packed<'Note'>)[] = []; const readMentions: (Note | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
const readChannelNotes: (Note | Packed<'Note'>)[] = []; const readChannelNotes: (Note | Packed<'Note'>)[] = [];
const readAntennaNotes: (Note | Packed<'Note'>)[] = []; const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
for (const note of notes) { for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) { if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note); readMentions.push(note);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note); readSpecifiedNotes.push(note);
} }
if (note.channelId && followingChannels.has(note.channelId)) { if (note.channelId && followingChannels.has(note.channelId)) {
readChannelNotes.push(note); readChannelNotes.push(note);
} }
if (note.user != null) { // たぶんnullになることは無いはずだけど一応 if (note.user != null) { // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) { for (const antenna of myAntennas) {
if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) {
@ -139,14 +142,14 @@ export class NoteReadService {
} }
} }
} }
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
// Remove the record // Remove the record
await this.noteUnreadsRepository.delete({ await this.noteUnreadsRepository.delete({
userId: userId, userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
}); });
// TODO: ↓まとめてクエリしたい // TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({ this.noteUnreadsRepository.countBy({
@ -183,7 +186,7 @@ export class NoteReadService {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
}); });
} }
if (readAntennaNotes.length > 0) { if (readAntennaNotes.length > 0) {
await this.antennaNotesRepository.update({ await this.antennaNotesRepository.update({
antennaId: In(myAntennas.map(a => a.id)), antennaId: In(myAntennas.map(a => a.id)),
@ -191,14 +194,14 @@ export class NoteReadService {
}, { }, {
read: true, read: true,
}); });
// TODO: まとめてクエリしたい // TODO: まとめてクエリしたい
for (const antenna of myAntennas) { for (const antenna of myAntennas) {
const count = await this.antennaNotesRepository.countBy({ const count = await this.antennaNotesRepository.countBy({
antennaId: antenna.id, antennaId: antenna.id,
read: false, read: false,
}); });
if (count === 0) { if (count === 0) {
this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); this.globalEventService.publishMainStream(userId, 'readAntenna', antenna);
this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id });
@ -213,4 +216,8 @@ export class NoteReadService {
}); });
} }
} }
onApplicationShutdown(signal?: string | undefined): void {
this.#shutdownController.abort();
}
} }

View file

@ -11,6 +11,8 @@ import { UserCacheService } from '@/core/UserCacheService.js';
import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = { export type RolePolicies = {
@ -56,6 +58,9 @@ export class RoleService implements OnApplicationShutdown {
private rolesCache: Cache<Role[]>; private rolesCache: Cache<Role[]>;
private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>; private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>;
public static AlreadyAssignedError = class extends Error {};
public static NotAssignedError = class extends Error {};
constructor( constructor(
@Inject(DI.redisSubscriber) @Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis, private redisSubscriber: Redis.Redis,
@ -72,6 +77,8 @@ export class RoleService implements OnApplicationShutdown {
private metaService: MetaService, private metaService: MetaService,
private userCacheService: UserCacheService, private userCacheService: UserCacheService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
private idService: IdService,
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
@ -128,6 +135,7 @@ export class RoleService implements OnApplicationShutdown {
cached.push({ cached.push({
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
}); });
} }
break; break;
@ -193,7 +201,10 @@ export class RoleService implements OnApplicationShutdown {
@bindThis @bindThis
public async getUserRoles(userId: User['id']) { public async getUserRoles(userId: User['id']) {
const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); const now = Date.now();
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId); const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
@ -207,7 +218,10 @@ export class RoleService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async getUserBadgeRoles(userId: User['id']) { public async getUserBadgeRoles(userId: User['id']) {
const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); const now = Date.now();
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId); const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
@ -316,6 +330,65 @@ export class RoleService implements OnApplicationShutdown {
return users; return users;
} }
@bindThis
public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise<void> {
const now = new Date();
const existing = await this.roleAssignmentsRepository.findOneBy({
roleId: roleId,
userId: userId,
});
if (existing) {
if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
await this.roleAssignmentsRepository.delete({
roleId: roleId,
userId: userId,
});
} else {
throw new RoleService.AlreadyAssignedError();
}
}
const created = await this.roleAssignmentsRepository.insert({
id: this.idService.genId(),
createdAt: now,
expiresAt: expiresAt,
roleId: roleId,
userId: userId,
}).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
this.rolesRepository.update(roleId, {
lastUsedAt: new Date(),
});
this.globalEventService.publishInternalEvent('userRoleAssigned', created);
}
@bindThis
public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> {
const now = new Date();
const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
if (existing == null) {
throw new RoleService.NotAssignedError();
} else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
await this.roleAssignmentsRepository.delete({
roleId: roleId,
userId: userId,
});
throw new RoleService.NotAssignedError();
}
await this.roleAssignmentsRepository.delete(existing.id);
this.rolesRepository.update(roleId, {
lastUsedAt: now,
});
this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
}
@bindThis @bindThis
public onApplicationShutdown(signal?: string | undefined) { public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage); this.redisSubscriber.off('message', this.onMessage);

View file

@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks.push({ this.webhooks.push({
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
}); });
} }
break; break;
@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks[i] = { this.webhooks[i] = {
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
}; };
} else { } else {
this.webhooks.push({ this.webhooks.push({
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
}); });
} }
} else { } else {

View file

@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown {
async onApplicationShutdown(signal: string): Promise<void> { async onApplicationShutdown(signal: string): Promise<void> {
clearInterval(this.saveIntervalId); clearInterval(this.saveIntervalId);
await Promise.all( if (process.env.NODE_ENV !== 'test') {
this.charts.map(chart => chart.save()), await Promise.all(
); this.charts.map(chart => chart.save()),
);
}
} }
} }

View file

@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart<typeof schema> {
} }
@bindThis @bindThis
public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise<void> { public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void {
await this.commit({ this.commit({
'total': isAdditional ? 1 : -1, 'total': isAdditional ? 1 : -1,
'inc': isAdditional ? 1 : 0, 'inc': isAdditional ? 1 : 0,
'dec': isAdditional ? 0 : 1, 'dec': isAdditional ? 0 : 1,

View file

@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -21,6 +21,7 @@ type PackOptions = {
}; };
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import { isNotNull } from '@/misc/is-not-null.js';
@Injectable() @Injectable()
export class DriveFileEntityService { export class DriveFileEntityService {
@ -255,10 +256,29 @@ export class DriveFileEntityService {
@bindThis @bindThis
public async packMany( public async packMany(
files: (DriveFile['id'] | DriveFile)[], files: DriveFile[],
options?: PackOptions, options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> { ): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options))); const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null); return items.filter((x): x is Packed<'DriveFile'> => x != null);
} }
@bindThis
public async packManyByIdsMap(
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'>>> {
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
const packedFiles = await this.packMany(files, options);
return new Map(packedFiles.map(f => [f.id, f]));
}
@bindThis
public async packManyByIds(
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const filesMap = await this.packManyByIdsMap(fileIds, options);
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
}
} }

View file

@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title, title: post.title,
description: post.description, description: post.description,
fileIds: post.fileIds, fileIds: post.fileIds,
files: this.driveFileEntityService.packMany(post.fileIds), // TODO: packMany causes N+1 queries
files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined, tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive, isSensitive: post.isSensitive,
likedCount: post.likedCount, likedCount: post.likedCount,

View file

@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js'; import type { ReactionService } from '../ReactionService.js';
@ -257,6 +258,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean; skipHide?: boolean;
_hint_?: { _hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>; myReactions: Map<Note['id'], NoteReaction | null>;
packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'>>;
}; };
}, },
): Promise<Packed<'Note'>> { ): Promise<Packed<'Note'>> {
@ -284,6 +286,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions) const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
const packedFiles = options?._hint_?.packedFiles;
const packed: Packed<'Note'> = await awaitAll({ const packed: Packed<'Note'> = await awaitAll({
id: note.id, id: note.id,
@ -304,7 +307,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds, fileIds: note.fileIds,
files: this.driveFileEntityService.packMany(note.fileIds), files: packedFiles != null ? note.fileIds.map(fi => packedFiles.get(fi)).filter(isNotNull) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId, replyId: note.replyId,
renoteId: note.renoteId, renoteId: note.renoteId,
channelId: note.channelId ?? undefined, channelId: note.channelId ?? undefined,
@ -388,11 +391,14 @@ export class NoteEntityService implements OnModuleInit {
} }
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
const fileIds = notes.flatMap(n => n.fileIds);
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
return await Promise.all(notes.map(n => this.pack(n, me, { return await Promise.all(notes.map(n => this.pack(n, me, {
...options, ...options,
_hint_: { _hint_: {
myReactions: myReactionsMap, myReactions: myReactionsMap,
packedFiles,
}, },
}))); })));
} }

View file

@ -1,19 +1,21 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js'; import type { Notification } from '@/models/entities/Notification.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
import type { Packed } from '@/misc/schema.js'; import type { Packed } from '@/misc/schema.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { notificationTypes } from '@/types.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js';
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
@Injectable() @Injectable()
export class NotificationEntityService implements OnModuleInit { export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService; private userEntityService: UserEntityService;
@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit {
public async pack( public async pack(
src: Notification['id'] | Notification, src: Notification['id'] | Notification,
options: { options: {
_hintForEachNotes_?: { _hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>; packedNotes: Map<Note['id'], Packed<'Note'>>;
}; };
}, },
): Promise<Packed<'Notification'>> { ): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src });
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
options._hint_?.packedNotes != null
? options._hint_.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
})
) : undefined;
return await awaitAll({ return await awaitAll({
id: notification.id, id: notification.id,
@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit {
isRead: notification.isRead, isRead: notification.isRead,
userId: notification.notifierId, userId: notification.notifierId,
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
...(notification.type === 'mention' ? { ...(noteIfNeed != null ? { note: noteIfNeed } : {}),
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'reply' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'renote' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'quote' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'reaction' ? { ...(notification.type === 'reaction' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
reaction: notification.reaction, reaction: notification.reaction,
} : {}), } : {}),
...(notification.type === 'pollEnded' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
} : {}),
...(notification.type === 'achievementEarned' ? { ...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement, achievement: notification.achievement,
} : {}), } : {}),
@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit {
}); });
} }
/**
* @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
*/
@bindThis @bindThis
public async packMany( public async packMany(
notifications: Notification[], notifications: Notification[],
meId: User['id'], meId: User['id'],
) { ) {
if (notifications.length === 0) return []; if (notifications.length === 0) return [];
const notes = notifications.filter(x => x.note != null).map(x => x.note!); for (const notification of notifications) {
const noteIds = notes.map(n => n.id); if (meId !== notification.notifieeId) {
const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); // because we call note packMany with meId, all notifieeId should be same as meId
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
const targets = [...noteIds, ...renoteIds]; }
const myReactions = await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(targets),
});
for (const target of targets) {
myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null);
} }
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); const notes = notifications.map(x => x.note).filter(isNotNull);
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, { return await Promise.all(notifications.map(x => this.pack(x, {
_hintForEachNotes_: { _hint_: {
myReactions: myReactionsMap, packedNotes,
}, },
}))); })));
} }

View file

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
@ -28,9 +29,13 @@ export class RoleEntityService {
) { ) {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
const assigns = await this.roleAssignmentsRepository.findBy({ const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
roleId: role.id, .where('assign.roleId = :roleId', { roleId: role.id })
}); .andWhere(new Brackets(qb => { qb
.where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
}))
.getCount();
const policies = { ...role.policies }; const policies = { ...role.policies };
for (const [k, v] of Object.entries(DEFAULT_POLICIES)) { for (const [k, v] of Object.entries(DEFAULT_POLICIES)) {
@ -57,7 +62,7 @@ export class RoleEntityService {
asBadge: role.asBadge, asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator, canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies, policies: policies,
usersCount: assigns.length, usersCount: assignedCount,
}); });
} }

View file

@ -0,0 +1,5 @@
// we are using {} as "any non-nullish value" as expected
// eslint-disable-next-line @typescript-eslint/ban-types
export function isNotNull<T extends {}>(input: T | undefined | null): input is T {
return input != null;
}

View file

@ -39,4 +39,10 @@ export class RoleAssignment {
}) })
@JoinColumn() @JoinColumn()
public role: Role | null; public role: Role | null;
@Index()
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
} }

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, UserIpsRepository } from '@/models/index.js'; import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -29,6 +29,9 @@ export class CleanProcessorService {
@Inject(DI.antennaNotesRepository) @Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository, private antennaNotesRepository: AntennaNotesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private idService: IdService, private idService: IdService,
) { ) {
@ -56,6 +59,17 @@ export class CleanProcessorService {
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
}); });
const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
.where('assign.expiresAt IS NOT NULL')
.andWhere('assign.expiresAt < :now', { now: new Date() })
.getMany();
if (expiredRoleAssignments.length > 0) {
await this.roleAssignmentsRepository.delete({
id: In(expiredRoleAssignments.map(x => x.id)),
});
}
this.logger.succ('Cleaned.'); this.logger.succ('Cleaned.');
done(); done();
} }

View file

@ -226,7 +226,10 @@ export class FileServerService {
return; return;
} }
if (this.config.externalMediaProxyEnabled) { // アバタークロップなど、どうしてもオリジンである必要がある場合
const mustOrigin = 'origin' in request.query;
if (this.config.externalMediaProxyEnabled && !mustOrigin) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト // 外部のメディアプロキシが有効なら、そちらにリダイレクト
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days reply.header('Cache-Control', 'public, max-age=259200'); // 3 days

View file

@ -1,7 +1,7 @@
import cluster from 'node:cluster'; import cluster from 'node:cluster';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Fastify from 'fastify'; import Fastify, { FastifyInstance } from 'fastify';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -23,8 +23,9 @@ import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
@Injectable() @Injectable()
export class ServerService { export class ServerService implements OnApplicationShutdown {
private logger: Logger; private logger: Logger;
#fastify: FastifyInstance;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -54,11 +55,12 @@ export class ServerService {
} }
@bindThis @bindThis
public launch() { public async launch() {
const fastify = Fastify({ const fastify = Fastify({
trustProxy: true, trustProxy: true,
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
}); });
this.#fastify = fastify;
// HSTS // HSTS
// 6months (15552000sec) // 6months (15552000sec)
@ -75,7 +77,7 @@ export class ServerService {
fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.nodeinfoServerService.createServer);
fastify.register(this.wellKnownServerService.createServer); fastify.register(this.wellKnownServerService.createServer);
fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path; const path = request.params.path;
reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Cache-Control', 'public, max-age=86400');
@ -105,11 +107,19 @@ export class ServerService {
} }
} }
const url = new URL(`${this.config.mediaProxy}/emoji.webp`); let url: URL;
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) if ('badge' in request.query) {
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url = new URL(`${this.config.mediaProxy}/emoji.png`);
url.searchParams.set('emoji', '1'); // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
if ('static' in request.query) url.searchParams.set('static', '1'); url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
url.searchParams.set('badge', '1');
} else {
url = new URL(`${this.config.mediaProxy}/emoji.webp`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
url.searchParams.set('emoji', '1');
if ('static' in request.query) url.searchParams.set('static', '1');
}
return await reply.redirect( return await reply.redirect(
301, 301,
@ -195,5 +205,11 @@ export class ServerService {
}); });
fastify.listen({ port: this.config.port, host: '0.0.0.0' }); fastify.listen({ port: this.config.port, host: '0.0.0.0' });
await fastify.ready();
}
async onApplicationShutdown(signal: string): Promise<void> {
await this.#fastify.close();
} }
} }

View file

@ -100,9 +100,12 @@ export class ApiCallService implements OnApplicationShutdown {
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {
const multipartData = await request.file(); const multipartData = await request.file().catch(() => {
/* Fastify throws if the remote didn't send multipart data. Return 400 below. */
});
if (multipartData == null) { if (multipartData == null) {
reply.code(400); reply.code(400);
reply.send();
return; return;
} }

View file

@ -73,28 +73,32 @@ export class ApiServerService {
Params: { endpoint: string; }, Params: { endpoint: string; },
Body: Record<string, unknown>, Body: Record<string, unknown>,
Querystring: Record<string, unknown>, Querystring: Record<string, unknown>,
}>('/' + endpoint.name, (request, reply) => { }>('/' + endpoint.name, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) { if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405); reply.code(405);
reply.send(); reply.send();
return; return;
} }
this.apiCallService.handleMultipartRequest(ep, request, reply); // Await so that any error can automatically be translated to HTTP 500
await this.apiCallService.handleMultipartRequest(ep, request, reply);
return reply;
}); });
} else { } else {
fastify.all<{ fastify.all<{
Params: { endpoint: string; }, Params: { endpoint: string; },
Body: Record<string, unknown>, Body: Record<string, unknown>,
Querystring: Record<string, unknown>, Querystring: Record<string, unknown>,
}>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, (request, reply) => { }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) { if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405); reply.code(405);
reply.send(); reply.send();
return; return;
} }
this.apiCallService.handleRequest(ep, request, reply); // Await so that any error can automatically be translated to HTTP 500
await this.apiCallService.handleRequest(ep, request, reply);
return reply;
}); });
} }
} }
@ -160,6 +164,22 @@ export class ApiServerService {
} }
}); });
// Make sure any unknown path under /api returns HTTP 404 Not Found,
// because otherwise ClientServerService will return the base client HTML
// page with HTTP 200.
fastify.get('*', (request, reply) => {
reply.code(404);
// Mock ApiCallService.send's error handling
reply.send({
error: {
message: 'Unknown API endpoint.',
code: 'UNKNOWN_API_ENDPOINT',
id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1',
kind: 'client',
},
});
});
done(); done();
} }
} }

View file

@ -741,8 +741,8 @@ export interface IEndpoint {
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return { return {
name: name, name: name,
meta: ep.meta ?? {}, get meta() { return ep.meta ?? {}; },
params: ep.paramDef, get params() { return ep.paramDef; },
}; };
}); });

View file

@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
export const meta = { export const meta = {
@ -39,6 +37,10 @@ export const paramDef = {
properties: { properties: {
roleId: { type: 'string', format: 'misskey:id' }, roleId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
expiresAt: {
type: 'integer',
nullable: true,
},
}, },
required: [ required: [
'roleId', 'roleId',
@ -56,12 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository) @Inject(DI.rolesRepository)
private rolesRepository: RolesRepository, private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private globalEventService: GlobalEventService,
private roleService: RoleService, private roleService: RoleService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@ -78,19 +75,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
const date = new Date(); if (ps.expiresAt && ps.expiresAt <= Date.now()) {
const created = await this.roleAssignmentsRepository.insert({ return;
id: this.idService.genId(), }
createdAt: date,
roleId: role.id,
userId: user.id,
}).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
this.rolesRepository.update(ps.roleId, { await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null);
lastUsedAt: new Date(),
});
this.globalEventService.publishInternalEvent('userRoleAssigned', created);
}); });
} }
} }

View file

@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
export const meta = { export const meta = {
@ -62,12 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository) @Inject(DI.rolesRepository)
private rolesRepository: RolesRepository, private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private globalEventService: GlobalEventService,
private roleService: RoleService, private roleService: RoleService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@ -84,18 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id }); await this.roleService.unassign(user.id, role.id);
if (roleAssignment == null) {
throw new ApiError(meta.errors.notAssigned);
}
await this.roleAssignmentsRepository.delete(roleAssignment.id);
this.rolesRepository.update(ps.roleId, {
lastUsedAt: new Date(),
});
this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment);
}); });
} }
} }

View file

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb
.where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
}))
.innerJoinAndSelect('assign.user', 'user'); .innerJoinAndSelect('assign.user', 'user');
const assigns = await query const assigns = await query
@ -64,7 +69,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
return await Promise.all(assigns.map(async assign => ({ return await Promise.all(assigns.map(async assign => ({
id: assign.id, id: assign.id,
createdAt: assign.createdAt,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }), user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
expiresAt: assign.expiresAt,
}))); })));
}); });
} }

View file

@ -82,6 +82,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion //#endregion
const timeline = await query.take(ps.limit).getMany(); const timeline = await query.take(ps.limit).getMany();

View file

@ -73,8 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
if (ps.email != null) { if (ps.email != null) {
const available = await this.emailService.validateEmailForAccount(ps.email); const res = await this.emailService.validateEmailForAccount(ps.email);
if (!available) { if (!res.available) {
throw new ApiError(meta.errors.unavailable); throw new ApiError(meta.errors.unavailable);
} }
} }

View file

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere('assign.roleId = :roleId', { roleId: role.id })
.andWhere(new Brackets(qb => { qb
.where('assign.expiresAt IS NULL')
.orWhere('assign.expiresAt > :now', { now: new Date() });
}))
.innerJoinAndSelect('assign.user', 'user'); .innerJoinAndSelect('assign.user', 'user');
const assigns = await query const assigns = await query

View file

@ -178,7 +178,14 @@ type EventUnionFromDictionary<
// redis通すとDateのインスタンスはstringに変換されるので // redis通すとDateのインスタンスはstringに変換されるので
type Serialized<T> = { type Serialized<T> = {
[K in keyof T]: T[K] extends Date ? string : T[K] extends Record<string, any> ? Serialized<T[K]> : T[K]; [K in keyof T]:
T[K] extends Date
? string
: T[K] extends (Date | null)
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
}; };
type SerializedAll<T> = { type SerializedAll<T> = {

View file

@ -1,18 +1,18 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, post, startServer } from '../utils.js';
import { signup, request, post, startServer, shutdownServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('API visibility', () => { describe('API visibility', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
beforeAll(async () => { beforeAll(async () => {
p = await startServer(); p = await startServer();
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
describe('Note visibility', () => { describe('Note visibility', () => {
@ -60,7 +60,7 @@ describe('API visibility', () => {
//#endregion //#endregion
const show = async (noteId: any, by: any) => { const show = async (noteId: any, by: any) => {
return await request('/notes/show', { return await api('/notes/show', {
noteId, noteId,
}, by); }, by);
}; };
@ -75,7 +75,7 @@ describe('API visibility', () => {
target2 = await signup({ username: 'target2' }); target2 = await signup({ username: 'target2' });
// follow alice <= follower // follow alice <= follower
await request('/following/create', { userId: alice.id }, follower); await api('/following/create', { userId: alice.id }, follower);
// normal posts // normal posts
pub = await post(alice, { text: 'x', visibility: 'public' }); pub = await post(alice, { text: 'x', visibility: 'public' });
@ -413,21 +413,21 @@ describe('API visibility', () => {
//#region HTL //#region HTL
test('[HTL] public-post が 自分が見れる', async () => { test('[HTL] public-post が 自分が見れる', async () => {
const res = await request('/notes/timeline', { limit: 100 }, alice); const res = await api('/notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id); const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[HTL] public-post が 非フォロワーから見れない', async () => { test('[HTL] public-post が 非フォロワーから見れない', async () => {
const res = await request('/notes/timeline', { limit: 100 }, other); const res = await api('/notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id); const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes.length, 0); assert.strictEqual(notes.length, 0);
}); });
test('[HTL] followers-post が フォロワーから見れる', async () => { test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await request('/notes/timeline', { limit: 100 }, follower); const res = await api('/notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === fol.id); const notes = res.body.filter((n: any) => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
@ -436,21 +436,21 @@ describe('API visibility', () => {
//#region RTL //#region RTL
test('[replies] followers-reply が フォロワーから見れる', async () => { test('[replies] followers-reply が フォロワーから見れる', async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes.length, 0); assert.strictEqual(notes.length, 0);
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
@ -459,14 +459,14 @@ describe('API visibility', () => {
//#region MTL //#region MTL
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await request('/notes/mentions', { limit: 100 }, target); const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id); const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
const res = await request('/notes/mentions', { limit: 100 }, target); const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folM.id); const notes = res.body.filter((n: any) => n.id === folM.id);
assert.strictEqual(notes[0].text, '@target x'); assert.strictEqual(notes[0].text, '@target x');
@ -474,4 +474,4 @@ describe('API visibility', () => {
//#endregion //#endregion
}); });
}); });
*/

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, startServer } from '../utils.js';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('API', () => { describe('API', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let bob: any; let bob: any;
let carol: any; let carol: any;
@ -15,69 +15,69 @@ describe('API', () => {
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' }); carol = await signup({ username: 'carol' });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
describe('General validation', () => { describe('General validation', () => {
test('wrong type', async(async () => { test('wrong type', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
string: 42, string: 42,
}); });
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('missing require param', async(async () => { test('missing require param', async () => {
const res = await request('/test', { const res = await api('/test', {
string: 'a', string: 'a',
}); });
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('invalid misskey:id (empty string)', async(async () => { test('invalid misskey:id (empty string)', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
id: '', id: '',
}); });
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('valid misskey:id', async(async () => { test('valid misskey:id', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
id: '8wvhjghbxu', id: '8wvhjghbxu',
}); });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
})); });
test('default value', async(async () => { test('default value', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
string: 'a', string: 'a',
}); });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.default, 'hello'); assert.strictEqual(res.body.default, 'hello');
})); });
test('can set null even if it has default value', async(async () => { test('can set null even if it has default value', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
nullableDefault: null, nullableDefault: null,
}); });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, null); assert.strictEqual(res.body.nullableDefault, null);
})); });
test('cannot set undefined if it has default value', async(async () => { test('cannot set undefined if it has default value', async () => {
const res = await request('/test', { const res = await api('/test', {
required: true, required: true,
nullableDefault: undefined, nullableDefault: undefined,
}); });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, 'hello'); assert.strictEqual(res.body.nullableDefault, 'hello');
})); });
}); });
}); });

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, post, startServer } from '../utils.js';
import { signup, request, post, startServer, shutdownServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('Block', () => { describe('Block', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
// alice blocks bob // alice blocks bob
let alice: any; let alice: any;
@ -17,14 +17,14 @@ describe('Block', () => {
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' }); carol = await signup({ username: 'carol' });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
test('Block作成', async () => { test('Block作成', async () => {
const res = await request('/blocking/create', { const res = await api('/blocking/create', {
userId: bob.id, userId: bob.id,
}, alice); }, alice);
@ -32,7 +32,7 @@ describe('Block', () => {
}); });
test('ブロックされているユーザーをフォローできない', async () => { test('ブロックされているユーザーをフォローできない', async () => {
const res = await request('/following/create', { userId: alice.id }, bob); const res = await api('/following/create', { userId: alice.id }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0');
@ -41,7 +41,7 @@ describe('Block', () => {
test('ブロックされているユーザーにリアクションできない', async () => { test('ブロックされているユーザーにリアクションできない', async () => {
const note = await post(alice, { text: 'hello' }); const note = await post(alice, { text: 'hello' });
const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec');
@ -50,7 +50,7 @@ describe('Block', () => {
test('ブロックされているユーザーに返信できない', async () => { test('ブロックされているユーザーに返信できない', async () => {
const note = await post(alice, { text: 'hello' }); const note = await post(alice, { text: 'hello' });
const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@ -59,7 +59,7 @@ describe('Block', () => {
test('ブロックされているユーザーのートをRenoteできない', async () => { test('ブロックされているユーザーのートをRenoteできない', async () => {
const note = await post(alice, { text: 'hello' }); const note = await post(alice, { text: 'hello' });
const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@ -74,7 +74,7 @@ describe('Block', () => {
const bobNote = await post(bob); const bobNote = await post(bob);
const carolNote = await post(carol); const carolNote = await post(carol);
const res = await request('/notes/local-timeline', {}, bob); const res = await api('/notes/local-timeline', {}, bob);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);

View file

@ -1,29 +1,35 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; // node-fetch only supports it's own Blob yet
import * as openapi from '@redocly/openapi-core'; // https://github.com/node-fetch/node-fetch/pull/1664
import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js'; import { Blob } from 'node-fetch';
import { startServer, signup, post, api, uploadFile } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
describe('Endpoints', () => { describe('Endpoints', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let bob: any; let bob: any;
let carol: any;
let dave: any;
beforeAll(async () => { beforeAll(async () => {
p = await startServer(); p = await startServer();
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
}, 1000 * 30); carol = await signup({ username: 'carol' });
dave = await signup({ username: 'dave' });
}, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
describe('signup', () => { describe('signup', () => {
test('不正なユーザー名でアカウントが作成できない', async () => { test('不正なユーザー名でアカウントが作成できない', async () => {
const res = await request('api/signup', { const res = await api('signup', {
username: 'test.', username: 'test.',
password: 'test', password: 'test',
}); });
@ -31,7 +37,7 @@ describe('Endpoints', () => {
}); });
test('空のパスワードでアカウントが作成できない', async () => { test('空のパスワードでアカウントが作成できない', async () => {
const res = await request('api/signup', { const res = await api('signup', {
username: 'test', username: 'test',
password: '', password: '',
}); });
@ -44,7 +50,7 @@ describe('Endpoints', () => {
password: 'test1', password: 'test1',
}; };
const res = await request('api/signup', me); const res = await api('signup', me);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -52,7 +58,7 @@ describe('Endpoints', () => {
}); });
test('同じユーザー名のアカウントは作成できない', async () => { test('同じユーザー名のアカウントは作成できない', async () => {
const res = await request('api/signup', { const res = await api('signup', {
username: 'test1', username: 'test1',
password: 'test1', password: 'test1',
}); });
@ -63,7 +69,7 @@ describe('Endpoints', () => {
describe('signin', () => { describe('signin', () => {
test('間違ったパスワードでサインインできない', async () => { test('間違ったパスワードでサインインできない', async () => {
const res = await request('api/signin', { const res = await api('signin', {
username: 'test1', username: 'test1',
password: 'bar', password: 'bar',
}); });
@ -72,7 +78,7 @@ describe('Endpoints', () => {
}); });
test('クエリをインジェクションできない', async () => { test('クエリをインジェクションできない', async () => {
const res = await request('api/signin', { const res = await api('signin', {
username: 'test1', username: 'test1',
password: { password: {
$gt: '', $gt: '',
@ -83,7 +89,7 @@ describe('Endpoints', () => {
}); });
test('正しい情報でサインインできる', async () => { test('正しい情報でサインインできる', async () => {
const res = await request('api/signin', { const res = await api('signin', {
username: 'test1', username: 'test1',
password: 'test1', password: 'test1',
}); });
@ -111,11 +117,12 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.birthday, myBirthday); assert.strictEqual(res.body.birthday, myBirthday);
}); });
test('名前を空白にできない', async () => { test('名前を空白にでき', async () => {
const res = await api('/i/update', { const res = await api('/i/update', {
name: ' ', name: ' ',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.name, ' ');
}); });
test('誕生日の設定を削除できる', async () => { test('誕生日の設定を削除できる', async () => {
@ -201,7 +208,6 @@ describe('Endpoints', () => {
test('リアクションできる', async () => { test('リアクションできる', async () => {
const bobPost = await post(bob); const bobPost = await post(bob);
const alice = await signup({ username: 'alice' });
const res = await api('/notes/reactions/create', { const res = await api('/notes/reactions/create', {
noteId: bobPost.id, noteId: bobPost.id,
reaction: '🚀', reaction: '🚀',
@ -214,7 +220,7 @@ describe('Endpoints', () => {
}, alice); }, alice);
assert.strictEqual(resNote.status, 200); assert.strictEqual(resNote.status, 200);
assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]); assert.strictEqual(resNote.body.reactions['🚀'], 1);
}); });
test('自分の投稿にもリアクションできる', async () => { test('自分の投稿にもリアクションできる', async () => {
@ -228,7 +234,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 204); assert.strictEqual(res.status, 204);
}); });
test('二重にリアクションできない', async () => { test('二重にリアクションすると上書きされる', async () => {
const bobPost = await post(bob); const bobPost = await post(bob);
await api('/notes/reactions/create', { await api('/notes/reactions/create', {
@ -241,7 +247,14 @@ describe('Endpoints', () => {
reaction: '🚀', reaction: '🚀',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 204);
const resNote = await api('/notes/show', {
noteId: bobPost.id,
}, alice);
assert.strictEqual(resNote.status, 200);
assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 });
}); });
test('存在しない投稿にはリアクションできない', async () => { test('存在しない投稿にはリアクションできない', async () => {
@ -369,57 +382,22 @@ describe('Endpoints', () => {
}); });
}); });
/*
describe('/i', () => {
test('', async () => {
});
});
*/
});
/*
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as childProcess from 'child_process';
import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js';
describe('API: Endpoints', () => {
let p: childProcess.ChildProcess;
let alice: any;
let bob: any;
let carol: any;
before(async () => {
p = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
});
after(async () => {
await shutdownServer(p);
});
describe('drive', () => { describe('drive', () => {
test('ドライブ情報を取得できる', async () => { test('ドライブ情報を取得できる', async () => {
await uploadFile({ await uploadFile(alice, {
userId: alice.id, blob: new Blob([new Uint8Array(256)]),
size: 256
}); });
await uploadFile({ await uploadFile(alice, {
userId: alice.id, blob: new Blob([new Uint8Array(512)]),
size: 512
}); });
await uploadFile({ await uploadFile(alice, {
userId: alice.id, blob: new Blob([new Uint8Array(1024)]),
size: 1024
}); });
const res = await api('/drive', {}, alice); const res = await api('/drive', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
expect(res.body).have.property('usage').eql(1792); expect(res.body).toHaveProperty('usage', 1792);
})); });
}); });
describe('drive/files/create', () => { describe('drive/files/create', () => {
@ -428,397 +406,392 @@ describe('API: Endpoints', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'Lenna.png'); assert.strictEqual(res.body.name, 'Lenna.jpg');
})); });
test('ファイルに名前を付けられる', async () => { test('ファイルに名前を付けられる', async () => {
const res = await assert.request(server) const res = await uploadFile(alice, { name: 'Belmond.png' });
.post('/drive/files/create')
.field('i', alice.token)
.field('name', 'Belmond.png')
.attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png');
expect(res).have.status(200); assert.strictEqual(res.status, 200);
expect(res.body).be.a('object'); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
expect(res.body).have.property('name').eql('Belmond.png'); assert.strictEqual(res.body.name, 'Belmond.png');
})); });
test('ファイル無しで怒られる', async () => { test('ファイル無しで怒られる', async () => {
const res = await api('/drive/files/create', {}, alice); const res = await api('/drive/files/create', {}, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('SVGファイルを作成できる', async () => { test('SVGファイルを作成できる', async () => {
const res = await uploadFile(alice, __dirname + '/resources/image.svg'); const res = await uploadFile(alice, { path: 'image.svg' });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'image.svg'); assert.strictEqual(res.body.name, 'image.svg');
assert.strictEqual(res.body.type, 'image/svg+xml'); assert.strictEqual(res.body.type, 'image/svg+xml');
})); });
}); });
describe('drive/files/update', () => { describe('drive/files/update', () => {
test('名前を更新できる', async () => { test('名前を更新できる', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const newName = 'いちごパスタ.png'; const newName = 'いちごパスタ.png';
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
name: newName name: newName,
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, newName); assert.strictEqual(res.body.name, newName);
})); });
test('他人のファイルは更新できない', async () => { test('他人のファイルは更新できない', async () => {
const file = await uploadFile(bob); const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
name: 'いちごパスタ.png' name: 'いちごパスタ.png',
}, alice); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('親フォルダを更新できる', async () => { test('親フォルダを更新できる', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: folder.id folderId: folder.id,
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, folder.id); assert.strictEqual(res.body.folderId, folder.id);
})); });
test('親フォルダを無しにできる', async () => { test('親フォルダを無しにできる', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
await api('/drive/files/update', { await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: folder.id folderId: folder.id,
}, alice); }, alice);
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: null folderId: null,
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, null); assert.strictEqual(res.body.folderId, null);
})); });
test('他人のフォルダには入れられない', async () => { test('他人のフォルダには入れられない', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, bob)).body; }, bob)).body;
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: folder.id folderId: folder.id,
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('存在しないフォルダで怒られる', async () => { test('存在しないフォルダで怒られる', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: '000000000000000000000000' folderId: '000000000000000000000000',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('不正なフォルダIDで怒られる', async () => { test('不正なフォルダIDで怒られる', async () => {
const file = await uploadFile(alice); const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: file.id, fileId: file.id,
folderId: 'foo' folderId: 'foo',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('ファイルが存在しなかったら怒る', async () => { test('ファイルが存在しなかったら怒る', async () => {
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: '000000000000000000000000', fileId: '000000000000000000000000',
name: 'いちごパスタ.png' name: 'いちごパスタ.png',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('間違ったIDで怒られる', async () => { test('間違ったIDで怒られる', async () => {
const res = await api('/drive/files/update', { const res = await api('/drive/files/update', {
fileId: 'kyoppie', fileId: 'kyoppie',
name: 'いちごパスタ.png' name: 'いちごパスタ.png',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
}); });
describe('drive/folders/create', () => { describe('drive/folders/create', () => {
test('フォルダを作成できる', async () => { test('フォルダを作成できる', async () => {
const res = await api('/drive/folders/create', { const res = await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'test'); assert.strictEqual(res.body.name, 'test');
})); });
}); });
describe('drive/folders/update', () => { describe('drive/folders/update', () => {
test('名前を更新できる', async () => { test('名前を更新できる', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
name: 'new name' name: 'new name',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'new name'); assert.strictEqual(res.body.name, 'new name');
})); });
test('他人のフォルダを更新できない', async () => { test('他人のフォルダを更新できない', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, bob)).body; }, bob)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
name: 'new name' name: 'new name',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('親フォルダを更新できる', async () => { test('親フォルダを更新できる', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const parentFolder = (await api('/drive/folders/create', { const parentFolder = (await api('/drive/folders/create', {
name: 'parent' name: 'parent',
}, alice)).body; }, alice)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: parentFolder.id parentId: parentFolder.id,
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, parentFolder.id); assert.strictEqual(res.body.parentId, parentFolder.id);
})); });
test('親フォルダを無しに更新できる', async () => { test('親フォルダを無しに更新できる', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const parentFolder = (await api('/drive/folders/create', { const parentFolder = (await api('/drive/folders/create', {
name: 'parent' name: 'parent',
}, alice)).body; }, alice)).body;
await api('/drive/folders/update', { await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: parentFolder.id parentId: parentFolder.id,
}, alice); }, alice);
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: null parentId: null,
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, null); assert.strictEqual(res.body.parentId, null);
})); });
test('他人のフォルダを親フォルダに設定できない', async () => { test('他人のフォルダを親フォルダに設定できない', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const parentFolder = (await api('/drive/folders/create', { const parentFolder = (await api('/drive/folders/create', {
name: 'parent' name: 'parent',
}, bob)).body; }, bob)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: parentFolder.id parentId: parentFolder.id,
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('フォルダが循環するような構造にできない', async () => { test('フォルダが循環するような構造にできない', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const parentFolder = (await api('/drive/folders/create', { const parentFolder = (await api('/drive/folders/create', {
name: 'parent' name: 'parent',
}, alice)).body; }, alice)).body;
await api('/drive/folders/update', { await api('/drive/folders/update', {
folderId: parentFolder.id, folderId: parentFolder.id,
parentId: folder.id parentId: folder.id,
}, alice); }, alice);
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: parentFolder.id parentId: parentFolder.id,
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('フォルダが循環するような構造にできない(再帰的)', async () => { test('フォルダが循環するような構造にできない(再帰的)', async () => {
const folderA = (await api('/drive/folders/create', { const folderA = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const folderB = (await api('/drive/folders/create', { const folderB = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const folderC = (await api('/drive/folders/create', { const folderC = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
await api('/drive/folders/update', { await api('/drive/folders/update', {
folderId: folderB.id, folderId: folderB.id,
parentId: folderA.id parentId: folderA.id,
}, alice); }, alice);
await api('/drive/folders/update', { await api('/drive/folders/update', {
folderId: folderC.id, folderId: folderC.id,
parentId: folderB.id parentId: folderB.id,
}, alice); }, alice);
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folderA.id, folderId: folderA.id,
parentId: folderC.id parentId: folderC.id,
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('フォルダが循環するような構造にできない(自身)', async () => { test('フォルダが循環するような構造にできない(自身)', async () => {
const folderA = (await api('/drive/folders/create', { const folderA = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folderA.id, folderId: folderA.id,
parentId: folderA.id parentId: folderA.id,
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('存在しない親フォルダを設定できない', async () => { test('存在しない親フォルダを設定できない', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: '000000000000000000000000' parentId: '000000000000000000000000',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('不正な親フォルダIDで怒られる', async () => { test('不正な親フォルダIDで怒られる', async () => {
const folder = (await api('/drive/folders/create', { const folder = (await api('/drive/folders/create', {
name: 'test' name: 'test',
}, alice)).body; }, alice)).body;
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: folder.id, folderId: folder.id,
parentId: 'foo' parentId: 'foo',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('存在しないフォルダを更新できない', async () => { test('存在しないフォルダを更新できない', async () => {
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: '000000000000000000000000' folderId: '000000000000000000000000',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
test('不正なフォルダIDで怒られる', async () => { test('不正なフォルダIDで怒られる', async () => {
const res = await api('/drive/folders/update', { const res = await api('/drive/folders/update', {
folderId: 'foo' folderId: 'foo',
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
})); });
}); });
describe('notes/replies', () => { describe('notes/replies', () => {
test('自分に閲覧権限のない投稿は含まれない', async () => { test('自分に閲覧権限のない投稿は含まれない', async () => {
const alicePost = await post(alice, { const alicePost = await post(alice, {
text: 'foo' text: 'foo',
}); });
await post(bob, { await post(bob, {
replyId: alicePost.id, replyId: alicePost.id,
text: 'bar', text: 'bar',
visibility: 'specified', visibility: 'specified',
visibleUserIds: [alice.id] visibleUserIds: [alice.id],
}); });
const res = await api('/notes/replies', { const res = await api('/notes/replies', {
noteId: alicePost.id noteId: alicePost.id,
}, carol); }, carol);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 0); assert.strictEqual(res.body.length, 0);
})); });
}); });
describe('notes/timeline', () => { describe('notes/timeline', () => {
test('フォロワー限定投稿が含まれる', async () => { test('フォロワー限定投稿が含まれる', async () => {
await api('/following/create', { await api('/following/create', {
userId: alice.id userId: carol.id,
}, bob); }, dave);
const alicePost = await post(alice, { const carolPost = await post(carol, {
text: 'foo', text: 'foo',
visibility: 'followers' visibility: 'followers',
}); });
const res = await api('/notes/timeline', {}, bob); const res = await api('/notes/timeline', {}, dave);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1); assert.strictEqual(res.body.length, 1);
assert.strictEqual(res.body[0].id, alicePost.id); assert.strictEqual(res.body[0].id, carolPost.id);
})); });
}); });
}); });
*/

View file

@ -1,9 +1,8 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { startServer, signup, post, api, simpleGet } from '../utils.js';
import * as openapi from '@redocly/openapi-core'; import type { INestApplicationContext } from '@nestjs/common';
import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js';
// Request Accept // Request Accept
const ONLY_AP = 'application/activity+json'; const ONLY_AP = 'application/activity+json';
@ -13,11 +12,10 @@ const UNSPECIFIED = '*/*';
// Response Content-Type // Response Content-Type
const AP = 'application/activity+json; charset=utf-8'; const AP = 'application/activity+json; charset=utf-8';
const JSON = 'application/json; charset=utf-8';
const HTML = 'text/html; charset=utf-8'; const HTML = 'text/html; charset=utf-8';
describe('Fetch resource', () => { describe('Fetch resource', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let alicesPost: any; let alicesPost: any;
@ -28,15 +26,15 @@ describe('Fetch resource', () => {
alicesPost = await post(alice, { alicesPost = await post(alice, {
text: 'test', text: 'test',
}); });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
describe('Common', () => { describe('Common', () => {
test('meta', async () => { test('meta', async () => {
const res = await request('/meta', { const res = await api('/meta', {
}); });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -54,36 +52,26 @@ describe('Fetch resource', () => {
assert.strictEqual(res.type, HTML); assert.strictEqual(res.type, HTML);
}); });
test('GET api-doc', async () => { test('GET api-doc (廃止)', async () => {
const res = await simpleGet('/api-doc'); const res = await simpleGet('/api-doc');
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 404);
assert.strictEqual(res.type, HTML);
}); });
test('GET api.json', async () => { test('GET api.json (廃止)', async () => {
const res = await simpleGet('/api.json'); const res = await simpleGet('/api.json');
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 404);
assert.strictEqual(res.type, JSON);
}); });
test('Validate api.json', async () => { test('GET api/foo (存在しない)', async () => {
const config = await openapi.loadConfig(); const res = await simpleGet('/api/foo');
const result = await openapi.bundle({ assert.strictEqual(res.status, 404);
config, assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT');
ref: `http://localhost:${port}/api.json`,
});
for (const problem of result.problems) {
console.log(`${problem.message} - ${problem.location[0]?.pointer}`);
}
assert.strictEqual(result.problems.length, 0);
}); });
test('GET favicon.ico', async () => { test('GET favicon.ico', async () => {
const res = await simpleGet('/favicon.ico'); const res = await simpleGet('/favicon.ico');
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.type, 'image/x-icon'); assert.strictEqual(res.type, 'image/vnd.microsoft.icon');
}); });
test('GET apple-touch-icon.png', async () => { test('GET apple-touch-icon.png', async () => {

View file

@ -1,36 +1,34 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, startServer, simpleGet } from '../utils.js';
import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('FF visibility', () => { describe('FF visibility', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let bob: any; let bob: any;
let carol: any;
beforeAll(async () => { beforeAll(async () => {
p = await startServer(); p = await startServer();
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2);
}, 1000 * 30);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'public', ffVisibility: 'public',
}, alice); }, alice);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
@ -41,14 +39,14 @@ describe('FF visibility', () => {
}); });
test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => { test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'followers', ffVisibility: 'followers',
}, alice); }, alice);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
@ -59,14 +57,14 @@ describe('FF visibility', () => {
}); });
test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => { test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'followers', ffVisibility: 'followers',
}, alice); }, alice);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
@ -75,18 +73,18 @@ describe('FF visibility', () => {
}); });
test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => { test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'followers', ffVisibility: 'followers',
}, alice); }, alice);
await request('/following/create', { await api('/following/create', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
@ -97,14 +95,14 @@ describe('FF visibility', () => {
}); });
test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => { test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'private', ffVisibility: 'private',
}, alice); }, alice);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
@ -115,14 +113,14 @@ describe('FF visibility', () => {
}); });
test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => { test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'private', ffVisibility: 'private',
}, alice); }, alice);
const followingRes = await request('/users/following', { const followingRes = await api('/users/following', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
const followersRes = await request('/users/followers', { const followersRes = await api('/users/followers', {
userId: alice.id, userId: alice.id,
}, bob); }, bob);
@ -133,7 +131,7 @@ describe('FF visibility', () => {
describe('AP', () => { describe('AP', () => {
test('ffVisibility が public 以外ならばAPからは取得できない', async () => { test('ffVisibility が public 以外ならばAPからは取得できない', async () => {
{ {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'public', ffVisibility: 'public',
}, alice); }, alice);
@ -143,22 +141,22 @@ describe('FF visibility', () => {
assert.strictEqual(followersRes.status, 200); assert.strictEqual(followersRes.status, 200);
} }
{ {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'followers', ffVisibility: 'followers',
}, alice); }, alice);
const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403); assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403); assert.strictEqual(followersRes.status, 403);
} }
{ {
await request('/i/update', { await api('/i/update', {
ffVisibility: 'private', ffVisibility: 'private',
}, alice); }, alice);
const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403); assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403); assert.strictEqual(followersRes.status, 403);
} }

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, post, react, startServer, waitFire } from '../utils.js';
import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('Mute', () => { describe('Mute', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
// alice mutes carol // alice mutes carol
let alice: any; let alice: any;
@ -17,14 +17,14 @@ describe('Mute', () => {
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' }); carol = await signup({ username: 'carol' });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
test('ミュート作成', async () => { test('ミュート作成', async () => {
const res = await request('/mute/create', { const res = await api('/mute/create', {
userId: carol.id, userId: carol.id,
}, alice); }, alice);
@ -35,7 +35,7 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: '@alice hi' }); const bobNote = await post(bob, { text: '@alice hi' });
const carolNote = await post(carol, { text: '@alice hi' }); const carolNote = await post(carol, { text: '@alice hi' });
const res = await request('/notes/mentions', {}, alice); const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
@ -45,11 +45,11 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await api('/i/read-all-unread-notes', {}, alice);
await post(carol, { text: '@alice hi' }); await post(carol, { text: '@alice hi' });
const res = await request('/i', {}, alice); const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false); assert.strictEqual(res.body.hasUnreadMentions, false);
@ -57,7 +57,7 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await api('/i/read-all-unread-notes', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
@ -66,8 +66,8 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await api('/i/read-all-unread-notes', {}, alice);
await request('/notifications/mark-all-as-read', {}, alice); await api('/notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
@ -80,7 +80,7 @@ describe('Mute', () => {
const bobNote = await post(bob); const bobNote = await post(bob);
const carolNote = await post(carol); const carolNote = await post(carol);
const res = await request('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
@ -96,7 +96,7 @@ describe('Mute', () => {
renoteId: carolNote.id, renoteId: carolNote.id,
}); });
const res = await request('/notes/local-timeline', {}, alice); const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
@ -112,7 +112,7 @@ describe('Mute', () => {
await react(bob, aliceNote, 'like'); await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like'); await react(carol, aliceNote, 'like');
const res = await request('/i/notifications', {}, alice); const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);

View file

@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { Note } from '@/models/entities/Note.js';
import { Note } from '../../src/models/entities/note.js'; import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js';
import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => { describe('Note', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let Notes: any; let Notes: any;
let alice: any; let alice: any;
@ -18,10 +18,10 @@ describe('Note', () => {
Notes = connection.getRepository(Note); Notes = connection.getRepository(Note);
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
test('投稿できる', async () => { test('投稿できる', async () => {
@ -29,7 +29,7 @@ describe('Note', () => {
text: 'test', text: 'test',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -39,7 +39,7 @@ describe('Note', () => {
test('ファイルを添付できる', async () => { test('ファイルを添付できる', async () => {
const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
const res = await request('/notes/create', { const res = await api('/notes/create', {
fileIds: [file.id], fileIds: [file.id],
}, alice); }, alice);
@ -48,37 +48,37 @@ describe('Note', () => {
assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]); assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]);
}, 1000 * 10); }, 1000 * 10);
test('他人のファイルは無視', async () => { test('他人のファイルで怒られる', async () => {
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
const res = await request('/notes/create', { const res = await api('/notes/create', {
text: 'test', text: 'test',
fileIds: [file.id], fileIds: [file.id],
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 400);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
assert.deepStrictEqual(res.body.createdNote.fileIds, []); assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}, 1000 * 10); }, 1000 * 10);
test('存在しないファイルは無視', async () => { test('存在しないファイルで怒られる', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
text: 'test', text: 'test',
fileIds: ['000000000000000000000000'], fileIds: ['000000000000000000000000'],
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 400);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
assert.deepStrictEqual(res.body.createdNote.fileIds, []); assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('不正なファイルIDは無視', async () => { test('不正なファイルIDで怒られる', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
fileIds: ['kyoppie'], fileIds: ['kyoppie'],
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 400);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
assert.deepStrictEqual(res.body.createdNote.fileIds, []); assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('返信できる', async () => { test('返信できる', async () => {
@ -91,7 +91,7 @@ describe('Note', () => {
replyId: bobPost.id, replyId: bobPost.id,
}; };
const res = await request('/notes/create', alicePost, alice); const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -109,7 +109,7 @@ describe('Note', () => {
renoteId: bobPost.id, renoteId: bobPost.id,
}; };
const res = await request('/notes/create', alicePost, alice); const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -127,7 +127,7 @@ describe('Note', () => {
renoteId: bobPost.id, renoteId: bobPost.id,
}; };
const res = await request('/notes/create', alicePost, alice); const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -140,7 +140,7 @@ describe('Note', () => {
const post = { const post = {
text: '!'.repeat(3000), text: '!'.repeat(3000),
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
}); });
@ -148,7 +148,7 @@ describe('Note', () => {
const post = { const post = {
text: '!'.repeat(3001), text: '!'.repeat(3001),
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
@ -157,7 +157,7 @@ describe('Note', () => {
text: 'test', text: 'test',
replyId: '000000000000000000000000', replyId: '000000000000000000000000',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
@ -165,7 +165,7 @@ describe('Note', () => {
const post = { const post = {
renoteId: '000000000000000000000000', renoteId: '000000000000000000000000',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
@ -174,7 +174,7 @@ describe('Note', () => {
text: 'test', text: 'test',
replyId: 'foo', replyId: 'foo',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
@ -182,7 +182,7 @@ describe('Note', () => {
const post = { const post = {
renoteId: 'foo', renoteId: 'foo',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
@ -191,7 +191,7 @@ describe('Note', () => {
text: '@ghost yo', text: '@ghost yo',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -203,7 +203,7 @@ describe('Note', () => {
text: '@bob @bob @bob yo', text: '@bob @bob @bob yo',
}; };
const res = await request('/notes/create', post, alice); const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@ -215,7 +215,7 @@ describe('Note', () => {
describe('notes/create', () => { describe('notes/create', () => {
test('投票を添付できる', async () => { test('投票を添付できる', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
text: 'test', text: 'test',
poll: { poll: {
choices: ['foo', 'bar'], choices: ['foo', 'bar'],
@ -228,14 +228,14 @@ describe('Note', () => {
}); });
test('投票の選択肢が無くて怒られる', async () => { test('投票の選択肢が無くて怒られる', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
poll: {}, poll: {},
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
}); });
test('投票の選択肢が無くて怒られる (空の配列)', async () => { test('投票の選択肢が無くて怒られる (空の配列)', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
poll: { poll: {
choices: [], choices: [],
}, },
@ -244,7 +244,7 @@ describe('Note', () => {
}); });
test('投票の選択肢が1つで怒られる', async () => { test('投票の選択肢が1つで怒られる', async () => {
const res = await request('/notes/create', { const res = await api('/notes/create', {
poll: { poll: {
choices: ['Strawberry Pasta'], choices: ['Strawberry Pasta'],
}, },
@ -253,14 +253,14 @@ describe('Note', () => {
}); });
test('投票できる', async () => { test('投票できる', async () => {
const { body } = await request('/notes/create', { const { body } = await api('/notes/create', {
text: 'test', text: 'test',
poll: { poll: {
choices: ['sakura', 'izumi', 'ako'], choices: ['sakura', 'izumi', 'ako'],
}, },
}, alice); }, alice);
const res = await request('/notes/polls/vote', { const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 1, choice: 1,
}, alice); }, alice);
@ -269,19 +269,19 @@ describe('Note', () => {
}); });
test('複数投票できない', async () => { test('複数投票できない', async () => {
const { body } = await request('/notes/create', { const { body } = await api('/notes/create', {
text: 'test', text: 'test',
poll: { poll: {
choices: ['sakura', 'izumi', 'ako'], choices: ['sakura', 'izumi', 'ako'],
}, },
}, alice); }, alice);
await request('/notes/polls/vote', { await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 0, choice: 0,
}, alice); }, alice);
const res = await request('/notes/polls/vote', { const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 2, choice: 2,
}, alice); }, alice);
@ -290,7 +290,7 @@ describe('Note', () => {
}); });
test('許可されている場合は複数投票できる', async () => { test('許可されている場合は複数投票できる', async () => {
const { body } = await request('/notes/create', { const { body } = await api('/notes/create', {
text: 'test', text: 'test',
poll: { poll: {
choices: ['sakura', 'izumi', 'ako'], choices: ['sakura', 'izumi', 'ako'],
@ -298,17 +298,17 @@ describe('Note', () => {
}, },
}, alice); }, alice);
await request('/notes/polls/vote', { await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 0, choice: 0,
}, alice); }, alice);
await request('/notes/polls/vote', { await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 1, choice: 1,
}, alice); }, alice);
const res = await request('/notes/polls/vote', { const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 2, choice: 2,
}, alice); }, alice);
@ -317,7 +317,7 @@ describe('Note', () => {
}); });
test('締め切られている場合は投票できない', async () => { test('締め切られている場合は投票できない', async () => {
const { body } = await request('/notes/create', { const { body } = await api('/notes/create', {
text: 'test', text: 'test',
poll: { poll: {
choices: ['sakura', 'izumi', 'ako'], choices: ['sakura', 'izumi', 'ako'],
@ -327,7 +327,7 @@ describe('Note', () => {
await new Promise(x => setTimeout(x, 2)); await new Promise(x => setTimeout(x, 2));
const res = await request('/notes/polls/vote', { const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id, noteId: body.createdNote.id,
choice: 1, choice: 1,
}, alice); }, alice);

View file

@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { Following } from '@/models/entities/Following.js';
import { Following } from '../../src/models/entities/following.js'; import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('Streaming', () => { describe('Streaming', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let Followings: any; let Followings: any;
const follow = async (follower: any, followee: any) => { const follow = async (follower: any, followee: any) => {
@ -71,10 +71,10 @@ describe('Streaming', () => {
listId: list.id, listId: list.id,
userId: kyoko.id, userId: kyoko.id,
}, chitose); }, chitose);
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
describe('Events', () => { describe('Events', () => {
@ -404,43 +404,45 @@ describe('Streaming', () => {
}); });
})); }));
test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => { // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
let fooCount = 0;
let barCount = 0; // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
let fooBarCount = 0; // let fooCount = 0;
// let barCount = 0;
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { // let fooBarCount = 0;
if (type === 'note') {
if (body.text === '#foo') fooCount++; // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (body.text === '#bar') barCount++; // if (type === 'note') {
if (body.text === '#foo #bar') fooBarCount++; // if (body.text === '#foo') fooCount++;
} // if (body.text === '#bar') barCount++;
}, { // if (body.text === '#foo #bar') fooBarCount++;
q: [ // }
['foo', 'bar'], // }, {
], // q: [
}); // ['foo', 'bar'],
// ],
post(chitose, { // });
text: '#foo',
}); // post(chitose, {
// text: '#foo',
post(chitose, { // });
text: '#bar',
}); // post(chitose, {
// text: '#bar',
post(chitose, { // });
text: '#foo #bar',
}); // post(chitose, {
// text: '#foo #bar',
setTimeout(() => { // });
assert.strictEqual(fooCount, 0);
assert.strictEqual(barCount, 0); // setTimeout(() => {
assert.strictEqual(fooBarCount, 1); // assert.strictEqual(fooCount, 0);
ws.close(); // assert.strictEqual(barCount, 0);
done(); // assert.strictEqual(fooBarCount, 1);
}, 3000); // ws.close();
})); // done();
// }, 3000);
// }));
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => { test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0; let fooCount = 0;

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, post, connectStream, startServer } from '../utils.js';
import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('Note thread mute', () => { describe('Note thread mute', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let bob: any; let bob: any;
@ -16,22 +16,22 @@ describe('Note thread mute', () => {
alice = await signup({ username: 'alice' }); alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' }); bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' }); carol = await signup({ username: 'carol' });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async () => { afterAll(async () => {
await shutdownServer(p); await p.close();
}); });
test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => {
const bobNote = await post(bob, { text: '@alice @carol root note' }); const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
const res = await request('/notes/mentions', {}, alice); const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
@ -42,27 +42,27 @@ describe('Note thread mute', () => {
test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' }); const bobNote = await post(bob, { text: '@alice @carol root note' });
await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const res = await request('/i', {}, alice); const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false); assert.strictEqual(res.body.hasUnreadMentions, false);
}); });
test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise<void>(async done => {
// 状態リセット // 状態リセット
await request('/i/read-all-unread-notes', {}, alice); await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' }); const bobNote = await post(bob, { text: '@alice @carol root note' });
await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
let fired = false; let fired = false;
@ -86,12 +86,12 @@ describe('Note thread mute', () => {
const bobNote = await post(bob, { text: '@alice @carol root note' }); const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice); await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
const res = await request('/i/notifications', {}, alice); const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);

View file

@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import * as childProcess from 'child_process'; import { signup, api, post, uploadUrl, startServer } from '../utils.js';
import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common';
describe('users/notes', () => { describe('users/notes', () => {
let p: childProcess.ChildProcess; let p: INestApplicationContext;
let alice: any; let alice: any;
let jpgNote: any; let jpgNote: any;
@ -26,14 +26,14 @@ describe('users/notes', () => {
jpgPngNote = await post(alice, { jpgPngNote = await post(alice, {
fileIds: [jpg.id, png.id], fileIds: [jpg.id, png.id],
}); });
}, 1000 * 30); }, 1000 * 60 * 2);
afterAll(async() => { afterAll(async() => {
await shutdownServer(p); await p.close();
}); });
test('ファイルタイプ指定 (jpg)', async () => { test('ファイルタイプ指定 (jpg)', async () => {
const res = await request('/users/notes', { const res = await api('/users/notes', {
userId: alice.id, userId: alice.id,
fileType: ['image/jpeg'], fileType: ['image/jpeg'],
}, alice); }, alice);
@ -46,7 +46,7 @@ describe('users/notes', () => {
}); });
test('ファイルタイプ指定 (jpg or png)', async () => { test('ファイルタイプ指定 (jpg or png)', async () => {
const res = await request('/users/notes', { const res = await api('/users/notes', {
userId: alice.id, userId: alice.id,
fileType: ['image/jpeg', 'image/png'], fileType: ['image/jpeg', 'image/png'],
}, alice); }, alice);

View file

@ -3,16 +3,18 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock'; import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { DataSource } from 'typeorm'; import * as lolex from '@sinonjs/fake-timers';
import rndstr from 'rndstr'; import rndstr from 'rndstr';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js'; import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CoreModule } from '@/core/CoreModule.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { genAid } from '@/misc/id/aid.js'; import { genAid } from '@/misc/id/aid.js';
import { UserCacheService } from '@/core/UserCacheService.js'; import { UserCacheService } from '@/core/UserCacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { sleep } from '../utils.js';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock'; import type { MockFunctionMetadata } from 'jest-mock';
@ -25,6 +27,7 @@ describe('RoleService', () => {
let rolesRepository: RolesRepository; let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository; let roleAssignmentsRepository: RoleAssignmentsRepository;
let metaService: jest.Mocked<MetaService>; let metaService: jest.Mocked<MetaService>;
let clock: lolex.InstalledClock;
function createUser(data: Partial<User> = {}) { function createUser(data: Partial<User> = {}) {
const un = rndstr('a-z0-9', 16); const un = rndstr('a-z0-9', 16);
@ -50,16 +53,12 @@ describe('RoleService', () => {
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); .then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
} }
async function assign(roleId: Role['id'], userId: User['id']) {
await roleAssignmentsRepository.insert({
id: genAid(new Date()),
createdAt: new Date(),
roleId,
userId,
});
}
beforeEach(async () => { beforeEach(async () => {
clock = lolex.install({
now: new Date(),
shouldClearNativeTimers: true,
});
app = await Test.createTestingModule({ app = await Test.createTestingModule({
imports: [ imports: [
GlobalModule, GlobalModule,
@ -67,6 +66,8 @@ describe('RoleService', () => {
providers: [ providers: [
RoleService, RoleService,
UserCacheService, UserCacheService,
IdService,
GlobalEventService,
], ],
}) })
.useMocker((token) => { .useMocker((token) => {
@ -92,12 +93,15 @@ describe('RoleService', () => {
}); });
afterEach(async () => { afterEach(async () => {
clock.uninstall();
await Promise.all([ await Promise.all([
app.get(DI.metasRepository).delete({}), app.get(DI.metasRepository).delete({}),
usersRepository.delete({}), usersRepository.delete({}),
rolesRepository.delete({}), rolesRepository.delete({}),
roleAssignmentsRepository.delete({}), roleAssignmentsRepository.delete({}),
]); ]);
await app.close(); await app.close();
}); });
@ -115,7 +119,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(false); expect(result.canManageCustomEmojis).toBe(false);
}); });
test('instance default policies 2', async () => { test('instance default policies 2', async () => {
const user = await createUser(); const user = await createUser();
metaService.fetch.mockResolvedValue({ metaService.fetch.mockResolvedValue({
policies: { policies: {
@ -128,7 +132,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true); expect(result.canManageCustomEmojis).toBe(true);
}); });
test('with role', async () => { test('with role', async () => {
const user = await createUser(); const user = await createUser();
const role = await createRole({ const role = await createRole({
name: 'a', name: 'a',
@ -140,7 +144,7 @@ describe('RoleService', () => {
}, },
}, },
}); });
await assign(role.id, user.id); await roleService.assign(user.id, role.id);
metaService.fetch.mockResolvedValue({ metaService.fetch.mockResolvedValue({
policies: { policies: {
canManageCustomEmojis: false, canManageCustomEmojis: false,
@ -152,7 +156,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true); expect(result.canManageCustomEmojis).toBe(true);
}); });
test('priority', async () => { test('priority', async () => {
const user = await createUser(); const user = await createUser();
const role1 = await createRole({ const role1 = await createRole({
name: 'role1', name: 'role1',
@ -174,8 +178,8 @@ describe('RoleService', () => {
}, },
}, },
}); });
await assign(role1.id, user.id); await roleService.assign(user.id, role1.id);
await assign(role2.id, user.id); await roleService.assign(user.id, role2.id);
metaService.fetch.mockResolvedValue({ metaService.fetch.mockResolvedValue({
policies: { policies: {
driveCapacityMb: 50, driveCapacityMb: 50,
@ -187,7 +191,7 @@ describe('RoleService', () => {
expect(result.driveCapacityMb).toBe(100); expect(result.driveCapacityMb).toBe(100);
}); });
test('conditional role', async () => { test('conditional role', async () => {
const user1 = await createUser({ const user1 = await createUser({
createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)), createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)),
}); });
@ -228,5 +232,42 @@ describe('RoleService', () => {
expect(user1Policies.canManageCustomEmojis).toBe(false); expect(user1Policies.canManageCustomEmojis).toBe(false);
expect(user2Policies.canManageCustomEmojis).toBe(true); expect(user2Policies.canManageCustomEmojis).toBe(true);
}); });
test('expired role', async () => {
const user = await createUser();
const role = await createRole({
name: 'a',
policies: {
canManageCustomEmojis: {
useDefault: false,
priority: 0,
value: true,
},
},
});
await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24)));
metaService.fetch.mockResolvedValue({
policies: {
canManageCustomEmojis: false,
},
} as any);
const result = await roleService.getUserPolicies(user.id);
expect(result.canManageCustomEmojis).toBe(true);
clock.tick('25:00:00');
const resultAfter25h = await roleService.getUserPolicies(user.id);
expect(resultAfter25h.canManageCustomEmojis).toBe(false);
await roleService.assign(user.id, role.id);
// ストリーミング経由で反映されるまでちょっと待つ
clock.uninstall();
await sleep(100);
const resultAfter25hAgain = await roleService.getUserPolicies(user.id);
expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true);
});
}); });
}); });

View file

@ -1,87 +1,50 @@
import * as fs from 'node:fs'; import { readFile } from 'node:fs/promises';
import * as path from 'node:path'; import { isAbsolute, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as childProcess from 'child_process';
import * as http from 'node:http';
import { SIGKILL } from 'constants';
import WebSocket from 'ws'; import WebSocket from 'ws';
import fetch from 'node-fetch'; import fetch, { Blob, File, RequestInit } from 'node-fetch';
import FormData from 'form-data';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import got, { RequestError } from 'got'; import { entities } from '../src/postgres.js';
import loadConfig from '../src/config/load.js'; import { loadConfig } from '../src/config.js';
import { entities } from '@/postgres.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
const _filename = fileURLToPath(import.meta.url); export { server as startServer } from '@/boot/common.js';
const _dirname = dirname(_filename);
const config = loadConfig(); const config = loadConfig();
export const port = config.port; export const port = config.port;
export const api = async (endpoint: string, params: any, me?: any) => { export const api = async (endpoint: string, params: any, me?: any) => {
endpoint = endpoint.replace(/^\//, ''); const normalized = endpoint.replace(/^\//, '');
return await request(`api/${normalized}`, params, me);
const auth = me ? {
i: me.token,
} : {};
try {
const res = await got<string>(`http://localhost:${port}/api/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(Object.assign(auth, params)),
retry: {
limit: 0,
},
});
const status = res.statusCode;
const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
return {
status,
body,
};
} catch (err: unknown) {
if (err instanceof RequestError && err.response) {
const status = err.response.statusCode;
const body = await JSON.parse(err.response.body as string);
return {
status,
body,
};
} else {
throw err;
}
}
}; };
export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? { const auth = me ? {
i: me.token, i: me.token,
} : {}; } : {};
const res = await fetch(`http://localhost:${port}/${path}`, { const res = await relativeFetch(path, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(Object.assign(auth, params)), body: JSON.stringify(Object.assign(auth, params)),
redirect: 'manual',
}); });
const status = res.status; const status = res.status;
const body = res.status === 200 ? await res.json().catch() : null; const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
? await res.json()
: null;
return { return {
body, status, body, status,
}; };
}; };
const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
};
export const signup = async (params?: any): Promise<any> => { export const signup = async (params?: any): Promise<any> => {
const q = Object.assign({ const q = Object.assign({
username: 'test', username: 'test',
@ -110,30 +73,46 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
}, user); }, user);
}; };
interface UploadOptions {
/** Optional, absolute path or relative from ./resources/ */
path?: string | URL;
/** The name to be used for the file upload */
name?: string;
/** A Blob can be provided instead of path */
blob?: Blob;
}
/** /**
* Upload file * Upload file
* @param user User * @param user User
* @param _path Optional, absolute path or relative from ./resources/
*/ */
export const uploadFile = async (user: any, _path?: string): Promise<any> => { export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise<any> => {
const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`; const absPath = path == null
? new URL('resources/Lenna.jpg', import.meta.url)
: isAbsolute(path.toString())
? new URL(path)
: new URL(path, new URL('resources/', import.meta.url));
const formData = new FormData() as any; const formData = new FormData();
formData.append('i', user.token); formData.append('i', user.token);
formData.append('file', fs.createReadStream(absPath)); formData.append('file', blob ??
new File([await readFile(absPath)], basename(absPath.toString())));
formData.append('force', 'true'); formData.append('force', 'true');
if (name) {
formData.append('name', name);
}
const res = await got<string>(`http://localhost:${port}/api/drive/files/create`, { const res = await relativeFetch('api/drive/files/create', {
method: 'POST', method: 'POST',
body: formData, body: formData,
retry: {
limit: 0,
},
}); });
const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; const body = res.status !== 204 ? await res.json() : null;
return body; return {
status: res.status,
body,
};
}; };
export const uploadUrl = async (user: any, url: string) => { export const uploadUrl = async (user: any, url: string) => {
@ -160,7 +139,7 @@ export const uploadUrl = async (user: any, url: string) => {
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> { export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`); const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`);
ws.on('open', () => { ws.on('open', () => {
ws.on('message', data => { ws.on('message', data => {
@ -187,7 +166,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => { export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
return new Promise<boolean>(async (res, rej) => { return new Promise<boolean>(async (res, rej) => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout | null = null;
let ws: WebSocket; let ws: WebSocket;
try { try {
@ -219,41 +198,25 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
}); });
}; };
export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => {
// node-fetchだと3xxを取れない const res = await relativeFetch(path, {
return await new Promise((resolve, reject) => { headers: {
const req = http.request(`http://localhost:${port}${path}`, { Accept: accept,
headers: { },
Accept: accept, redirect: 'manual',
},
}, res => {
if (res.statusCode! >= 400) {
reject(res);
} else {
resolve({
status: res.statusCode,
type: res.headers['content-type'],
location: res.headers.location,
});
}
});
req.end();
}); });
};
export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise<void> = async () => {}) { const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
return (done: (err?: Error) => any) => { ? await res.json()
const p = childProcess.spawn('node', [_dirname + '/../index.js'], { : null;
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { NODE_ENV: 'test', PATH: process.env.PATH }, return {
}); status: res.status,
callbackSpawnedProcess(p); body,
p.on('message', message => { type: res.headers.get('content-type'),
if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e)); location: res.headers.get('location'),
});
}; };
} };
export async function initTestDb(justBorrow = false, initEntities?: any[]) { export async function initTestDb(justBorrow = false, initEntities?: any[]) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
@ -275,46 +238,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db; return db;
} }
export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProcess> {
return new Promise((res, rej) => {
const t = setTimeout(() => {
p.kill(SIGKILL);
rej('timeout to start');
}, timeout);
const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { NODE_ENV: 'test', PATH: process.env.PATH },
});
p.on('error', e => rej(e));
p.on('message', message => {
if (message === 'ok') {
clearTimeout(t);
res(p);
}
});
});
}
export function shutdownServer(p: childProcess.ChildProcess | undefined, timeout = 20 * 1000) {
if (p == null) return Promise.resolve('nop');
return new Promise((res, rej) => {
const t = setTimeout(() => {
p.kill(SIGKILL);
res('force exit');
}, timeout);
p.once('exit', () => {
clearTimeout(t);
res('exited');
});
p.kill();
});
}
export function sleep(msec: number) { export function sleep(msec: number) {
return new Promise<void>(res => { return new Promise<void>(res => {
setTimeout(() => { setTimeout(() => {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -18,7 +18,7 @@
</div> </div>
</Transition> </Transition>
<div class="container"> <div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" crossorigin="anonymous" @load="onImageLoad"> <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div> </div>
</div> </div>
</template> </template>
@ -49,7 +49,7 @@ const props = defineProps<{
aspectRatio: number; aspectRatio: number;
}>(); }>();
const imgUrl = getProxiedImageUrl(props.file.url); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>(); let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>();
let imgEl = $shallowRef<HTMLImageElement>(); let imgEl = $shallowRef<HTMLImageElement>();
let cropper: Cropper | null = null; let cropper: Cropper | null = null;

View file

@ -43,8 +43,8 @@
import { nextTick, onMounted } from 'vue'; import { nextTick, onMounted } from 'vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
defaultOpen: boolean; defaultOpen?: boolean;
maxHeight: number | null; maxHeight?: number | null;
}>(), { }>(), {
defaultOpen: false, defaultOpen: false,
maxHeight: null, maxHeight: null,

View file

@ -13,7 +13,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref, useCssModule } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe'; import PhotoSwipe from 'photoswipe';
@ -29,8 +29,11 @@ const props = defineProps<{
raw?: boolean; raw?: boolean;
}>(); }>();
const $style = useCssModule();
const gallery = ref(null); const gallery = ref(null);
const pswpZIndex = os.claimZIndex('middle'); const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
onMounted(() => { onMounted(() => {
@ -54,17 +57,18 @@ onMounted(() => {
return item; return item;
}), }),
gallery: gallery.value, gallery: gallery.value,
mainClass: $style.pswp,
children: '.image', children: '.image',
thumbSelector: '.image', thumbSelector: '.image',
loop: false, loop: false,
padding: window.innerWidth > 500 ? { padding: window.innerWidth > 500 ? {
top: 32, top: 32,
bottom: 32, bottom: 90,
left: 32, left: 32,
right: 32, right: 32,
} : { } : {
top: 0, top: 0,
bottom: 0, bottom: 78,
left: 0, left: 0,
right: 0, right: 0,
}, },
@ -82,6 +86,7 @@ onMounted(() => {
const id = element.dataset.id; const id = element.dataset.id;
const file = props.mediaList.find(media => media.id === id); const file = props.mediaList.find(media => media.id === id);
if (!file) return;
itemData.src = file.url; itemData.src = file.url;
itemData.w = Number(file.properties.width); itemData.w = Number(file.properties.width);
@ -198,16 +203,14 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
overflow: hidden; // clip overflow: hidden; // clip
border-radius: 8px; border-radius: 8px;
} }
.pswp {
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
--pswp-bg: var(--modalBg) !important;
}
</style> </style>
<style lang="scss"> <style lang="scss">
.pswp {
//
//z-index: v-bind(pswpZIndex);
z-index: 2000000;
--pswp-bg: var(--modalBg);
}
.pswp__bg { .pswp__bg {
background: var(--modalBg); background: var(--modalBg);
backdrop-filter: var(--modalBgFilter); backdrop-filter: var(--modalBgFilter);
@ -219,7 +222,7 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
align-items: center; align-items: center;
position: absolute; position: absolute;
bottom: 30px; bottom: 20px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);

View file

@ -36,7 +36,7 @@
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)"> <button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
<span :class="$style.caret"><i class="ti ti-caret-right ti-fw"></i></span> <span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
</button> </button>
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>

View file

@ -4,7 +4,7 @@
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
:class="$style.root" :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined" :tabindex="!isDeleted ? '-1' : undefined"
> >
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
@ -32,6 +32,7 @@
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span> </span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div> </div>
</div> </div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
@ -76,14 +77,14 @@
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div> </div>
<MkReactionsViewer :note="appearNote" :max-number="16">
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
</button>
</template>
</MkReactionsViewer>
<footer :class="$style.footer"> <footer :class="$style.footer">
<MkReactionsViewer :note="appearNote" :max-number="16">
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
</button>
</template>
</MkReactionsViewer>
<button :class="$style.footerButton" class="_button" @click="reply()"> <button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i> <i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p>
@ -156,6 +157,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary'; import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -255,9 +257,19 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.apiWithDialog('notes/create', { const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
channelId: appearNote.channelId, channelId: appearNote.channelId,
}).then(() => {
os.toast(i18n.ts.renoted);
}); });
}, },
}, { }, {
@ -276,8 +288,18 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.apiWithDialog('notes/create', { const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
}).then(() => {
os.toast(i18n.ts.renoted);
}); });
}, },
}, { }, {
@ -443,6 +465,34 @@ function showReactions(): void {
&:hover > .article > .main > .footer > .footerButton { &:hover > .article > .main > .footer > .footerButton {
opacity: 1; opacity: 1;
} }
&.showActionsOnlyHover {
.footer {
visibility: hidden;
position: absolute;
top: 12px;
right: 12px;
padding: 0 4px;
margin-bottom: 0 !important;
background: var(--popup);
border-radius: 8px;
box-shadow: 0px 4px 32px var(--shadow);
}
.footerButton {
font-size: 90%;
&:not(:last-child) {
margin-right: 0;
}
}
}
&.showActionsOnlyHover:hover {
.footer {
visibility: visible;
}
}
} }
.tip { .tip {
@ -541,14 +591,15 @@ function showReactions(): void {
} }
.article { .article {
position: relative;
display: flex; display: flex;
padding: 28px 32px 18px; padding: 28px 32px;
} }
.avatar { .avatar {
flex-shrink: 0; flex-shrink: 0;
display: block !important; display: block !important;
margin: 0 14px 8px 0; margin: 0 14px 0 0;
width: 58px; width: 58px;
height: 58px; height: 58px;
position: sticky !important; position: sticky !important;
@ -571,9 +622,9 @@ function showReactions(): void {
.showLess { .showLess {
width: 100%; width: 100%;
margin-top: 1em; margin-top: 14px;
position: sticky; position: sticky;
bottom: 1em; bottom: calc(var(--stickyBottom, 0px) + 14px);
} }
.showLessLabel { .showLessLabel {
@ -653,6 +704,10 @@ function showReactions(): void {
font-size: 80%; font-size: 80%;
} }
.footer {
margin-bottom: -14px;
}
.footerButton { .footerButton {
margin: 0; margin: 0;
padding: 8px; padding: 8px;
@ -683,7 +738,7 @@ function showReactions(): void {
} }
.article { .article {
padding: 24px 26px 14px; padding: 24px 26px;
} }
.avatar { .avatar {
@ -702,7 +757,11 @@ function showReactions(): void {
} }
.article { .article {
padding: 20px 22px 12px; padding: 20px 22px;
}
.footer {
margin-bottom: -8px;
} }
} }
@ -721,13 +780,13 @@ function showReactions(): void {
} }
.article { .article {
padding: 14px 16px 9px; padding: 14px 16px;
} }
} }
@container (max-width: 450px) { @container (max-width: 450px) {
.avatar { .avatar {
margin: 0 10px 8px 0; margin: 0 10px 0 0;
width: 46px; width: 46px;
height: 46px; height: 46px;
top: calc(14px + var(--stickyTop, 0px)); top: calc(14px + var(--stickyTop, 0px));
@ -735,17 +794,21 @@ function showReactions(): void {
} }
@container (max-width: 400px) { @container (max-width: 400px) {
.footerButton { .root:not(.showActionsOnlyHover) {
&:not(:last-child) { .footerButton {
margin-right: 18px; &:not(:last-child) {
margin-right: 18px;
}
} }
} }
} }
@container (max-width: 350px) { @container (max-width: 350px) {
.footerButton { .root:not(.showActionsOnlyHover) {
&:not(:last-child) { .footerButton {
margin-right: 12px; &:not(:last-child) {
margin-right: 12px;
}
} }
} }
} }
@ -756,9 +819,11 @@ function showReactions(): void {
height: 44px; height: 44px;
} }
.footerButton { .root:not(.showActionsOnlyHover) {
&:not(:last-child) { .footerButton {
margin-right: 8px; &:not(:last-child) {
margin-right: 8px;
}
} }
} }
} }

View file

@ -161,6 +161,7 @@ import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -250,9 +251,19 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.apiWithDialog('notes/create', { const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
channelId: appearNote.channelId, channelId: appearNote.channelId,
}).then(() => {
os.toast(i18n.ts.renoted);
}); });
}, },
}, { }, {
@ -271,8 +282,18 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
os.apiWithDialog('notes/create', { const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.id,
}).then(() => {
os.toast(i18n.ts.renoted);
}); });
}, },
}, { }, {

View file

@ -18,6 +18,7 @@
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span> </span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div> </div>
</header> </header>
</template> </template>

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