mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-28 16:38:23 +01:00
Merge branch 'develop' into re-ed25519
This commit is contained in:
commit
c61982574a
147 changed files with 5738 additions and 705 deletions
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
|
@ -6,7 +6,7 @@
|
|||
'packages/backend:test':
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['packages/backend/test/**/*']
|
||||
- any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*']
|
||||
|
||||
'packages/frontend':
|
||||
- any:
|
||||
|
|
59
.github/workflows/test-federation.yml
vendored
Normal file
59
.github/workflows/test-federation.yml
vendored
Normal file
|
@ -0,0 +1,59 @@
|
|||
name: Test (federation)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.16.0]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Install FFmpeg
|
||||
uses: FedericoCarboni/setup-ffmpeg@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- name: Build Misskey
|
||||
run: |
|
||||
corepack enable && corepack prepare
|
||||
pnpm i --frozen-lockfile
|
||||
pnpm build
|
||||
- name: Setup
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
bash ./setup.sh
|
||||
sudo chmod 644 ./certificates/*.test.key
|
||||
- name: Start servers
|
||||
# https://github.com/docker/compose/issues/1294#issuecomment-374847206
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose up -d --scale tester=0
|
||||
- name: Test
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose run --no-deps tester
|
||||
- name: Stop servers
|
||||
run: |
|
||||
cd packages/backend/test-federation
|
||||
docker compose down
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -37,7 +37,7 @@ coverage
|
|||
!/.config/docker_example.env
|
||||
!/.config/cypress-devcontainer.yml
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
./compose.yml
|
||||
.devcontainer/compose.yml
|
||||
!/.devcontainer/compose.yml
|
||||
|
||||
|
|
47
CHANGELOG.md
47
CHANGELOG.md
|
@ -1,8 +1,45 @@
|
|||
## 2024.10.2
|
||||
|
||||
### General
|
||||
- Feat: コンテンツの表示にログインを必須にできるように
|
||||
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
|
||||
|
||||
### Client
|
||||
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/751)
|
||||
- Enhance: ドライブでソートができるように
|
||||
- Enhance: アイコンデコレーション管理画面の改善
|
||||
- Enhance: 「単なるラッキー」の取得条件を変更
|
||||
- Enhance: 投稿フォームでEscキーを押したときIME入力中ならフォームを閉じないように( #10866 )
|
||||
- Enhance: MiAuth, OAuthの認可画面の改善
|
||||
- どのアカウントで認証しようとしているのかがわかるように
|
||||
- 認証するアカウントを切り替えられるように
|
||||
- Enhance: Self-XSS防止用の警告を追加
|
||||
- Enhance: カタルーニャ語 (ca-ES) に対応
|
||||
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
||||
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
||||
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
|
||||
- Fix: Encode RSS urls with escape sequences before fetching allowing query parameters to be used
|
||||
- Fix: リンク切れを修正
|
||||
|
||||
### Server
|
||||
- Enhance: 起動前の疎通チェックで、DBとメイン以外のRedisの疎通確認も行うように
|
||||
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/588)
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/715)
|
||||
- Fix: Nested proxy requestsを検出した際にブロックするように
|
||||
[ghsa-gq5q-c77c-v236](https://github.com/misskey-dev/misskey/security/advisories/ghsa-gq5q-c77c-v236)
|
||||
- Fix: 招待コードの発行可能な残り数算出に使用すべきロールポリシーの値が違う問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/706)
|
||||
|
||||
### Misskey.js
|
||||
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正
|
||||
|
||||
## 2024.10.1
|
||||
|
||||
### Note
|
||||
- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、
|
||||
7日間活動していない場合は自動的に招待制へと移行(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。
|
||||
詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
|
||||
- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替え(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 )
|
||||
- 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。
|
||||
|
||||
### General
|
||||
- Feat: ユーザーの名前に禁止ワードを設定できるように
|
||||
|
@ -14,12 +51,10 @@
|
|||
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
|
||||
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 )
|
||||
- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
|
||||
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
|
||||
- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正
|
||||
|
||||
### Server
|
||||
- Fix: キューのエラーログを簡略化するように
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
|
||||
|
||||
|
|
|
@ -64,6 +64,22 @@ Thank you for your PR! Before creating a PR, please check the following:
|
|||
|
||||
Thanks for your cooperation 🤗
|
||||
|
||||
### Additional things for ActivityPub payload changes
|
||||
*This section is specific to misskey-dev implementation. Other fork or implementation may take different way. A significant difference is that non-"misskey-dev" extension is not described in the misskey-hub's document.*
|
||||
|
||||
If PR includes changes to ActivityPub payload, please reflect it in [misskey-hub's document](https://github.com/misskey-dev/misskey-hub-next/blob/master/content/ns.md) by sending PR.
|
||||
|
||||
The name of purporsed extension property (referred as "extended property" in later) to ActivityPub shall be prefixed by `_misskey_`. (i.e. `_misskey_quote`)
|
||||
|
||||
The extended property in `packages/backend/src/core/activitypub/type.ts` **must** be declared as optional because ActivityPub payloads that comes from older Misskey or other implementation may not contain it.
|
||||
|
||||
The extended property must be included in the context definition. Context is defined in `packages/backend/src/core/activitypub/misc/contexts.ts`.
|
||||
The key shall be same as the name of extended property, and the value shall be same as "short IRI".
|
||||
|
||||
"Short IRI" is defined in misskey-hub's document, but usually takes form of `misskey:<name of extended property>`. (i.e. `misskey:_misskey_quote`)
|
||||
|
||||
One should not add property that has defined before by other implementation, or add custom variant value to "well-known" property.
|
||||
|
||||
## Reviewers guide
|
||||
Be willing to comment on the good points and not just the things you want fixed 💯
|
||||
|
||||
|
@ -116,7 +132,8 @@ You can improve our translations with your Crowdin account.
|
|||
Your changes in Crowdin are automatically submitted as a PR (with the title "New Crowdin translations") to the repository.
|
||||
The owner [@syuilo](https://github.com/syuilo) merges the PR into the develop branch before the next release.
|
||||
|
||||
If your language is not listed in Crowdin, please open an issue.
|
||||
If your language is not listed in Crowdin, please open an issue. We will add it to Crowdin.
|
||||
For newly added languages, once the translation progress per language exceeds 70%, it will be officially introduced into Misskey and made available to users.
|
||||
|
||||
![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg)
|
||||
|
||||
|
@ -181,31 +198,45 @@ MK_DEV_PREFER=backend pnpm dev
|
|||
- HMR may not work in some environments such as Windows.
|
||||
|
||||
## Testing
|
||||
- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
|
||||
|
||||
### Run test
|
||||
Create a config file.
|
||||
You can run non-backend tests by executing following commands:
|
||||
```sh
|
||||
pnpm --filter frontend test
|
||||
pnpm --filter misskey-js test
|
||||
```
|
||||
|
||||
Backend tests require manual preparation of servers. See the next section for more on this.
|
||||
|
||||
### Backend
|
||||
There are three types of test codes for the backend:
|
||||
- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit)
|
||||
- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e)
|
||||
- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation)
|
||||
|
||||
#### Running Unit Tests or Single-server E2E Tests
|
||||
1. Create a config file:
|
||||
```sh
|
||||
cp .github/misskey/test.yml .config/
|
||||
```
|
||||
Prepare DB/Redis for testing.
|
||||
```
|
||||
|
||||
2. Start DB and Redis servers for testing:
|
||||
```sh
|
||||
docker compose -f packages/backend/test/compose.yml up
|
||||
```
|
||||
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
|
||||
Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately.
|
||||
|
||||
Run all test.
|
||||
3. Run all tests:
|
||||
```sh
|
||||
pnpm --filter backend test # unit tests
|
||||
pnpm --filter backend test:e2e # single-server E2E tests
|
||||
```
|
||||
pnpm test
|
||||
If you want to run a specific test, run as a following command:
|
||||
```sh
|
||||
pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
|
||||
pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts
|
||||
```
|
||||
|
||||
#### Run specify test
|
||||
```
|
||||
pnpm jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
TODO
|
||||
#### Running Multiple-server E2E Tests
|
||||
See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md).
|
||||
|
||||
## Environment Variable
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
_lang_: "Català"
|
||||
headlineMisskey: "Una xarxa connectada per notes"
|
||||
introMisskey: "Benvingut! Misskey és un servei de microblogging descentralitzat de codi obert.\nCrea \"notes\" per compartir els teus pensaments amb tots els que t'envolten. 📡\nAmb \"reaccions\", també pots expressar ràpidament els teus sentiments sobre les notes de tothom. 👍\nExplorem un món nou! 🚀"
|
||||
poweredByMisskeyDescription: "{name} És un del serveis (anomenats instàncies de Misskey) que utilitzen la plataforma de codi obert <b>Misskey</b>."
|
||||
poweredByMisskeyDescription: "{name} És un dels serveis (anomenats instàncies de Misskey) que utilitzen la plataforma de codi obert <b>Misskey</b>."
|
||||
monthAndDay: "{day}/{month}"
|
||||
search: "Cercar"
|
||||
notifications: "Notificacions"
|
||||
|
@ -10,6 +10,7 @@ username: "Nom d'usuari"
|
|||
password: "Contrasenya"
|
||||
initialPasswordForSetup: "Contrasenya inicial per la configuració inicial"
|
||||
initialPasswordIsIncorrect: "La contrasenya no és correcta."
|
||||
initialPasswordForSetupDescription: "Fes servir la contrasenya que has fet servir al fitxer de configuració, si tu mateix has instal·lat Misskey.\nSi fas servir una empresa d'allotjament de Misskey, fes servir la contrasenya que t'han donat.\nSi no has posat cap contrasenya deixar l'espai en blanc."
|
||||
forgotPassword: "Contrasenya oblidada"
|
||||
fetchingAsApObject: "Cercant en el Fediverse..."
|
||||
ok: "OK"
|
||||
|
@ -17,7 +18,7 @@ gotIt: "Ho he entès!"
|
|||
cancel: "Cancel·lar"
|
||||
noThankYou: "No, gràcies"
|
||||
enterUsername: "Introdueix el teu nom d'usuari"
|
||||
renotedBy: "Impulsat per {usuari}"
|
||||
renotedBy: "Impulsat per {user}"
|
||||
noNotes: "Cap nota"
|
||||
noNotifications: "Cap notificació"
|
||||
instance: "Servidor"
|
||||
|
@ -946,6 +947,9 @@ oneHour: "1 hora"
|
|||
oneDay: "Un dia"
|
||||
oneWeek: "Una setmana"
|
||||
oneMonth: "Un mes"
|
||||
threeMonths: "3 mesos"
|
||||
oneYear: "1 any"
|
||||
threeDays: "3 dies"
|
||||
reflectMayTakeTime: "Això pot trigar una estona a tenir efecte"
|
||||
failedToFetchAccountInformation: "No es pot obtenir la informació del compte"
|
||||
rateLimitExceeded: "S'ha arribat al màxim de peticions"
|
||||
|
@ -1086,6 +1090,7 @@ retryAllQueuesConfirmTitle: "Tornar a intentar-ho tot?"
|
|||
retryAllQueuesConfirmText: "Això farà que la càrrega del servidor augmenti temporalment."
|
||||
enableChartsForRemoteUser: "Generar gràfiques d'usuaris remots"
|
||||
enableChartsForFederatedInstances: "Generar gràfiques d'instàncies remotes"
|
||||
enableStatsForFederatedInstances: "Activa les estadístiques de les instàncies remotes federades"
|
||||
showClipButtonInNoteFooter: "Afegir \"Retall\" al menú d'acció de la nota"
|
||||
reactionsDisplaySize: "Mida de les reaccions"
|
||||
limitWidthOfReaction: "Limitar l'amplada màxima de la reacció i mostrar-les en una mida reduïda "
|
||||
|
@ -1287,6 +1292,25 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la
|
|||
messageToFollower: "Missatge als meus seguidors"
|
||||
target: "Assumpte "
|
||||
testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
|
||||
prohibitedWordsForNameOfUser: "Noms prohibits per escollir noms d'usuari "
|
||||
prohibitedWordsForNameOfUserDescription: "Si qualsevol d'aquestes paraules es troben a un nom d'usuari la creació de l'usuari no es durà a terme. Als moderadors no els afecta aquesta restricció."
|
||||
yourNameContainsProhibitedWords: "El nom conté paraules prohibides "
|
||||
yourNameContainsProhibitedWordsDescription: "Si de veritat vols fer servir aquest nom posat en contacte amb l'administrador."
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "L'autor requereix l'inici de sessió per poder veure"
|
||||
lockdown: "Bloquejat"
|
||||
pleaseSelectAccount: "Seleccionar un compte"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "És obligatori l'inici de sessió per poder veure el contingut"
|
||||
requireSigninToViewContentsDescription1: "Es requereix l'inici de sessió per poder veure totes les notes i el contingut que has creat. Amb això esperem evitar que els rastrejadors recopilin informació."
|
||||
requireSigninToViewContentsDescription2: "També es desactivaran les vistes prèvies d'URLS (OGP), la incrustació a pàgines web i la visualització des de servidors que no admetin la citació de notes."
|
||||
requireSigninToViewContentsDescription3: "Aquestes restriccions pot ser que no s'apliquin als continguts federats en servidors remots."
|
||||
makeNotesFollowersOnlyBefore: "Permetre que les notes antigues només es mostrin als seguidors."
|
||||
makeNotesFollowersOnlyBeforeDescription: "Mentre aquesta funció estigui activada, les notes que hagin passat la data i hora fixada o hagi passat els temps establert seran visibles només per als teus seguidors. Quan es desactivi, també es restableix l'estat públic de la nota."
|
||||
makeNotesHiddenBefore: "Fes que les notes antigues siguin privades"
|
||||
makeNotesHiddenBeforeDescription: "Mentres aquesta funció estigui activada les notes que hagin superat una data i hora fixada o hagi passat el temps establert només seran visibles per a tu. Si la desactives es restablirà també l'estat públic de les notes."
|
||||
mayNotEffectForFederatedNotes: "Això pot ser que no afecti les notes federades."
|
||||
notesHavePassedSpecifiedPeriod: "Notes publicades durant un període de temps especificat."
|
||||
notesOlderThanSpecifiedDateAndTime: "Notes més antigues de la data i temps especificat "
|
||||
_abuseUserReport:
|
||||
forward: "Reenviar "
|
||||
forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
|
||||
|
@ -2151,8 +2175,11 @@ _auth:
|
|||
permissionAsk: "Aquesta aplicació demana els següents permisos"
|
||||
pleaseGoBack: "Si us plau, torna a l'aplicació"
|
||||
callback: "Tornant a l'aplicació"
|
||||
accepted: "Accés garantit"
|
||||
denied: "Accés denegat"
|
||||
scopeUser: "Opera com si fossis aquest usuari"
|
||||
pleaseLogin: "Si us plau, identificat per autoritzar l'aplicació."
|
||||
byClickingYouWillBeRedirectedToThisUrl: "Si es garanteix l'accés, seràs redirigit automàticament a la següent adreça URL"
|
||||
_antennaSources:
|
||||
all: "Totes les publicacions"
|
||||
homeTimeline: "Publicacions dels usuaris seguits"
|
||||
|
@ -2402,7 +2429,8 @@ _notification:
|
|||
renotedBySomeUsers: "L'han impulsat {n} usuaris"
|
||||
followedBySomeUsers: "Et segueixen {n} usuaris"
|
||||
flushNotification: "Netejar notificacions"
|
||||
exportOfXCompleted: "Completada l'exportació de {n}"
|
||||
exportOfXCompleted: "Completada l'exportació de {x}"
|
||||
login: "Algú ha iniciat sessió "
|
||||
_types:
|
||||
all: "Tots"
|
||||
note: "Notes noves"
|
||||
|
@ -2485,6 +2513,8 @@ _webhookSettings:
|
|||
abuseReport: "Quan reps un nou informe de moderació "
|
||||
abuseReportResolved: "Quan resols un informe de moderació "
|
||||
userCreated: "Quan es crea un usuari"
|
||||
inactiveModeratorsWarning: "Quan el compte d'un moderador no té activitat durant un temps"
|
||||
inactiveModeratorsInvitationOnlyChanged: "Quan el compte d'un moderador no té activitat durant un temps, i el servidor es canvia a registre per invitacions"
|
||||
deleteConfirm: "Segur que vols esborrar el webhook?"
|
||||
testRemarks: "Si feu clic al botó a la dreta de l'interruptor, podeu enviar un webhook de prova amb dades dummy."
|
||||
_abuseReport:
|
||||
|
@ -2612,8 +2642,81 @@ _dataSaver:
|
|||
description: "Les imatges en miniatura que serveixen com a vista prèvia de les URLs no es tornaran a carregar."
|
||||
_code:
|
||||
title: "Ressaltat del codi "
|
||||
description: "Quan s'utilitza codi MFM, no es llegeix fins que es copiï. En els punts destacats del codi s'han de llegir els fitxers definits per a cada llengua que resulti alt, però no es poden llegir automàticament, per la qual cosa es poden reduir les quantitats de comunicació."
|
||||
_hemisphere:
|
||||
N: "Hemisferi Nord "
|
||||
S: "Hemisferi Sud"
|
||||
caption: "El fan servir alguns clients per determinar l'estació de l'any."
|
||||
_reversi:
|
||||
reversi: "Reversi"
|
||||
gameSettings: "Opcions del joc"
|
||||
chooseBoard: "Escull un taulell"
|
||||
blackOrWhite: "Negres/Blanques"
|
||||
blackIs: "{name} juga amb negres "
|
||||
rules: "Regles"
|
||||
thisGameIsStartedSoon: "El joc començarà en breu"
|
||||
waitingForOther: "Esperant la tirada de l'oponent "
|
||||
waitingForMe: "Esperant el teu torn"
|
||||
waitingBoth: "Prepara't "
|
||||
ready: "Preparat "
|
||||
cancelReady: " No preparat "
|
||||
opponentTurn: "Torn de l'oponent "
|
||||
myTurn: "El teu torn"
|
||||
turnOf: "Li toca a {name}"
|
||||
pastTurnOf: "Torn de {name}"
|
||||
surrender: "Rendeix-te"
|
||||
surrendered: "T'has rendit"
|
||||
timeout: "Temps esgotat"
|
||||
drawn: "Empat"
|
||||
won: "{name} ha guanyat"
|
||||
black: "Negres"
|
||||
white: "Blanques"
|
||||
total: "Total"
|
||||
turnCount: "Torn {count}"
|
||||
myGames: "Jugades"
|
||||
allGames: "Totes les jugades"
|
||||
ended: "Acabat"
|
||||
playing: "Jugant"
|
||||
isLlotheo: "Qui tingui menys pedres guanya (Llotheo)"
|
||||
loopedMap: "Mapa de recursiu"
|
||||
canPutEverywhere: "Les fitxes es poden posar a qualsevol lloc"
|
||||
timeLimitForEachTurn: "Temps límit per jugada"
|
||||
freeMatch: "Partida lliure"
|
||||
lookingForPlayer: "Buscant contrincant..."
|
||||
gameCanceled: "La partida s'ha cancel·lat "
|
||||
shareToTlTheGameWhenStart: "Compartir la partida a la línia de temps quan comenci"
|
||||
iStartedAGame: "La partida ha començat! #MisskeyReversi"
|
||||
opponentHasSettingsChanged: "L'oponent h canviat la seva configuració "
|
||||
allowIrregularRules: "Regles irregulars (totalment lliure)"
|
||||
disallowIrregularRules: "Sense regles irregulars"
|
||||
showBoardLabels: "Mostrar el número de línia i columna al tauler de joc"
|
||||
useAvatarAsStone: "Fer servir els avatars dels usuaris com a fitxes"
|
||||
_offlineScreen:
|
||||
title: "Fora de línia - No es pot connectar amb el servidor"
|
||||
header: "Impossible connectar amb el servidor"
|
||||
_urlPreviewSetting:
|
||||
title: "Configuració per a la previsualització de l'URL"
|
||||
enable: "Activa la previsualització de l'URL"
|
||||
timeout: "Temps màxim per carregar la previsualització de l'URL (ms)"
|
||||
timeoutDescription: "Si l'obtenció de la previsualització triga més que el temps establert, no es generarà la vista prèvia."
|
||||
maximumContentLength: "Longitud màxima del contingut (bytes)"
|
||||
maximumContentLengthDescription: "Si la màxima longitud és més gran que aquest valor, la previsualització no es generarà."
|
||||
requireContentLength: "Generar la previsualització només si es pot obtenir la longitud màxima "
|
||||
requireContentLengthDescription: "Si l'altre servidor no proporciona la longitud màxima, la previsualització no es generarà."
|
||||
userAgent: "User-Agent"
|
||||
userAgentDescription: "Estableix l'User-Agent que és farà servir per a la recuperació de la vista prèvia. Si és deixa en blanc es farà servir l'User-Agent per defecte."
|
||||
summaryProxy: "Proxy endpoints per generar vistes prèvies"
|
||||
summaryProxyDescription: "La vista prèvia es genera fent servir Summaly proxy, no la genera el mateix Misskey."
|
||||
summaryProxyDescription2: "Els següents paràmetres són passats al proxy com cadenes de consulta. Si el proxy no els admet, s'ignoren els valors configurats."
|
||||
_mediaControls:
|
||||
pip: "Imatge sobre impressionada "
|
||||
playbackRate: "Velocitat de reproducció "
|
||||
loop: "Reproducció en bucle"
|
||||
_contextMenu:
|
||||
title: "Menú contextual"
|
||||
app: "Aplicació "
|
||||
appWithShift: "Aplicació amb la tecla shift"
|
||||
native: "Interfície del navegador"
|
||||
_embedCodeGen:
|
||||
title: "Personalitza el codi per incrustar"
|
||||
header: "Mostrar la capçalera"
|
||||
|
@ -2628,3 +2731,9 @@ _embedCodeGen:
|
|||
generateCode: "Crea el codi per incrustar"
|
||||
codeGenerated: "Codi generat"
|
||||
codeGeneratedDescription: "Si us plau, enganxeu el codi generat al lloc web."
|
||||
_selfXssPrevention:
|
||||
warning: "Advertència "
|
||||
title: "\"Enganxa qualsevol cosa en aquesta finestra\" És tot un engany."
|
||||
description1: "Si posa alguna cosa al seu compte, un usuari malintencionat podria segrestar-la o robar-li les dades."
|
||||
description2: "Si no entens que estàs fent %cpara ara mateix i tanca la finestra."
|
||||
description3: "Per obtenir més informació. {link}"
|
||||
|
|
|
@ -331,7 +331,6 @@ selectFile: "Select a file"
|
|||
selectFiles: "Select files"
|
||||
selectFolder: "Select a folder"
|
||||
selectFolders: "Select folders"
|
||||
fileNotSelected: ""
|
||||
renameFile: "Rename file"
|
||||
folderName: "Folder name"
|
||||
createFolder: "Create a folder"
|
||||
|
@ -947,6 +946,9 @@ oneHour: "One hour"
|
|||
oneDay: "One day"
|
||||
oneWeek: "One week"
|
||||
oneMonth: "One month"
|
||||
threeMonths: "3 months"
|
||||
oneYear: "1 year"
|
||||
threeDays: "3 days"
|
||||
reflectMayTakeTime: "It may take some time for this to be reflected."
|
||||
failedToFetchAccountInformation: "Could not fetch account information"
|
||||
rateLimitExceeded: "Rate limit exceeded"
|
||||
|
@ -1087,6 +1089,7 @@ retryAllQueuesConfirmTitle: "Really retry all?"
|
|||
retryAllQueuesConfirmText: "This will temporarily increase the server load."
|
||||
enableChartsForRemoteUser: "Generate remote user data charts"
|
||||
enableChartsForFederatedInstances: "Generate remote instance data charts"
|
||||
enableStatsForFederatedInstances: "Receive remote server stats"
|
||||
showClipButtonInNoteFooter: "Add \"Clip\" to note action menu"
|
||||
reactionsDisplaySize: "Reaction display size"
|
||||
limitWidthOfReaction: "Limit the maximum width of reactions and display them in reduced size."
|
||||
|
@ -1287,6 +1290,26 @@ passkeyVerificationFailed: "Passkey verification has failed."
|
|||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
|
||||
messageToFollower: "Message to followers"
|
||||
target: "Target"
|
||||
testCaptchaWarning: "This function is intended for CAPTCHA testing purposes.\n<strong>Do not use in a production environment.</strong>"
|
||||
prohibitedWordsForNameOfUser: "Prohibited words for user names"
|
||||
prohibitedWordsForNameOfUserDescription: "If any of the strings in this list are included in the user's name, the name will be denied. Users with moderator privileges are not affected by this restriction."
|
||||
yourNameContainsProhibitedWords: "Your name contains prohibited words"
|
||||
yourNameContainsProhibitedWordsDescription: "If you wish to use this name, please contact your server administrator."
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view"
|
||||
lockdown: "Lockdown"
|
||||
pleaseSelectAccount: "Select an account"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "Require sign-in to view contents"
|
||||
requireSigninToViewContentsDescription1: "Require login to view all notes and other content you have created. This will have the effect of preventing crawlers from collecting your information."
|
||||
requireSigninToViewContentsDescription2: "Content will not be displayed in URL previews (OGP), embedded in web pages, or on servers that don't support note quotes."
|
||||
requireSigninToViewContentsDescription3: "These restrictions may not apply to federated content from other remote servers."
|
||||
makeNotesFollowersOnlyBefore: "Make past notes to be displayed only to followers"
|
||||
makeNotesFollowersOnlyBeforeDescription: "While this feature is enabled, only followers can see notes past the set date and time or have been visible for a set time. When it is deactivated, the note publication status will also be restored."
|
||||
makeNotesHiddenBefore: "Make past notes private"
|
||||
makeNotesHiddenBeforeDescription: "While this feature is enabled, notes that are past the set date and time or have been visible only to you. When it is deactivated, the note publication status will also be restored."
|
||||
mayNotEffectForFederatedNotes: "Notes federated to a remote server may not be effective."
|
||||
notesHavePassedSpecifiedPeriod: "Note that the specified time has passed"
|
||||
notesOlderThanSpecifiedDateAndTime: "Notes before the specified date and time"
|
||||
_abuseUserReport:
|
||||
forward: "Forward"
|
||||
forwardDescription: "Forward the report to a remote server as an anonymous system account."
|
||||
|
@ -1431,6 +1454,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "When enabled, performance during reaction creation will be greatly improved, reducing the load on the database. However, Redis memory usage will increase."
|
||||
inquiryUrl: "Inquiry URL"
|
||||
inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information."
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "If no moderator activity is detected for a while, this setting will be automatically turned off to prevent spam."
|
||||
_accountMigration:
|
||||
moveFrom: "Migrate another account to this one"
|
||||
moveFromSub: "Create alias to another account"
|
||||
|
@ -2150,8 +2174,11 @@ _auth:
|
|||
permissionAsk: "This application requests the following permissions"
|
||||
pleaseGoBack: "Please go back to the application"
|
||||
callback: "Returning to the application"
|
||||
accepted: "Access granted"
|
||||
denied: "Access denied"
|
||||
scopeUser: "Operate as the following user"
|
||||
pleaseLogin: "Please log in to authorize applications."
|
||||
byClickingYouWillBeRedirectedToThisUrl: "When access is granted, you will automatically be redirected to the following URL"
|
||||
_antennaSources:
|
||||
all: "All notes"
|
||||
homeTimeline: "Notes from followed users"
|
||||
|
@ -2485,6 +2512,8 @@ _webhookSettings:
|
|||
abuseReport: "When received a new report"
|
||||
abuseReportResolved: "When resolved report"
|
||||
userCreated: "When user is created"
|
||||
inactiveModeratorsWarning: "When moderators have been inactive for a while"
|
||||
inactiveModeratorsInvitationOnlyChanged: "When a moderator has been inactive for a while, and the server is changed to invitation-only"
|
||||
deleteConfirm: "Are you sure you want to delete the Webhook?"
|
||||
testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data."
|
||||
_abuseReport:
|
||||
|
|
|
@ -8,6 +8,8 @@ search: "Buscar"
|
|||
notifications: "Notificaciones"
|
||||
username: "Nombre de usuario"
|
||||
password: "Contraseña"
|
||||
initialPasswordForSetup: "Contraseña para iniciar la inicialización"
|
||||
initialPasswordIsIncorrect: "La contraseña para iniciar la configuración inicial es incorrecta."
|
||||
forgotPassword: "Olvidé mi contraseña"
|
||||
fetchingAsApObject: "Buscando en el fediverso"
|
||||
ok: "OK"
|
||||
|
@ -502,6 +504,8 @@ uiLanguage: "Idioma de visualización de la interfaz"
|
|||
aboutX: "Acerca de {x}"
|
||||
emojiStyle: "Estilo de emoji"
|
||||
native: "Nativo"
|
||||
menuStyle: "Diseño del menú"
|
||||
style: "Diseño"
|
||||
showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor"
|
||||
showReactionsCount: "Mostrar el número de reacciones en las notas"
|
||||
noHistory: "No hay datos en el historial"
|
||||
|
@ -925,6 +929,9 @@ oneHour: "1 hora"
|
|||
oneDay: "1 día"
|
||||
oneWeek: "1 semana"
|
||||
oneMonth: "1 mes"
|
||||
threeMonths: "Tres meses"
|
||||
oneYear: "Un año"
|
||||
threeDays: "Tres días"
|
||||
reflectMayTakeTime: "Puede pasar un tiempo hasta que se reflejen los cambios"
|
||||
failedToFetchAccountInformation: "No se pudo obtener información de la cuenta"
|
||||
rateLimitExceeded: "Se excedió el límite de peticiones"
|
||||
|
@ -1240,6 +1247,14 @@ useNativeUIForVideoAudioPlayer: "Usar la interfaz del navegador cuando se reprod
|
|||
keepOriginalFilename: "Mantener el nombre original del archivo"
|
||||
noDescription: "No hay descripción"
|
||||
alwaysConfirmFollow: "Confirmar siempre cuando se sigue a alguien"
|
||||
inquiry: "Contacto"
|
||||
tryAgain: "Por favor , inténtalo de nuevo"
|
||||
performance: "Rendimiento"
|
||||
unknownWebAuthnKey: "Esto no se ha registrado llave maestra."
|
||||
messageToFollower: "Mensaje a seguidores"
|
||||
_abuseUserReport:
|
||||
accept: "Acepte"
|
||||
reject: "repudio"
|
||||
_delivery:
|
||||
stop: "Suspendido"
|
||||
_type:
|
||||
|
@ -2340,6 +2355,7 @@ _notification:
|
|||
roleAssigned: "Rol asignado"
|
||||
achievementEarned: "Logro desbloqueado"
|
||||
login: "Iniciar sesión"
|
||||
test: "Pruebas de nofiticaciones"
|
||||
app: "Notificaciones desde aplicaciones"
|
||||
_actions:
|
||||
followBack: "Te sigue de vuelta"
|
||||
|
@ -2398,6 +2414,8 @@ _webhookSettings:
|
|||
renote: "Cuando reciba un \"re-note\""
|
||||
reaction: "Cuando se recibe una reacción"
|
||||
mention: "Cuando hay una mención"
|
||||
_systemEvents:
|
||||
userCreated: "Cuando se crea el usuario."
|
||||
_abuseReport:
|
||||
_notificationRecipient:
|
||||
_recipientType:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
_lang_: "Japán"
|
||||
_lang_: "Magyar"
|
||||
monthAndDay: "{month}.{day}."
|
||||
search: "Keresés"
|
||||
notifications: "Értesítések"
|
||||
|
|
|
@ -196,6 +196,7 @@ followConfirm: "Apakah kamu yakin ingin mengikuti {name}?"
|
|||
proxyAccount: "Akun proksi"
|
||||
proxyAccountDescription: "Akun proksi merupakan sebuah akun yang bertindak sebagai pengikut instansi luar untuk pengguna dalam kondisi tertentu. Sebagai contoh, ketika pengguna menambahkan seorang pengguna instansi luar ke dalam daftar, aktivitas dari pengguna instansi luar tidak akan disampaikan ke instansi apabila tidak ada pengguna lokal yang mengikuti pengguna tersebut, dengan begitu akun proksilah yang akan mengikutinya."
|
||||
host: "Host"
|
||||
selectSelf: "Pilih diri sendiri"
|
||||
selectUser: "Pilih pengguna"
|
||||
recipient: "Penerima"
|
||||
annotation: "Keterangan konten"
|
||||
|
@ -232,6 +233,7 @@ blockedInstances: "Instansi terblokir"
|
|||
blockedInstancesDescription: "Daftar nama host dari instansi yang diperlukan untuk diblokir. Instansi yang didaftarkan tidak akan dapat berkomunikasi dengan instansi ini."
|
||||
silencedInstances: "Instansi yang disenyapkan"
|
||||
silencedInstancesDescription: "Daftar nama host dari instansi yang ingin kamu senyapkan. Semua akun dari instansi yang terdaftar akan diperlakukan sebagai disenyapkan. Hal ini membuat akun hanya dapat membuat permintaan mengikuti, dan tidak dapat menyebutkan akun lokal apabila tidak mengikuti. Hal ini tidak akan mempengaruhi instansi yang diblokir."
|
||||
federationAllowedHosts: "Server yang membolehkan federasi"
|
||||
muteAndBlock: "Bisukan / Blokir"
|
||||
mutedUsers: "Pengguna yang dibisukan"
|
||||
blockedUsers: "Pengguna yang diblokir"
|
||||
|
@ -330,6 +332,7 @@ renameFolder: "Ubah nama folder"
|
|||
deleteFolder: "Hapus folder"
|
||||
folder: "Folder"
|
||||
addFile: "Tambahkan berkas"
|
||||
showFile: "Tampilkan berkas"
|
||||
emptyDrive: "Drive kosong"
|
||||
emptyFolder: "Folder kosong"
|
||||
unableToDelete: "Tidak dapat menghapus"
|
||||
|
@ -504,6 +507,8 @@ uiLanguage: "Bahasa antarmuka pengguna"
|
|||
aboutX: "Tentang {x}"
|
||||
emojiStyle: "Gaya emoji"
|
||||
native: "Native"
|
||||
menuStyle: "Gaya menu"
|
||||
style: "Gaya"
|
||||
showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk"
|
||||
showReactionsCount: "Lihat jumlah reaksi dalam catatan"
|
||||
noHistory: "Tidak ada riwayat"
|
||||
|
@ -927,6 +932,9 @@ oneHour: "1 Jam"
|
|||
oneDay: "1 Hari"
|
||||
oneWeek: "1 Bulan"
|
||||
oneMonth: "satu bulan"
|
||||
threeMonths: "3 bulan"
|
||||
oneYear: "1 tahun"
|
||||
threeDays: "3 hari"
|
||||
reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan."
|
||||
failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun"
|
||||
rateLimitExceeded: "Batas sudah terlampaui"
|
||||
|
@ -1101,6 +1109,7 @@ preservedUsernames: "Nama pengguna tercadangkan"
|
|||
preservedUsernamesDescription: "Daftar nama pengguna yang dicadangkan dipisah dengan baris baru. Nama pengguna berikut akan tidak dapat dipakai pada pembuatan akun normal, namun dapat digunakan oleh admin untuk membuat akun baru. Akun yang sudah ada dengan menggunakan nama pengguna ini tidak akan terpengaruh."
|
||||
createNoteFromTheFile: "Buat catatan dari berkas ini"
|
||||
archive: "Arsipkan"
|
||||
archived: "Diarsipkan"
|
||||
channelArchiveConfirmTitle: "Yakin untuk mengarsipkan {name}?"
|
||||
channelArchiveConfirmDescription: "Kanal yang diarsipkan tidak akan muncul pada daftar kanal atau hasil pencarian. Postingan baru juga tidak dapat ditambahkan lagi."
|
||||
thisChannelArchived: "Kanal ini telah diarsipkan."
|
||||
|
@ -1111,6 +1120,7 @@ preventAiLearning: "Tolak penggunaan Pembelajaran Mesin (AI Generatif)"
|
|||
preventAiLearningDescription: "Minta perayap web untuk tidak menggunakan materi teks atau gambar yang telah diposting ke dalam set data Pembelajaran Mesin (Prediktif / Generatif). Hal ini dicapai dengan menambahkan flag HTML-Response \"noai\" ke masing-masing konten. Pencegahan penuh mungkin tidak dapat dicapai dengan flag ini, karena juga dapat diabaikan begitu saja."
|
||||
options: "Opsi peran"
|
||||
specifyUser: "Pengguna spesifik"
|
||||
openTagPageConfirm: "Apakah ingin membuka laman tagar?"
|
||||
failedToPreviewUrl: "Tidak dapat dipratinjau"
|
||||
update: "Perbarui"
|
||||
rolesThatCanBeUsedThisEmojiAsReaction: "Peran yang dapat menggunakan emoji ini sebagai reaksi"
|
||||
|
@ -1243,6 +1253,18 @@ noDescription: "Tidak ada deskripsi"
|
|||
alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti"
|
||||
inquiry: "Hubungi kami"
|
||||
tryAgain: "Silahkan coba lagi."
|
||||
createdLists: "Senarai yang dibuat"
|
||||
createdAntennas: "Antena yang dibuat"
|
||||
fromX: "Dari {x}"
|
||||
noteOfThisUser: "Catatan oleh pengguna ini"
|
||||
clipNoteLimitExceeded: "Klip ini tak bisa ditambahi lagi catatan."
|
||||
performance: "Kinerja"
|
||||
modified: "Diubah"
|
||||
thereAreNChanges: "Ada {n} perubahan"
|
||||
prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna"
|
||||
_abuseUserReport:
|
||||
accept: "Setuju"
|
||||
reject: "Tolak"
|
||||
_delivery:
|
||||
status: "Status pengiriman"
|
||||
stop: "Ditangguhkan"
|
||||
|
@ -1707,6 +1729,8 @@ _role:
|
|||
canSearchNotes: "Penggunaan pencarian catatan"
|
||||
canUseTranslator: "Penggunaan penerjemah"
|
||||
avatarDecorationLimit: "Jumlah maksimum dekorasi avatar yang dapat diterapkan"
|
||||
canImportAntennas: "Izinkan mengimpor antena"
|
||||
canImportUserLists: "Izinkan mengimpor senarai"
|
||||
_condition:
|
||||
roleAssignedTo: "Ditugaskan ke peran manual"
|
||||
isLocal: "Pengguna lokal"
|
||||
|
@ -1943,6 +1967,7 @@ _soundSettings:
|
|||
driveFileTypeWarnDescription: "Pilih berkas audio"
|
||||
driveFileDurationWarn: "Audio ini terlalu panjang"
|
||||
driveFileDurationWarnDescription: "Audio panjang dapat mengganggu penggunaan Misskey. Masih ingin melanjutkan?"
|
||||
driveFileError: "Tak bisa memuat audio. Mohon ubah pengaturan"
|
||||
_ago:
|
||||
future: "Masa depan"
|
||||
justNow: "Baru saja"
|
||||
|
@ -2415,6 +2440,8 @@ _abuseReport:
|
|||
_notificationRecipient:
|
||||
_recipientType:
|
||||
mail: "Surel"
|
||||
webhook: "Webhook"
|
||||
keywords: "Kata kunci"
|
||||
_moderationLogTypes:
|
||||
createRole: "Peran telah dibuat"
|
||||
deleteRole: "Peran telah dihapus"
|
||||
|
@ -2452,6 +2479,7 @@ _moderationLogTypes:
|
|||
deleteAvatarDecoration: "Hapus dekorasi avatar"
|
||||
unsetUserAvatar: "Hapus avatar pengguna"
|
||||
unsetUserBanner: "Hapus banner pengguna"
|
||||
deleteAccount: "Akun dihapus"
|
||||
_fileViewer:
|
||||
title: "Rincian berkas"
|
||||
type: "Jenis berkas"
|
||||
|
|
114
locales/index.d.ts
vendored
114
locales/index.d.ts
vendored
|
@ -3806,6 +3806,18 @@ export interface Locale extends ILocale {
|
|||
* 1ヶ月
|
||||
*/
|
||||
"oneMonth": string;
|
||||
/**
|
||||
* 3ヶ月
|
||||
*/
|
||||
"threeMonths": string;
|
||||
/**
|
||||
* 1年
|
||||
*/
|
||||
"oneYear": string;
|
||||
/**
|
||||
* 3日
|
||||
*/
|
||||
"threeDays": string;
|
||||
/**
|
||||
* 反映されるまで時間がかかる場合があります。
|
||||
*/
|
||||
|
@ -5190,6 +5202,68 @@ export interface Locale extends ILocale {
|
|||
* 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。
|
||||
*/
|
||||
"yourNameContainsProhibitedWordsDescription": string;
|
||||
/**
|
||||
* 投稿者により、表示にはログインが必要と設定されています
|
||||
*/
|
||||
"thisContentsAreMarkedAsSigninRequiredByAuthor": string;
|
||||
/**
|
||||
* ロックダウン
|
||||
*/
|
||||
"lockdown": string;
|
||||
/**
|
||||
* アカウントを選択してください
|
||||
*/
|
||||
"pleaseSelectAccount": string;
|
||||
/**
|
||||
* 利用可能なロール
|
||||
*/
|
||||
"availableRoles": string;
|
||||
"_accountSettings": {
|
||||
/**
|
||||
* コンテンツの表示にログインを必須にする
|
||||
*/
|
||||
"requireSigninToViewContents": string;
|
||||
/**
|
||||
* あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription1": string;
|
||||
/**
|
||||
* URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription2": string;
|
||||
/**
|
||||
* リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。
|
||||
*/
|
||||
"requireSigninToViewContentsDescription3": string;
|
||||
/**
|
||||
* 過去のノートをフォロワーのみ表示可能にする
|
||||
*/
|
||||
"makeNotesFollowersOnlyBefore": string;
|
||||
/**
|
||||
* この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。
|
||||
*/
|
||||
"makeNotesFollowersOnlyBeforeDescription": string;
|
||||
/**
|
||||
* 過去のノートを非公開化する
|
||||
*/
|
||||
"makeNotesHiddenBefore": string;
|
||||
/**
|
||||
* この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。
|
||||
*/
|
||||
"makeNotesHiddenBeforeDescription": string;
|
||||
/**
|
||||
* リモートサーバーに連合されたノートには効果が及ばない場合があります。
|
||||
*/
|
||||
"mayNotEffectForFederatedNotes": string;
|
||||
/**
|
||||
* 指定した時間を経過しているノート
|
||||
*/
|
||||
"notesHavePassedSpecifiedPeriod": string;
|
||||
/**
|
||||
* 指定した日時より前のノート
|
||||
*/
|
||||
"notesOlderThanSpecifiedDateAndTime": string;
|
||||
};
|
||||
"_abuseUserReport": {
|
||||
/**
|
||||
* 転送
|
||||
|
@ -8382,14 +8456,26 @@ export interface Locale extends ILocale {
|
|||
* アプリケーションに戻っています
|
||||
*/
|
||||
"callback": string;
|
||||
/**
|
||||
* アクセスを許可しました
|
||||
*/
|
||||
"accepted": string;
|
||||
/**
|
||||
* アクセスを拒否しました
|
||||
*/
|
||||
"denied": string;
|
||||
/**
|
||||
* 以下のユーザーとして操作しています
|
||||
*/
|
||||
"scopeUser": string;
|
||||
/**
|
||||
* アプリケーションにアクセス許可を与えるには、ログインが必要です。
|
||||
*/
|
||||
"pleaseLogin": string;
|
||||
/**
|
||||
* アクセスを許可すると、自動で以下のURLに遷移します
|
||||
*/
|
||||
"byClickingYouWillBeRedirectedToThisUrl": string;
|
||||
};
|
||||
"_antennaSources": {
|
||||
/**
|
||||
|
@ -9271,7 +9357,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"youGotQuote": ParameterizedString<"name">;
|
||||
/**
|
||||
* {name}がRenoteしました
|
||||
* {name}がリノートしました
|
||||
*/
|
||||
"youRenoted": ParameterizedString<"name">;
|
||||
/**
|
||||
|
@ -9376,7 +9462,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"reply": string;
|
||||
/**
|
||||
* Renote
|
||||
* リノート
|
||||
*/
|
||||
"renote": string;
|
||||
/**
|
||||
|
@ -9434,7 +9520,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"reply": string;
|
||||
/**
|
||||
* Renote
|
||||
* リノート
|
||||
*/
|
||||
"renote": string;
|
||||
};
|
||||
|
@ -10471,6 +10557,28 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"codeGeneratedDescription": string;
|
||||
};
|
||||
"_selfXssPrevention": {
|
||||
/**
|
||||
* 警告
|
||||
*/
|
||||
"warning": string;
|
||||
/**
|
||||
* 「この画面に何か貼り付けろ」はすべて詐欺です。
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。
|
||||
*/
|
||||
"description1": string;
|
||||
/**
|
||||
* 貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。
|
||||
*/
|
||||
"description2": string;
|
||||
/**
|
||||
* 詳しくはこちらをご確認ください。 {link}
|
||||
*/
|
||||
"description3": ParameterizedString<"link">;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -15,6 +15,7 @@ const merge = (...args) => args.reduce((a, c) => ({
|
|||
|
||||
const languages = [
|
||||
'ar-SA',
|
||||
'ca-ES',
|
||||
'cs-CZ',
|
||||
'da-DK',
|
||||
'de-DE',
|
||||
|
|
|
@ -68,7 +68,7 @@ reply: "Rispondi"
|
|||
loadMore: "Mostra di più"
|
||||
showMore: "Espandi"
|
||||
showLess: "Comprimi"
|
||||
youGotNewFollower: "Adesso ti segue"
|
||||
youGotNewFollower: "Hai un nuovo Follower"
|
||||
receiveFollowRequest: "Hai ricevuto una richiesta di follow"
|
||||
followRequestAccepted: "Ha accettato la tua richiesta di follow"
|
||||
mention: "Menzioni"
|
||||
|
@ -80,14 +80,14 @@ export: "Esporta"
|
|||
files: "Allegati"
|
||||
download: "Scarica"
|
||||
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?"
|
||||
unfollowConfirm: "Vuoi davvero smettere di seguire {name}?"
|
||||
unfollowConfirm: "Vuoi davvero togliere il Following a {name}?"
|
||||
exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive."
|
||||
importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo."
|
||||
lists: "Liste"
|
||||
noLists: "Nessuna lista"
|
||||
note: "Nota"
|
||||
notes: "Note"
|
||||
following: "Follow"
|
||||
following: "Following"
|
||||
followers: "Follower"
|
||||
followsYou: "Follower"
|
||||
createList: "Aggiungi una nuova lista"
|
||||
|
@ -106,7 +106,7 @@ defaultNoteVisibility: "Privacy predefinita delle note"
|
|||
follow: "Segui"
|
||||
followRequest: "Richiesta di follow"
|
||||
followRequests: "Richieste di follow"
|
||||
unfollow: "Smetti di seguire"
|
||||
unfollow: "Togli Following"
|
||||
followRequestPending: "Richiesta in approvazione"
|
||||
enterEmoji: "Inserisci emoji"
|
||||
renote: "Rinota"
|
||||
|
@ -195,7 +195,7 @@ setWallpaper: "Imposta sfondo"
|
|||
removeWallpaper: "Elimina lo sfondo"
|
||||
searchWith: "Cerca: {q}"
|
||||
youHaveNoLists: "Non hai ancora creato nessuna lista"
|
||||
followConfirm: "Vuoi seguire {name}?"
|
||||
followConfirm: "Confermi il Following a {name}?"
|
||||
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."
|
||||
host: "Host"
|
||||
|
@ -263,7 +263,7 @@ all: "Tutte"
|
|||
subscribing: "Iscrizione"
|
||||
publishing: "Pubblicazione"
|
||||
notResponding: "Nessuna risposta"
|
||||
instanceFollowing: "Seguiti dall'istanza"
|
||||
instanceFollowing: "Istanza Following"
|
||||
instanceFollowers: "Follower dell'istanza"
|
||||
instanceUsers: "Profili nell'istanza"
|
||||
changePassword: "Aggiorna Password"
|
||||
|
@ -615,7 +615,7 @@ unsetUserBannerConfirm: "Vuoi davvero rimuovere l'intestazione dal profilo?"
|
|||
deleteAllFiles: "Elimina tutti i file"
|
||||
deleteAllFilesConfirm: "Vuoi davvero eliminare tutti i file?"
|
||||
removeAllFollowing: "Annulla tutti i follow"
|
||||
removeAllFollowingDescription: "Cancella tutti i follows del server {host}. Per favore, esegui se, ad esempio, l'istanza non esiste più."
|
||||
removeAllFollowingDescription: "Togli il Following a tutti i profili su {host}. Utile, ad esempio, quando l'istanza non esiste più."
|
||||
userSuspended: "L'utente è in sospensione"
|
||||
userSilenced: "Profilo silenziato"
|
||||
yourAccountSuspendedTitle: "Questo profilo è sospeso"
|
||||
|
@ -688,7 +688,7 @@ hardWordMute: "Filtro parole forte"
|
|||
regexpError: "errore regex"
|
||||
regexpErrorDescription: "Si è verificato un errore nell'espressione regolare alla riga {line} della parola muta {tab}:"
|
||||
instanceMute: "Silenziare l'istanza"
|
||||
userSaysSomething: "{name} ha parlato"
|
||||
userSaysSomething: "{name} ha detto qualcosa"
|
||||
makeActive: "Attiva"
|
||||
display: "Visualizza"
|
||||
copy: "Copia"
|
||||
|
@ -703,7 +703,7 @@ notificationSetting: "Impostazioni notifiche"
|
|||
notificationSettingDesc: "Seleziona il tipo di notifiche da visualizzare."
|
||||
useGlobalSetting: "Usa impostazioni generali"
|
||||
useGlobalSettingDesc: "Quando attiva, verranno utilizzate le impostazioni notifiche del profilo. Altrimenti si possono segliere impostazioni personalizzate."
|
||||
other: "Ulteriori"
|
||||
other: "Eccetera"
|
||||
regenerateLoginToken: "Genera di nuovo un token di connessione"
|
||||
regenerateLoginTokenDescription: "Genera un nuovo token di autenticazione. Solitamente questa operazione non è necessaria: quando si genera un nuovo token, tutti i dispositivi vanno disconnessi."
|
||||
theKeywordWhenSearchingForCustomEmoji: "Questa sarà la parola chiave durante la ricerca di emoji personalizzate"
|
||||
|
@ -747,7 +747,7 @@ repliesCount: "Numero di risposte inviate"
|
|||
renotesCount: "Numero di note che hai ricondiviso"
|
||||
repliedCount: "Numero di risposte ricevute"
|
||||
renotedCount: "Numero delle tue note ricondivise"
|
||||
followingCount: "Numero di profili seguiti"
|
||||
followingCount: "Numero di Following"
|
||||
followersCount: "Numero di profili che ti seguono"
|
||||
sentReactionsCount: "Numero di reazioni inviate"
|
||||
receivedReactionsCount: "Numero di reazioni ricevute"
|
||||
|
@ -901,8 +901,8 @@ pubSub: "Publish/Subscribe del profilo"
|
|||
lastCommunication: "La comunicazione più recente"
|
||||
resolved: "Risolto"
|
||||
unresolved: "Non risolto"
|
||||
breakFollow: "Impedire di seguirmi"
|
||||
breakFollowConfirm: "Vuoi davvero che questo profilo smetta di seguirti?"
|
||||
breakFollow: "Rimuovi Follower"
|
||||
breakFollowConfirm: "Vuoi davvero togliere questo Follower?"
|
||||
itsOn: "Abilitato"
|
||||
itsOff: "Disabilitato"
|
||||
on: "Acceso"
|
||||
|
@ -917,7 +917,7 @@ makeReactionsPublicDescription: "La lista delle reazioni che avete fatto è a di
|
|||
classic: "Classico"
|
||||
muteThread: "Silenziare conversazione"
|
||||
unmuteThread: "Riattiva la conversazione"
|
||||
followingVisibility: "Visibilità dei profili seguiti"
|
||||
followingVisibility: "Visibilità dei Following"
|
||||
followersVisibility: "Visibilità dei profili che ti seguono"
|
||||
continueThread: "Altre conversazioni"
|
||||
deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?"
|
||||
|
@ -947,6 +947,9 @@ oneHour: "1 ora"
|
|||
oneDay: "1 giorno"
|
||||
oneWeek: "1 settimana"
|
||||
oneMonth: "Un mese"
|
||||
threeMonths: "3 mesi"
|
||||
oneYear: "1 anno"
|
||||
threeDays: "3 giorni"
|
||||
reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto."
|
||||
failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo"
|
||||
rateLimitExceeded: "Superato il limite di richieste."
|
||||
|
@ -965,7 +968,7 @@ driveCapOverrideLabel: "Modificare la capienza del Drive per questo profilo"
|
|||
driveCapOverrideCaption: "Se viene specificato meno di 0, viene annullato."
|
||||
requireAdminForView: "Per visualizzarli, è necessario aver effettuato l'accesso con un profilo amministratore."
|
||||
isSystemAccount: "Questi profili vengono creati e gestiti automaticamente dal sistema"
|
||||
typeToConfirm: "Per eseguire questa operazione, digitare {x}"
|
||||
typeToConfirm: "Digita {x} per continuare"
|
||||
deleteAccount: "Eliminazione profilo"
|
||||
document: "Documento"
|
||||
numberOfPageCache: "Numero di pagine cache"
|
||||
|
@ -1020,7 +1023,7 @@ neverShow: "Non mostrare più"
|
|||
remindMeLater: "Rimanda"
|
||||
didYouLikeMisskey: "Ti piace Misskey?"
|
||||
pleaseDonate: "Misskey è il software libero utilizzato su {host}. Offrendo una donazione è più facile continuare a svilupparlo!"
|
||||
correspondingSourceIsAvailable: ""
|
||||
correspondingSourceIsAvailable: "Il codice sorgente corrispondente è disponibile su {anchor}."
|
||||
roles: "Ruoli"
|
||||
role: "Ruolo"
|
||||
noRole: "Ruolo non trovato"
|
||||
|
@ -1130,7 +1133,7 @@ channelArchiveConfirmDescription: "Un canale archiviato non compare nell'elenco
|
|||
thisChannelArchived: "Questo canale è stato archiviato."
|
||||
displayOfNote: "Visualizzazione delle Note"
|
||||
initialAccountSetting: "Impostazioni iniziali del profilo"
|
||||
youFollowing: "Seguiti"
|
||||
youFollowing: "Following"
|
||||
preventAiLearning: "Impedisci l'apprendimento della IA"
|
||||
preventAiLearningDescription: "Aggiungendo il campo \"noai\" alla risposta HTML, si indica ai Robot esterni di non usare testi e allegati per addestrare sistemi di Machine Learning (IA predittiva/generativa). Anche se è impossibile sapere se la richiesta venga onorata o semplicemente ignorata."
|
||||
options: "Opzioni del ruolo"
|
||||
|
@ -1293,6 +1296,21 @@ prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
|
|||
prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
|
||||
yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
|
||||
yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "L'autore richiede di iscriversi per vedere il contenuto"
|
||||
lockdown: "Isolamento"
|
||||
pleaseSelectAccount: "Per favore, seleziona un profilo"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "Per vedere il contenuto, è necessaria l'iscrizione"
|
||||
requireSigninToViewContentsDescription1: "Richiedere l'iscrizione per visualizzare tutte le Note e gli altri contenuti che hai creato. Probabilmente l'effetto è impedire la raccolta di informazioni da parte dei bot crawler."
|
||||
requireSigninToViewContentsDescription2: "La visualizzazione verrà disabilitata a server che non supportano l'anteprima URL (OGP), all'incorporamento nelle pagine Web e alla citazione delle Note."
|
||||
requireSigninToViewContentsDescription3: "Queste restrizioni potrebbero non applicarsi al contenuto federato su server remoti."
|
||||
makeNotesFollowersOnlyBefore: "Rendi visibili solo ai Follower le Note pubblicate in precedenza"
|
||||
makeNotesFollowersOnlyBeforeDescription: "Mentre questa funzione è abilitata, le Note antecedenti al momento impostato, saranno visibili solo ai profili Follower. Disabilitandola nuovamente, verrà ripristinata anche la visibilità pubblica della Nota."
|
||||
makeNotesHiddenBefore: "Nascondi le Note pubblicate in precedenza"
|
||||
makeNotesHiddenBeforeDescription: "Mentre questa funzione è abilitata, le Note antecedenti al momento impostato, saranno visibili soltanto a te (private). Disabilitandola nuovamente, verrà ripristinata anche la visibilità pubblica della Nota."
|
||||
mayNotEffectForFederatedNotes: "Le Note già federate su server remoti potrebbero non essere modificate."
|
||||
notesHavePassedSpecifiedPeriod: "Note antecedenti al periodo specificato"
|
||||
notesOlderThanSpecifiedDateAndTime: "Note antecedenti al momento specificato"
|
||||
_abuseUserReport:
|
||||
forward: "Inoltra"
|
||||
forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
|
||||
|
@ -1378,7 +1396,7 @@ _initialTutorial:
|
|||
_timeline:
|
||||
title: "Come funziona la Timeline"
|
||||
description1: "Misskey fornisce alcune Timeline (sequenze cronologiche di Note). Una di queste potrebbe essere stata disattivata dagli amministratori."
|
||||
home: "le Note provenienti dai profili che segui (follow)."
|
||||
home: "le Note provenienti dai profili che segui (Following)."
|
||||
local: "tutte le Note pubblicate dai profili di questa istanza."
|
||||
social: "sia le Note della Timeline Home che quelle della Timeline Locale, insieme!"
|
||||
global: "le Note da pubblicate da tutte le altre istanze federate con la nostra."
|
||||
|
@ -1416,7 +1434,7 @@ _initialTutorial:
|
|||
title: "Il tutorial è finito! 🎉"
|
||||
description: "Queste sono solamente alcune delle funzionalità principali di Misskey. Per ulteriori informazioni, {link}."
|
||||
_timelineDescription:
|
||||
home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (follow)."
|
||||
home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (Following)."
|
||||
local: "La Timeline Locale, è una cronologia di Note pubblicate da tutti i profili iscritti su questo server."
|
||||
social: "La Timeline Sociale, unisce in ordine cronologico l'elenco di Note presenti nella Timeline Home e quella Locale."
|
||||
global: "La Timeline Federata ti consente di vedere le Note pubblicate dai profili di tutti gli altri server federati a questo."
|
||||
|
@ -1442,7 +1460,7 @@ _accountMigration:
|
|||
moveFrom: "Migra un altro profilo dentro a questo"
|
||||
moveFromSub: "Crea un alias verso un altro profilo remoto"
|
||||
moveFromLabel: "Profilo da cui migrare #{n}"
|
||||
moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it"
|
||||
moveFromDescription: "Se desideri spostare i Follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it"
|
||||
moveTo: "Migrare questo profilo verso un un altro"
|
||||
moveToLabel: "Profilo verso cui migrare"
|
||||
moveCannotBeUndone: "La migrazione è irreversibile, non può essere interrotta o annullata."
|
||||
|
@ -1451,7 +1469,7 @@ _accountMigration:
|
|||
startMigration: "Avvia la migrazione"
|
||||
migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo."
|
||||
movedAndCannotBeUndone: "Il tuo profilo è stato migrato.\nLa migrazione non può essere annullata."
|
||||
postMigrationNote: "Questo profilo smetterà di seguire gli altri profili remoti a 24 ore dal termine della migrazione.\nSia i Follow che i Follower scenderanno a zero. I tuoi follower saranno comunque in grado di vedere le Note per soli follower, poiché non smetteranno di seguirti."
|
||||
postMigrationNote: "Questo profilo smetterà di seguire gli altri profili remoti a 24 ore dal termine della migrazione.\nSia i Following che i Follower scenderanno a zero. I tuoi Follower saranno comunque in grado di vedere le Note per soli Follower, poiché non smetteranno di seguirti."
|
||||
movedTo: "Profilo verso cui migrare"
|
||||
_achievements:
|
||||
earnedAt: "Data di conseguimento"
|
||||
|
@ -1844,7 +1862,7 @@ _gallery:
|
|||
unlike: "Non mi piace più"
|
||||
_email:
|
||||
_follow:
|
||||
title: "Adesso ti segue"
|
||||
title: "Follower aggiuntivo"
|
||||
_receiveFollowRequest:
|
||||
title: "Hai ricevuto una richiesta di follow"
|
||||
_plugin:
|
||||
|
@ -1908,7 +1926,7 @@ _channel:
|
|||
removeBanner: "Rimuovi intestazione"
|
||||
featured: "Di tendenza"
|
||||
owned: "I miei canali"
|
||||
following: "Seguiti"
|
||||
following: "Following"
|
||||
usersCount: "{n} partecipanti"
|
||||
notesCount: "{n} note"
|
||||
nameAndDescription: "Nome e descrizione"
|
||||
|
@ -2074,7 +2092,7 @@ _permissions:
|
|||
"read:favorites": "Visualizza i tuoi preferiti"
|
||||
"write:favorites": "Gestisci i tuoi preferiti"
|
||||
"read:following": "Vedi le informazioni di follow"
|
||||
"write:following": "Following di altri profili"
|
||||
"write:following": "Aggiungere e togliere Following"
|
||||
"read:messaging": "Visualizzare la chat"
|
||||
"write:messaging": "Gestire la chat"
|
||||
"read:mutes": "Vedi i profili silenziati"
|
||||
|
@ -2157,11 +2175,14 @@ _auth:
|
|||
permissionAsk: "Questa app richiede le seguenti autorizzazioni:"
|
||||
pleaseGoBack: "Si prega di ritornare sulla app"
|
||||
callback: "Ritornando sulla app"
|
||||
accepted: "Accesso concesso"
|
||||
denied: "Accesso negato"
|
||||
scopeUser: "Sto funzionando per il seguente profilo"
|
||||
pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione"
|
||||
byClickingYouWillBeRedirectedToThisUrl: "Consentendo l'accesso, si verrà reindirizzati presso questo indirizzo URL"
|
||||
_antennaSources:
|
||||
all: "Tutte le note"
|
||||
homeTimeline: "Note dagli utenti che segui"
|
||||
homeTimeline: "Note dai tuoi Following"
|
||||
users: "Note dagli utenti selezionati"
|
||||
userList: "Note dagli utenti della lista selezionata"
|
||||
userBlacklist: "Tutte le Note tranne quelle di uno o più profili specificati"
|
||||
|
@ -2274,7 +2295,7 @@ _exportOrImport:
|
|||
allNotes: "Tutte le note"
|
||||
favoritedNotes: "Note preferite"
|
||||
clips: "Clip"
|
||||
followingList: "Follow"
|
||||
followingList: "Following"
|
||||
muteList: "Elenco profili silenziati"
|
||||
blockingList: "Elenco profili bloccati"
|
||||
userLists: "Liste"
|
||||
|
@ -2390,7 +2411,7 @@ _notification:
|
|||
youGotReply: "{name} ti ha risposto"
|
||||
youGotQuote: "{name} ha citato la tua Nota e ha detto"
|
||||
youRenoted: "{name} ha rinotato"
|
||||
youWereFollowed: "Adesso ti segue"
|
||||
youWereFollowed: "Follower aggiuntivo"
|
||||
youReceivedFollowRequest: "Hai ricevuto una richiesta di follow"
|
||||
yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata"
|
||||
pollEnded: "Risultati del sondaggio."
|
||||
|
@ -2413,7 +2434,7 @@ _notification:
|
|||
_types:
|
||||
all: "Tutto"
|
||||
note: "Nuove Note"
|
||||
follow: "Nuovi profili follower"
|
||||
follow: "Follower"
|
||||
mention: "Menzioni"
|
||||
reply: "Risposte"
|
||||
renote: "Rinota"
|
||||
|
@ -2429,7 +2450,7 @@ _notification:
|
|||
test: "Prova la notifica"
|
||||
app: "Notifiche da applicazioni"
|
||||
_actions:
|
||||
followBack: "Segui"
|
||||
followBack: "Following ricambiato"
|
||||
reply: "Rispondi"
|
||||
renote: "Rinota"
|
||||
_deck:
|
||||
|
@ -2481,7 +2502,7 @@ _webhookSettings:
|
|||
trigger: "Trigger"
|
||||
active: "Attivo"
|
||||
_events:
|
||||
follow: "Quando segui un profilo"
|
||||
follow: "Quando aggiungi Following"
|
||||
followed: "Quando ti segue un profilo"
|
||||
note: "Quando pubblichi una Nota"
|
||||
reply: "Quando rispondono ad una Nota"
|
||||
|
@ -2710,3 +2731,9 @@ _embedCodeGen:
|
|||
generateCode: "Crea il codice di incorporamento"
|
||||
codeGenerated: "Codice generato"
|
||||
codeGeneratedDescription: "Incolla il codice appena generato sul tuo sito web."
|
||||
_selfXssPrevention:
|
||||
warning: "Avviso"
|
||||
title: "\"Incolla qualcosa su questa schermata\" è tutta una truffa."
|
||||
description1: "Incollando qualcosa qui, malintenzionati potrebbero prendere il controllo del tuo profilo o rubare i tuoi dati personali."
|
||||
description2: "Se non sai esattamente cosa stai facendo, %c smetti subito e chiudi questa finestra."
|
||||
description3: "Per favore, controlla questo collegamento per avere maggiori dettagli. {link}"
|
||||
|
|
|
@ -947,6 +947,9 @@ oneHour: "1時間"
|
|||
oneDay: "1日"
|
||||
oneWeek: "1週間"
|
||||
oneMonth: "1ヶ月"
|
||||
threeMonths: "3ヶ月"
|
||||
oneYear: "1年"
|
||||
threeDays: "3日"
|
||||
reflectMayTakeTime: "反映されるまで時間がかかる場合があります。"
|
||||
failedToFetchAccountInformation: "アカウント情報の取得に失敗しました"
|
||||
rateLimitExceeded: "レート制限を超えました"
|
||||
|
@ -1293,6 +1296,23 @@ prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
|
|||
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
|
||||
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
|
||||
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています"
|
||||
lockdown: "ロックダウン"
|
||||
pleaseSelectAccount: "アカウントを選択してください"
|
||||
availableRoles: "利用可能なロール"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
requireSigninToViewContentsDescription1: "あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。"
|
||||
requireSigninToViewContentsDescription2: "URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。"
|
||||
requireSigninToViewContentsDescription3: "リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。"
|
||||
makeNotesFollowersOnlyBefore: "過去のノートをフォロワーのみ表示可能にする"
|
||||
makeNotesFollowersOnlyBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。"
|
||||
makeNotesHiddenBefore: "過去のノートを非公開化する"
|
||||
makeNotesHiddenBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。"
|
||||
mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばない場合があります。"
|
||||
notesHavePassedSpecifiedPeriod: "指定した時間を経過しているノート"
|
||||
notesOlderThanSpecifiedDateAndTime: "指定した日時より前のノート"
|
||||
|
||||
_abuseUserReport:
|
||||
forward: "転送"
|
||||
|
@ -2199,8 +2219,11 @@ _auth:
|
|||
permissionAsk: "このアプリは次の権限を要求しています"
|
||||
pleaseGoBack: "アプリケーションに戻ってやっていってください"
|
||||
callback: "アプリケーションに戻っています"
|
||||
accepted: "アクセスを許可しました"
|
||||
denied: "アクセスを拒否しました"
|
||||
scopeUser: "以下のユーザーとして操作しています"
|
||||
pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。"
|
||||
byClickingYouWillBeRedirectedToThisUrl: "アクセスを許可すると、自動で以下のURLに遷移します"
|
||||
|
||||
_antennaSources:
|
||||
all: "全てのノート"
|
||||
|
@ -2448,7 +2471,7 @@ _notification:
|
|||
youGotMention: "{name}からのメンション"
|
||||
youGotReply: "{name}からのリプライ"
|
||||
youGotQuote: "{name}による引用"
|
||||
youRenoted: "{name}がRenoteしました"
|
||||
youRenoted: "{name}がリノートしました"
|
||||
youWereFollowed: "フォローされました"
|
||||
youReceivedFollowRequest: "フォローリクエストが来ました"
|
||||
yourFollowRequestAccepted: "フォローリクエストが承認されました"
|
||||
|
@ -2476,7 +2499,7 @@ _notification:
|
|||
follow: "フォロー"
|
||||
mention: "メンション"
|
||||
reply: "リプライ"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
quote: "引用"
|
||||
reaction: "リアクション"
|
||||
pollEnded: "アンケートが終了"
|
||||
|
@ -2492,7 +2515,7 @@ _notification:
|
|||
_actions:
|
||||
followBack: "フォローバック"
|
||||
reply: "返信"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
|
||||
_deck:
|
||||
alwaysShowMainColumn: "常にメインカラムを表示"
|
||||
|
@ -2789,3 +2812,10 @@ _embedCodeGen:
|
|||
generateCode: "埋め込みコードを作成"
|
||||
codeGenerated: "コードが生成されました"
|
||||
codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。"
|
||||
|
||||
_selfXssPrevention:
|
||||
warning: "警告"
|
||||
title: "「この画面に何か貼り付けろ」はすべて詐欺です。"
|
||||
description1: "ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。"
|
||||
description2: "貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。"
|
||||
description3: "詳しくはこちらをご確認ください。 {link}"
|
||||
|
|
|
@ -8,6 +8,9 @@ search: "探す"
|
|||
notifications: "通知"
|
||||
username: "ユーザー名"
|
||||
password: "パスワード"
|
||||
initialPasswordForSetup: "初期設定開始用パスワード"
|
||||
initialPasswordIsIncorrect: "初期設定開始用のパスワードがちゃうで。"
|
||||
initialPasswordForSetupDescription: "Miskkeyを自分でインストールしたんやったら、設定ファイルに入れたパスワードを使ってや。\nホスティングサービスを使っとるんやったら、サービスから言われたやつを使うんやで。\n別に何も設定しとらんのやったら、何も入れずに空けといてな。"
|
||||
forgotPassword: "パスワード忘れたん?"
|
||||
fetchingAsApObject: "今ちと連合に照会しとるで"
|
||||
ok: "ええで"
|
||||
|
@ -236,6 +239,8 @@ silencedInstances: "サーバーサイレンスされてんねん"
|
|||
silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定すんで。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなんねん。ブロックしたインスタンスには影響せーへんで。"
|
||||
mediaSilencedInstances: "メディアサイレンスしたサーバー"
|
||||
mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定するで。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われてな、カスタム絵文字が使えへんようになるで。ブロックしたインスタンスには影響せえへんで。"
|
||||
federationAllowedHosts: "連合を許すサーバー"
|
||||
federationAllowedHostsDescription: "連合してもいいサーバーのホストを行ごとに区切って設定してや。"
|
||||
muteAndBlock: "ミュートとブロック"
|
||||
mutedUsers: "ミュートしとるユーザー"
|
||||
blockedUsers: "ブロックしとるユーザー"
|
||||
|
@ -334,6 +339,7 @@ renameFolder: "フォルダー名を変える"
|
|||
deleteFolder: "フォルダーをほかす"
|
||||
folder: "フォルダー"
|
||||
addFile: "ファイルを追加"
|
||||
showFile: "ファイル出す"
|
||||
emptyDrive: "ドライブは空っぽや"
|
||||
emptyFolder: "このフォルダーは空や"
|
||||
unableToDelete: "消せんかったわ"
|
||||
|
@ -448,6 +454,7 @@ totpDescription: "認証アプリ使うてワンタイムパスワードを入
|
|||
moderator: "モデレーター"
|
||||
moderation: "モデレーション"
|
||||
moderationNote: "モデレーションノート"
|
||||
moderationNoteDescription: "モデレーターの中だけで共有するメモを入れれるで。"
|
||||
addModerationNote: "モデレーションノートを追加するで"
|
||||
moderationLogs: "モデログ"
|
||||
nUsersMentioned: "{n}人が投稿"
|
||||
|
@ -509,6 +516,10 @@ uiLanguage: "UIの表示言語"
|
|||
aboutX: "{x}について"
|
||||
emojiStyle: "絵文字のスタイル"
|
||||
native: "ネイティブ"
|
||||
menuStyle: "メニューのスタイル"
|
||||
style: "スタイル"
|
||||
drawer: "ドロワー"
|
||||
popup: "ポップアップ"
|
||||
showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで"
|
||||
showReactionsCount: "ノートのリアクション数を表示する"
|
||||
noHistory: "履歴はないわ。"
|
||||
|
@ -591,6 +602,8 @@ ascendingOrder: "小さい順"
|
|||
descendingOrder: "大きい順"
|
||||
scratchpad: "スクラッチパッド"
|
||||
scratchpadDescription: "スクラッチパッドではAiScriptを色々試すことができるんや。Misskeyに対して色々できるコードを書いて動かしてみたり、結果を見たりできるで。"
|
||||
uiInspector: "UIインスペクター"
|
||||
uiInspectorDescription: "メモリ上にあるUIコンポーネントのインスタンス一覧を見れるで。UIコンポーネントはUi:C:系関数で生成されるで。"
|
||||
output: "出力"
|
||||
script: "スクリプト"
|
||||
disablePagesScript: "Pagesのスクリプトを無効にしてや"
|
||||
|
@ -909,6 +922,7 @@ followersVisibility: "フォロワーの公開範囲"
|
|||
continueThread: "さらにスレッドを見るで"
|
||||
deleteAccountConfirm: "アカウントを消すで?ええんか?"
|
||||
incorrectPassword: "パスワードがちゃうわ。"
|
||||
incorrectTotp: "ワンタイムパスワードが間違っとるか、期限が切れとるみたいやな。"
|
||||
voteConfirm: "「{choice}」に投票するんか?"
|
||||
hide: "隠す"
|
||||
useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで"
|
||||
|
@ -1073,6 +1087,7 @@ retryAllQueuesConfirmTitle: "もっかいやってみるか?"
|
|||
retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。"
|
||||
enableChartsForRemoteUser: "リモートユーザーのチャートを作る"
|
||||
enableChartsForFederatedInstances: "リモートサーバーのチャートを作る"
|
||||
enableStatsForFederatedInstances: "リモートサーバの情報を取得"
|
||||
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
|
||||
reactionsDisplaySize: "ツッコミの表示のでかさ"
|
||||
limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく表示するで"
|
||||
|
@ -1259,6 +1274,32 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示
|
|||
sensitiveMediaRevealConfirm: "センシティブなメディアやで。表示するんか?"
|
||||
createdLists: "作成したリスト"
|
||||
createdAntennas: "作成したアンテナ"
|
||||
fromX: "{x}から"
|
||||
genEmbedCode: "埋め込みコードを作る"
|
||||
noteOfThisUser: "このユーザーのノート全部"
|
||||
clipNoteLimitExceeded: "これ以上このクリップにノート追加でけへんわ。"
|
||||
performance: "パフォーマンス"
|
||||
modified: "変更あり"
|
||||
discard: "やめる"
|
||||
thereAreNChanges: "{n}個の変更があるみたいや"
|
||||
signinWithPasskey: "パスキーでログイン"
|
||||
unknownWebAuthnKey: "登録されてへんパスキーやな。"
|
||||
passkeyVerificationFailed: "パスキーの検証に失敗したで。"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証は成功したんやけど、パスワードレスログインが無効になっとるわ。"
|
||||
messageToFollower: "フォロワーへのメッセージ"
|
||||
target: "対象"
|
||||
testCaptchaWarning: "CAPTCHAのテストを目的としてるで。<strong>絶対に本番環境で使わんといてな。絶対やで。</strong>"
|
||||
prohibitedWordsForNameOfUser: "禁止ワード(ユーザー名)"
|
||||
prohibitedWordsForNameOfUserDescription: "このリストの中にある文字列がユーザー名に入っとったら、その名前に変更できひんようになるで。モデレーター権限があるユーザーは除外や。"
|
||||
yourNameContainsProhibitedWords: "その名前は禁止した文字列が含まれとるで"
|
||||
yourNameContainsProhibitedWordsDescription: "その名前は禁止した文字列が含まれとるわ。どうしてもって言うなら、サーバー管理者に言うしかないで。"
|
||||
_abuseUserReport:
|
||||
forward: "転送"
|
||||
forwardDescription: "匿名のシステムアカウントってことにして、リモートサーバーに通報を転送するで。"
|
||||
resolve: "解決"
|
||||
accept: "ええよ"
|
||||
reject: "あかんよ"
|
||||
resolveTutorial: "内容がええなら「ええよ」を選ぶんや。肯定的に解決されたことにして記録するで。\n逆に、内容がだめなら「あかんよ」を選びいや。否定的に解決されたって記録しとくで。"
|
||||
_delivery:
|
||||
status: "配信状態"
|
||||
stop: "配信せぇへん"
|
||||
|
@ -1393,8 +1434,10 @@ _serverSettings:
|
|||
fanoutTimelineDescription: "入れると、おのおのタイムラインを取得するときにめちゃめちゃ動きが良うなって、データベースが軽くなるわ。でも、Redisのメモリ使う量が増えるから注意な。サーバーのメモリが足りんときとか、動きが変なときは切れるで。"
|
||||
fanoutTimelineDbFallback: "データベースにフォールバックする"
|
||||
fanoutTimelineDbFallbackDescription: "有効にしたら、タイムラインがキャッシュん中に入ってないときにDBにもっかい問い合わせるフォールバック処理ってのをやっとくで。切ったらフォールバック処理をやらんからサーバーはもっと軽くなんねんけど、タイムラインの取得範囲がちょっと減るで。"
|
||||
reactionsBufferingDescription: "有効にしたら、リアクション作るときのパフォーマンスがすっごい上がって、データベースへの負荷が減るで。代わりに、Redisのメモリ使用は増えるで。"
|
||||
inquiryUrl: "問い合わせ先URL"
|
||||
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターがおらんかったら、スパムを防ぐためにこの設定は勝手に切られるで。"
|
||||
_accountMigration:
|
||||
moveFrom: "別のアカウントからこのアカウントに引っ越す"
|
||||
moveFromSub: "別のアカウントへエイリアスを作る"
|
||||
|
@ -1726,6 +1769,11 @@ _role:
|
|||
canSearchNotes: "ノート探せるかどうか"
|
||||
canUseTranslator: "翻訳使えるかどうか"
|
||||
avatarDecorationLimit: "アイコンデコのいっちばんつけれる数"
|
||||
canImportAntennas: "アンテナのインポートを許す"
|
||||
canImportBlocking: "ブロックのインポートを許す"
|
||||
canImportFollowing: "フォローのインポートを許す"
|
||||
canImportMuting: "ミュートのインポートを許す"
|
||||
canImportUserLists: "リストのインポートを許す"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
isLocal: "ローカルユーザー"
|
||||
|
@ -2219,6 +2267,9 @@ _profile:
|
|||
changeBanner: "バナー画像を変更するで"
|
||||
verifiedLinkDescription: "内容をURLに設定すると、リンク先のwebサイトに自分のプロフのリンクが含まれてる場合に所有者確認済みアイコンを表示させることができるで。"
|
||||
avatarDecorationMax: "最大{max}つまでデコつけれんで"
|
||||
followedMessage: "フォローされたら返すメッセージ"
|
||||
followedMessageDescription: "フォローされたときに相手に返す短めのメッセージを決めれるで。"
|
||||
followedMessageDescriptionForLockedAccount: "フォローが承認制なら、フォローリクエストをOKしたときに見せるで。"
|
||||
_exportOrImport:
|
||||
allNotes: "全てのノート"
|
||||
favoritedNotes: "お気に入りにしたノート"
|
||||
|
@ -2311,6 +2362,7 @@ _pages:
|
|||
eyeCatchingImageSet: "アイキャッチ画像を設定"
|
||||
eyeCatchingImageRemove: "アイキャッチ画像を削除"
|
||||
chooseBlock: "ブロックを追加"
|
||||
enterSectionTitle: "セクションタイトルを入れる"
|
||||
selectType: "種類を選択"
|
||||
contentBlocks: "コンテンツ"
|
||||
inputBlocks: "入力"
|
||||
|
@ -2356,13 +2408,15 @@ _notification:
|
|||
renotedBySomeUsers: "{n}人がリノートしたで"
|
||||
followedBySomeUsers: "{n}人にフォローされたで"
|
||||
flushNotification: "通知の履歴をリセットする"
|
||||
exportOfXCompleted: "{x}のエクスポートが終わったわ"
|
||||
login: "ログインしとったで"
|
||||
_types:
|
||||
all: "すべて"
|
||||
note: "あんたらの新規投稿"
|
||||
follow: "フォロー"
|
||||
mention: "メンション"
|
||||
reply: "リプライ"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
quote: "引用"
|
||||
reaction: "ツッコミ"
|
||||
pollEnded: "アンケートが終了したで"
|
||||
|
@ -2370,12 +2424,14 @@ _notification:
|
|||
followRequestAccepted: "フォローが受理されたで"
|
||||
roleAssigned: "ロールが付与された"
|
||||
achievementEarned: "実績の獲得"
|
||||
exportCompleted: "エクスポート終わった"
|
||||
login: "ログイン"
|
||||
test: "通知テスト"
|
||||
app: "連携アプリからの通知や"
|
||||
_actions:
|
||||
followBack: "フォローバック"
|
||||
reply: "返事"
|
||||
renote: "Renote"
|
||||
renote: "リノート"
|
||||
_deck:
|
||||
alwaysShowMainColumn: "いつもメインカラムを表示"
|
||||
columnAlign: "カラムの寄せ"
|
||||
|
@ -2436,7 +2492,10 @@ _webhookSettings:
|
|||
abuseReport: "ユーザーから通報があったとき"
|
||||
abuseReportResolved: "ユーザーからの通報を処理したとき"
|
||||
userCreated: "ユーザーが作成されたとき"
|
||||
inactiveModeratorsWarning: "モデレーターがしばらくおらんかったとき"
|
||||
inactiveModeratorsInvitationOnlyChanged: "モデレーターがしばらくおらんかったから、システムが招待制に変えたとき"
|
||||
deleteConfirm: "ほんまにWebhookをほかしてもええんか?"
|
||||
testRemarks: "スイッチ右のボタンを押すとダミーデータを使ったテスト用Webhookを送れるで。"
|
||||
_abuseReport:
|
||||
_notificationRecipient:
|
||||
createRecipient: "通報の通知先を追加"
|
||||
|
@ -2480,6 +2539,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "ファイルをセンシティブ付与"
|
||||
unmarkSensitiveDriveFile: "ファイルをセンシティブ解除"
|
||||
resolveAbuseReport: "苦情を解決"
|
||||
forwardAbuseReport: "通報を転送"
|
||||
updateAbuseReportNote: "通報のモデレーションノート更新"
|
||||
createInvitation: "招待コード作る"
|
||||
createAd: "広告を作んで"
|
||||
deleteAd: "広告ほかす"
|
||||
|
@ -2491,6 +2552,14 @@ _moderationLogTypes:
|
|||
unsetUserBanner: "この子のバナー元に戻す"
|
||||
createSystemWebhook: "SystemWebhookを作成"
|
||||
updateSystemWebhook: "SystemWebhookを更新"
|
||||
deleteSystemWebhook: "SystemWebhookを削除"
|
||||
createAbuseReportNotificationRecipient: "通報の通知先作る"
|
||||
updateAbuseReportNotificationRecipient: "通報の通知先更新"
|
||||
deleteAbuseReportNotificationRecipient: "通報の通知先消す"
|
||||
deleteAccount: "アカウント消す"
|
||||
deletePage: "ページ消す"
|
||||
deleteFlash: "Playをほかす"
|
||||
deleteGalleryPost: "ギャラリーの投稿をほかす"
|
||||
_fileViewer:
|
||||
title: "ファイルの詳しい情報"
|
||||
type: "ファイルの種類"
|
||||
|
@ -2622,3 +2691,22 @@ _mediaControls:
|
|||
pip: "ピクチャインピクチャ"
|
||||
playbackRate: "再生速度"
|
||||
loop: "ループ再生"
|
||||
_contextMenu:
|
||||
title: "コンテキストメニュー"
|
||||
app: "アプリ"
|
||||
appWithShift: "Shiftキーでアプリ"
|
||||
native: "ブラウザのUI"
|
||||
_embedCodeGen:
|
||||
title: "埋め込みコードをカスタム"
|
||||
header: "ヘッダー出す"
|
||||
autoload: "勝手に続きを読み込む(非推奨)"
|
||||
maxHeight: "高さの最大値"
|
||||
maxHeightDescription: "0は最大値を指定せえへんけど、ウィジェットが伸び続けるから絶対1以上にしといてや。"
|
||||
maxHeightWarn: "高さの最大値が無効になっとるで。意図してへん変更なら、普通の値に戻してや。"
|
||||
previewIsNotActual: "プレビュー画面で出せる範囲をはみ出したから、ホンマの表示とはちゃうとおもうで。"
|
||||
rounded: "角丸める"
|
||||
border: "外枠に枠線つける"
|
||||
applyToPreview: "プレビューに反映"
|
||||
generateCode: "埋め込みコード作る"
|
||||
codeGenerated: "コード作ったで"
|
||||
codeGeneratedDescription: "作ったコードはウェブサイトに貼っつけて使ってや。"
|
||||
|
|
|
@ -468,7 +468,7 @@ tooShort: "억수로 짜립니다"
|
|||
tooLong: "억수로 집니다"
|
||||
passwordMatched: "맞십니다"
|
||||
passwordNotMatched: "안 맞십니다"
|
||||
signinWith: "{n}서 로그인"
|
||||
signinWith: "{x} 서 로그인"
|
||||
signinFailed: "로그인 몬 했십니다. 고 이름이랑 비밀번호 제대로 썼는가 확인해 주이소."
|
||||
or: "아니면"
|
||||
language: "언어"
|
||||
|
@ -809,11 +809,13 @@ _notification:
|
|||
_types:
|
||||
follow: "팔로잉"
|
||||
mention: "멘션"
|
||||
renote: "리노트"
|
||||
quote: "따오기"
|
||||
reaction: "반엉"
|
||||
login: "로그인"
|
||||
_actions:
|
||||
reply: "답하기"
|
||||
renote: "리노트"
|
||||
_deck:
|
||||
_columns:
|
||||
notifications: "알림"
|
||||
|
|
|
@ -8,6 +8,9 @@ search: "Поиск"
|
|||
notifications: "Уведомления"
|
||||
username: "Имя пользователя"
|
||||
password: "Пароль"
|
||||
initialPasswordForSetup: "Пароль для начала настройки"
|
||||
initialPasswordIsIncorrect: "Пароль для запуска настройки неверен"
|
||||
initialPasswordForSetupDescription: "Если вы установили Misskey самостоятельно, используйте пароль, который вы указали в файле конфигурации.\nЕсли вы используете что-то вроде хостинга Misskey, используйте предоставленный пароль.\nЕсли вы не установили пароль, оставьте его пустым и продолжайте."
|
||||
forgotPassword: "Забыли пароль?"
|
||||
fetchingAsApObject: "Приём с других сайтов"
|
||||
ok: "Подтвердить"
|
||||
|
@ -232,6 +235,7 @@ clearCachedFilesConfirm: "Удалить все закэшированные ф
|
|||
blockedInstances: "Заблокированные инстансы"
|
||||
blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом."
|
||||
silencedInstances: "Заглушённые инстансы"
|
||||
federationAllowedHosts: "Серверы, поддерживающие федерацию"
|
||||
muteAndBlock: "Скрытие и блокировка"
|
||||
mutedUsers: "Скрытые пользователи"
|
||||
blockedUsers: "Заблокированные пользователи"
|
||||
|
@ -330,6 +334,7 @@ renameFolder: "Переименовать папку"
|
|||
deleteFolder: "Удалить папку"
|
||||
folder: "Папка"
|
||||
addFile: "Добавить файл"
|
||||
showFile: "Посмотреть файл"
|
||||
emptyDrive: "Диск пуст"
|
||||
emptyFolder: "Папка пуста"
|
||||
unableToDelete: "Удаление невозможно"
|
||||
|
@ -443,6 +448,7 @@ totp: "Приложение-аутентификатор"
|
|||
totpDescription: "Описание приложения-аутентификатора"
|
||||
moderator: "Модератор"
|
||||
moderation: "Модерация"
|
||||
moderationNote: "Примечания модератора"
|
||||
moderationLogs: "Журнал модерации"
|
||||
nUsersMentioned: "Упомянуло пользователей: {n}"
|
||||
securityKeyAndPasskey: "Ключ безопасности и парольная фраза"
|
||||
|
@ -503,6 +509,8 @@ uiLanguage: "Язык интерфейса"
|
|||
aboutX: "Описание {x}"
|
||||
emojiStyle: "Стиль эмодзи"
|
||||
native: "Системные"
|
||||
menuStyle: "Стиль меню"
|
||||
style: "Стиль"
|
||||
showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении"
|
||||
showReactionsCount: "Видеть количество реакций на заметках"
|
||||
noHistory: "История пока пуста"
|
||||
|
|
|
@ -8,6 +8,9 @@ search: "ค้นหา"
|
|||
notifications: "เเจ้งเตือน"
|
||||
username: "ชื่อผู้ใช้"
|
||||
password: "รหัสผ่าน"
|
||||
initialPasswordForSetup: "รหัสผ่านเริ่มต้นสำหรับการตั้งค่า"
|
||||
initialPasswordIsIncorrect: "รหัสผ่านเริ่มต้นสำหรับตั้งค่านั้นไม่ถูกต้องค่ะ"
|
||||
initialPasswordForSetupDescription: "ถ้าหากคุณติดตั้ง Misskey เอง ให้ใช้รหัสผ่านที่คุณป้อนในไฟล์กำหนดค่า \nถ้าหากคุณกำลังใช้บริการโฮสต์ Misskey ให้ใช้รหัสผ่านที่ได้รับมา\nถ้ายังไม่มีรหัสผ่าน ให้ข้ามช่องรหัสผ่านไป แล้วกดต่อไป"
|
||||
forgotPassword: "ลืมรหัสผ่าน"
|
||||
fetchingAsApObject: "กำลังดึงข้อมูลจากสหพันธ์..."
|
||||
ok: "ตกลง"
|
||||
|
@ -236,6 +239,8 @@ silencedInstances: "ปิดปากเซิร์ฟเวอร์นี้
|
|||
silencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปาก คั่นด้วยการขึ้นบรรทัดใหม่, บัญชีทั้งหมดของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในเซิร์ฟเวอร์นี้ได้หากไม่ได้ถูกติดตามกลับ | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก"
|
||||
mediaSilencedInstances: "เซิร์ฟเวอร์ที่ถูกปิดปากสื่อ"
|
||||
mediaSilencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปากสื่อ คั่นด้วยการขึ้นบรรทัดใหม่, ไฟล์ที่ถูกส่งจากบัญชีของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปาก แล้วจะถูกติดเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน และเอโมจิแบบกำหนดเองก็จะใช้ไม่ได้ด้วย | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก"
|
||||
federationAllowedHosts: "เซิร์ฟเวอร์ที่เปิดให้บริการแบบเฟเดอเรชั่น"
|
||||
federationAllowedHostsDescription: "ระบุชื่อโฮสต์ของเซิร์ฟเวอร์ที่คุณต้องการอนุญาตให้เชื่อมต่อแบบเฟเดอเรชั่น โดยต้องเว้นวรรคแต่ละบรรทัด"
|
||||
muteAndBlock: "ปิดเสียงและบล็อก"
|
||||
mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง"
|
||||
blockedUsers: "ผู้ใช้ที่ถูกบล็อก"
|
||||
|
@ -334,6 +339,7 @@ renameFolder: "เปลี่ยนชื่อโฟลเดอร์"
|
|||
deleteFolder: "ลบโฟลเดอร์"
|
||||
folder: "โฟลเดอร์"
|
||||
addFile: "เพิ่มไฟล์"
|
||||
showFile: "แสดงไฟล์"
|
||||
emptyDrive: "ไดรฟ์ของคุณว่างเปล่านะ"
|
||||
emptyFolder: "โฟลเดอร์นี้ว่างเปล่า"
|
||||
unableToDelete: "ไม่สามารถลบออกได้"
|
||||
|
@ -448,6 +454,7 @@ totpDescription: "ใช้แอปยืนยันตัวตนเพื
|
|||
moderator: "ผู้ควบคุม"
|
||||
moderation: "การกลั่นกรอง"
|
||||
moderationNote: "โน้ตการกลั่นกรอง"
|
||||
moderationNoteDescription: "คุณสามารถใส่โน้ตส่วนตัวที่เฉพาะผู้ดูแลระบบเท่านั้นที่สามารถเข้าถึงได้"
|
||||
addModerationNote: "เพิ่มโน้ตการกลั่นกรอง"
|
||||
moderationLogs: "ปูมการควบคุมดูแล"
|
||||
nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย"
|
||||
|
@ -509,6 +516,10 @@ uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้ง
|
|||
aboutX: "เกี่ยวกับ {x}"
|
||||
emojiStyle: "สไตล์ของเอโมจิ"
|
||||
native: "ภาษาแม่"
|
||||
menuStyle: "สไตล์เมนู"
|
||||
style: "สไตล์"
|
||||
drawer: "ตัววาด"
|
||||
popup: "ป๊อปอัพ"
|
||||
showNoteActionsOnlyHover: "แสดงการดำเนินการโน้ตเมื่อโฮเวอร์(วางเมาส์เหนือ)เท่านั้น"
|
||||
showReactionsCount: "แสดงจำนวนรีแอกชั่นในโน้ต"
|
||||
noHistory: "ไม่มีประวัติ"
|
||||
|
@ -591,6 +602,8 @@ ascendingOrder: "เรียงลำดับขึ้น"
|
|||
descendingOrder: "เรียงลำดับลง"
|
||||
scratchpad: "Scratchpad"
|
||||
scratchpadDescription: "Scratchpad ให้สภาพแวดล้อมสำหรับการทดลอง AiScript คุณสามารถเขียนโค้ด/สั่งดำเนินการ/ตรวจสอบผลลัพธ์ ของการโต้ตอบกับ Misskey ได้"
|
||||
uiInspector: "ตัวตรวจสอบ UI"
|
||||
uiInspectorDescription: "คุณสามารถตรวจสอบรายชื่อเซิร์ฟเวอร์ที่เกี่ยวข้องกับส่วนประกอบอินเตอร์เฟซผู้ใช้ (UI) บนหน่วยความจำของระบบ ส่วนประกอบ UI เหล่านี้จะถูกสร้างขึ้นโดยฟังก์ชัน Ui:C:"
|
||||
output: "เอาท์พุต"
|
||||
script: "สคริปต์"
|
||||
disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ"
|
||||
|
@ -909,6 +922,7 @@ followersVisibility: "การมองเห็นผู้ที่กำล
|
|||
continueThread: "ดูความต่อเนื่องเธรด"
|
||||
deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?"
|
||||
incorrectPassword: "รหัสผ่านไม่ถูกต้อง"
|
||||
incorrectTotp: "รหัสยืนยันตัวตนแบบใช้ครั้งเดียวที่ท่านได้ระบุมานั้น ไม่ถูกต้องหรือหมดอายุลงแล้วค่ะ"
|
||||
voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?"
|
||||
hide: "ซ่อน"
|
||||
useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ"
|
||||
|
@ -1073,6 +1087,7 @@ retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริ
|
|||
retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ"
|
||||
enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล"
|
||||
enableChartsForFederatedInstances: "สร้างแผนภูมิของเซิร์ฟเวอร์ระยะไกล"
|
||||
enableStatsForFederatedInstances: "ดึงข้อมูลสถิติจากเซิร์ฟเวอร์ที่อยู่ห่างไกล"
|
||||
showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต"
|
||||
reactionsDisplaySize: "ขนาดของรีแอคชั่น"
|
||||
limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง"
|
||||
|
@ -1259,6 +1274,32 @@ confirmWhenRevealingSensitiveMedia: "ตรวจสอบก่อนแสด
|
|||
sensitiveMediaRevealConfirm: "สื่อนี้มีเนื้อหาละเอียดอ่อน, ต้องการแสดงใช่ไหม?"
|
||||
createdLists: "รายชื่อที่ถูกสร้าง"
|
||||
createdAntennas: "เสาอากาศที่ถูกสร้าง"
|
||||
fromX: "จาก {x}"
|
||||
genEmbedCode: "สร้างรหัสฝัง"
|
||||
noteOfThisUser: "โน้ตโดยผู้ใช้นี้"
|
||||
clipNoteLimitExceeded: "ไม่สามารถเพิ่มโน้ตเพิ่มเติมในคลิปนี้ได้อีกแล้ว"
|
||||
performance: "ประสิทธิภาพ"
|
||||
modified: "แก้ไข"
|
||||
discard: "ละทิ้ง"
|
||||
thereAreNChanges: "มีอยู่ {n} เปลี่ยนแปลง(s)"
|
||||
signinWithPasskey: "ลงชื่อเข้าใช้ด้วย Passkey"
|
||||
unknownWebAuthnKey: "พาสคีย์ไม่ถูกต้องค่ะ"
|
||||
passkeyVerificationFailed: "การยืนยันกุญแจดิจิทัลไม่สำเร็จค่ะ"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยันพาสคีย์สำเร็จแล้ว แต่การลงชื่อเข้าใช้แบบไม่ต้องใส่รหัสผ่านถูกปิดใช้งานแล้ว"
|
||||
messageToFollower: "ข้อความถึงผู้ติดตาม"
|
||||
target: "เป้า"
|
||||
testCaptchaWarning: "ฟังก์ชันนี้มีไว้สำหรับทดสอบ CAPTCHA เท่านั้น\n<strong>ห้ามนำไปใช้ในระบบจริงโดยเด็ดขาด</strong>"
|
||||
prohibitedWordsForNameOfUser: "คำนี้ไม่สามารถใช้เป็นชื่อผู้ใช้ได้"
|
||||
prohibitedWordsForNameOfUserDescription: "หากมีสตริงใดๆ ในรายการนี้ปรากฏอยู่ในชื่อของผู้ใช้ ชื่อนั้นจะถูกปฏิเสธ ผู้ใช้ที่มีสิทธิ์แต่ผู้ดูแลระบบนั้นจะไม่ได้รับผลกระทบใดๆจากข้อจำกัดนี้ค่ะ"
|
||||
yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม"
|
||||
yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ"
|
||||
_abuseUserReport:
|
||||
forward: "ส่งต่อ"
|
||||
forwardDescription: "ส่งรายงานไปยังเซิร์ฟเวอร์ระยะไกลโดยใช้บัญชีระบบที่ไม่ระบุตัวตน"
|
||||
resolve: "แก้ไข"
|
||||
accept: "ยอมรับ"
|
||||
reject: "ปฏิเสธ"
|
||||
resolveTutorial: "ถ้าหากรายงานนี้มีเนื้อหาถูกต้อง ให้เลือก \"ยอมรับ\" เพื่อปิดเคสกรณีนี้โดยถือว่าได้รับการแก้ไขแล้ว\nถ้าหากเนื้อหาในรายงานนี้นั้นไม่ถูกต้อง ให้เลือก \"ปฏิเสธ\" เพื่อปิดเคสกรณีนี้โดยถือว่าไม่ได้รับการแก้ไข"
|
||||
_delivery:
|
||||
status: "สถานะการจัดส่ง"
|
||||
stop: "ระงับการส่ง"
|
||||
|
@ -1393,8 +1434,10 @@ _serverSettings:
|
|||
fanoutTimelineDescription: "เพิ่มประสิทธิภาพการดึงข้อมูลไทม์ไลน์อย่างมาก และลดภาระในฐานข้อมูลเมื่อเปิดใช้งาน ในทางกลับกัน การใช้หน่วยความจำของ Redis จะเพิ่มขึ้น ลองปิดการใช้งานนี้ในกรณีที่หน่วยความจำเซิร์ฟเวอร์เหลือน้อยหรือเซิร์ฟเวอร์ไม่เสถียร"
|
||||
fanoutTimelineDbFallback: "ฟอลแบ๊กกลับฐานข้อมูล"
|
||||
fanoutTimelineDbFallbackDescription: "เมื่อเปิดใช้งาน หากไม่ได้แคชไทม์ไลน์ ไทม์ไลน์จะฟอลแบ๊กไปยังฐานข้อมูลสำหรับการ query เพิ่มเติม การปิดใช้งานจะช่วยลดภาระของเซิร์ฟเวอร์ด้วยการกำจัดกระบวนฟอลแบ๊ก แต่มันก็จะจำกัดช่วงเวลาไทม์ไลน์ที่สามารถดึงข้อมูลได้"
|
||||
reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ"
|
||||
inquiryUrl: "URL สำหรับการติดต่อสอบถาม"
|
||||
inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "ถ้าหากไม่มีการตรวจสอบจากผู้ดูแลระบบหรือไม่มีความเคลื่อนไหวมาเป็นระยะเวลาหนึ่ง ระบบจะทำการปิดใช้งานฟังก์ชันนี้โดยอัตโนมัติ เพื่อลดความเสี่ยงในการถูกโจมตีด้วยสแปมและอื่นๆ"
|
||||
_accountMigration:
|
||||
moveFrom: "ย้ายจากบัญชีอื่นมาที่บัญชีนี้"
|
||||
moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น"
|
||||
|
@ -1726,6 +1769,11 @@ _role:
|
|||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||
canUseTranslator: "การใช้งานแปล"
|
||||
avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
||||
canImportBlocking: "อนุญาตให้นำเข้าการบล็อก"
|
||||
canImportFollowing: "อนุญาตให้นำเข้ารายการต่อไปนี้"
|
||||
canImportMuting: "อนุญาตให้นำเข้าการปิดกั้น"
|
||||
canImportUserLists: "อนุญาตให้นำเข้ารายการ"
|
||||
_condition:
|
||||
roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ"
|
||||
isLocal: "ผู้ใช้ท้องถิ่น"
|
||||
|
@ -2219,6 +2267,9 @@ _profile:
|
|||
changeBanner: "เปลี่ยนแบนเนอร์"
|
||||
verifiedLinkDescription: "หากป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณ ไอคอนการยืนยันความเป็นเจ้าของจะแสดงถัดจากฟิลด์นั้น ๆ"
|
||||
avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}"
|
||||
followedMessage: "ส่งข้อความเมื่อมีคนกดติดตาม"
|
||||
followedMessageDescription: "ส่งข้อความเมื่อมีคนกดติดตามแล้ว"
|
||||
followedMessageDescriptionForLockedAccount: "ถ้าหากคุณตั้งค่าให้คนอื่นต้องขออนุญาตก่อนที่จะติดตามคุณ ระบบจะขึ้นข้อความนี้ในตอนที่คุณอนุมัติให้เขาติดตาม"
|
||||
_exportOrImport:
|
||||
allNotes: "โน้ตทั้งหมด"
|
||||
favoritedNotes: "โน้ตที่ถูกใจไว้"
|
||||
|
@ -2311,6 +2362,7 @@ _pages:
|
|||
eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ"
|
||||
eyeCatchingImageRemove: "ลบภาพขนาดย่อ"
|
||||
chooseBlock: "เพิ่มบล็อค"
|
||||
enterSectionTitle: "ป้อนชื่อหัวข้อ"
|
||||
selectType: "เลือกชนิด"
|
||||
contentBlocks: "เนื้อหา"
|
||||
inputBlocks: "ป้อนข้อมูล"
|
||||
|
@ -2356,6 +2408,8 @@ _notification:
|
|||
renotedBySomeUsers: "รีโน้ตจากผู้ใช้ {n} ราย"
|
||||
followedBySomeUsers: "มีผู้ติดตาม {n} ราย"
|
||||
flushNotification: "ล้างประวัติการแจ้งเตือน"
|
||||
exportOfXCompleted: "การดำเนินการส่งออก {x} ได้เสร็จสิ้นลงแล้ว"
|
||||
login: "มีคนล็อกอิน"
|
||||
_types:
|
||||
all: "ทั้งหมด"
|
||||
note: "โน้ตใหม่"
|
||||
|
@ -2370,7 +2424,9 @@ _notification:
|
|||
followRequestAccepted: "อนุมัติให้ติดตามแล้ว"
|
||||
roleAssigned: "ให้บทบาท"
|
||||
achievementEarned: "ปลดล็อกความสำเร็จแล้ว"
|
||||
exportCompleted: "กระบวนการส่งออกข้อมูลได้เสร็จสิ้นสมบูรณ์แล้ว"
|
||||
login: "เข้าสู่ระบบ"
|
||||
test: "ทดสอบระบบแจ้งเตือน"
|
||||
app: "การแจ้งเตือนจากแอปที่มีลิงก์"
|
||||
_actions:
|
||||
followBack: "ติดตามกลับด้วย"
|
||||
|
@ -2436,7 +2492,10 @@ _webhookSettings:
|
|||
abuseReport: "เมื่อมีการรายงานจากผู้ใช้"
|
||||
abuseReportResolved: "เมื่อมีการจัดการกับการรายงานจากผู้ใช้"
|
||||
userCreated: "เมื่อผู้ใช้ถูกสร้างขึ้น"
|
||||
inactiveModeratorsWarning: "เมื่อผู้ดูแลระบบไม่ได้ใช้งานมานานระยะหนึ่ง"
|
||||
inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ดูแลระบบที่ไม่ได้ใช้งานมานาน และเซิร์ฟเวอร์เปลี่ยนเป็นแบบเชิญเข้าร่วมเท่านั้น"
|
||||
deleteConfirm: "ต้องการลบ Webhook ใช่ไหม?"
|
||||
testRemarks: "คลิกปุ่มทางด้านขวาของสวิตช์เพื่อส่ง Webhook ทดสอบที่มีข้อมูลจำลอง"
|
||||
_abuseReport:
|
||||
_notificationRecipient:
|
||||
createRecipient: "เพิ่มปลายทางการแจ้งเตือนการรายงาน"
|
||||
|
@ -2480,6 +2539,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "ทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน"
|
||||
unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน"
|
||||
resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว"
|
||||
forwardAbuseReport: "ได้ส่งรายงานไปแล้ว"
|
||||
updateAbuseReportNote: "โน้ตการกลั่นกรองที่รายงานไปนั้น ได้รับการอัปเดตแล้ว"
|
||||
createInvitation: "สร้างรหัสเชิญ"
|
||||
createAd: "สร้างโฆษณาแล้ว"
|
||||
deleteAd: "ลบโฆษณาออกแล้ว"
|
||||
|
@ -2495,6 +2556,10 @@ _moderationLogTypes:
|
|||
createAbuseReportNotificationRecipient: "สร้างปลายทางการแจ้งเตือนการรายงาน"
|
||||
updateAbuseReportNotificationRecipient: "อัปเดตปลายทางการแจ้งเตือนการรายงาน"
|
||||
deleteAbuseReportNotificationRecipient: "ลบปลายทางการแจ้งเตือนการรายงาน"
|
||||
deleteAccount: "บัญชีถูกลบไปแล้ว"
|
||||
deletePage: "เพจถูกลบออกไปแล้ว"
|
||||
deleteFlash: "Play ถูกลบออกไปแล้ว"
|
||||
deleteGalleryPost: "โพสต์แกลเลอรี่ถูกลบออกแล้ว"
|
||||
_fileViewer:
|
||||
title: "รายละเอียดไฟล์"
|
||||
type: "ประเภทไฟล์"
|
||||
|
@ -2631,3 +2696,17 @@ _contextMenu:
|
|||
app: "แอปพลิเคชัน"
|
||||
appWithShift: "แอปฟลิเคชันด้วยปุ่มยกแคร่ (Shift)"
|
||||
native: "UI ของเบราว์เซอร์"
|
||||
_embedCodeGen:
|
||||
title: "ปรับแต่งโค้ดฝัง"
|
||||
header: "แสดงส่วนหัว"
|
||||
autoload: "โหลดเพิ่มโดยอัตโนมัติ (เลิกใช้แล้ว)"
|
||||
maxHeight: "ความสูงสุด"
|
||||
maxHeightDescription: "หากถ้าตั้งค่าเป็น 0 จะทำให้ไม่มีการจำกัดความสูงของวิดเจ็ต แต่ควรตั้งค่าเป็นตัวเลขอื่นๆ เพื่อไม่ให้วิดเจ็ตยืดตัวลงไปเรื่อยๆ"
|
||||
maxHeightWarn: "การจำกัดความสูงสูงสุดถูกปิดใช้งาน (0) หากไม่ได้ตั้งใจให้เป็นเช่นนี้ โปรดตั้งค่าความสูงสูงสุดให้เป็นค่าอื่นๆแทน"
|
||||
previewIsNotActual: "การแสดงผลนั้นต่างจากการฝังจริงเพราะเกินขอบเขตที่แสดงบนหน้าจอตัวอย่างนะ"
|
||||
rounded: "ทำให้มันกลม"
|
||||
border: "เพิ่มขอบให้กับกรอบด้านนอก"
|
||||
applyToPreview: "นำไปใช้กับการแสดงตัวอย่าง"
|
||||
generateCode: "สร้างโค้ดสำหรับการฝัง"
|
||||
codeGenerated: "รหัสถูกสร้างขึ้นแล้ว"
|
||||
codeGeneratedDescription: "นำโค้ดที่สร้างแล้วไปวางในเว็บไซต์ของคุณเพื่อฝังเนื้อหา"
|
||||
|
|
|
@ -213,8 +213,8 @@ charts: "图表"
|
|||
perHour: "每小时"
|
||||
perDay: "每天"
|
||||
stopActivityDelivery: "停止发送活动"
|
||||
blockThisInstance: "阻止此服务器向本服务器推流"
|
||||
silenceThisInstance: "使服务器静音"
|
||||
blockThisInstance: "封锁此服务器"
|
||||
silenceThisInstance: "静音此服务器"
|
||||
mediaSilenceThisInstance: "隐藏此服务器的媒体文件"
|
||||
operations: "操作"
|
||||
software: "软件"
|
||||
|
@ -258,7 +258,7 @@ noCustomEmojis: "没有自定义表情符号"
|
|||
noJobs: "没有任务"
|
||||
federating: "联合中"
|
||||
blocked: "已拉黑"
|
||||
suspended: "停止推流"
|
||||
suspended: "停止投递"
|
||||
all: "全部"
|
||||
subscribing: "已订阅"
|
||||
publishing: "投递中"
|
||||
|
@ -706,7 +706,7 @@ useGlobalSettingDesc: "启用时,将使用账户通知设置。关闭时,则
|
|||
other: "其他"
|
||||
regenerateLoginToken: "重新生成登录令牌"
|
||||
regenerateLoginTokenDescription: "重新生成用于登录的内部令牌。通常您不需要这样做。重新生成后,您将在所有设备上登出。"
|
||||
theKeywordWhenSearchingForCustomEmoji: "这将是搜素自定义表情符号时的关键词。"
|
||||
theKeywordWhenSearchingForCustomEmoji: "这将是搜索自定义表情符号时的关键词。"
|
||||
setMultipleBySeparatingWithSpace: "您可以使用空格分隔多个项目。"
|
||||
fileIdOrUrl: "文件 ID 或者 URL"
|
||||
behavior: "行为"
|
||||
|
@ -947,6 +947,9 @@ oneHour: "1 小时"
|
|||
oneDay: "1 天"
|
||||
oneWeek: "1 周"
|
||||
oneMonth: "1 个月"
|
||||
threeMonths: "3 个月"
|
||||
oneYear: "1 年"
|
||||
threeDays: "3 天"
|
||||
reflectMayTakeTime: "可能需要一些时间才能体现出效果。"
|
||||
failedToFetchAccountInformation: "获取账户信息失败"
|
||||
rateLimitExceeded: "已超过速率限制"
|
||||
|
@ -1070,7 +1073,7 @@ nonSensitiveOnlyForLocalLikeOnlyForRemote: "仅限非敏感内容(远程仅点
|
|||
rolesAssignedToMe: "指派给自己的角色"
|
||||
resetPasswordConfirm: "确定重置密码?"
|
||||
sensitiveWords: "敏感词"
|
||||
sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。"
|
||||
sensitiveWordsDescription: "包含这些词的帖子将只在首页可见。可用换行来设定多个词。"
|
||||
sensitiveWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。"
|
||||
prohibitedWords: "禁用词"
|
||||
prohibitedWordsDescription: "发布包含设定词汇的帖子时将出错。可用换行设定多个关键字"
|
||||
|
@ -1293,6 +1296,21 @@ prohibitedWordsForNameOfUser: "用户名中禁止的词"
|
|||
prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
|
||||
yourNameContainsProhibitedWords: "目标用户名包含违禁词"
|
||||
yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "根据发帖者的设定,需要登录才能显示"
|
||||
lockdown: "锁定"
|
||||
pleaseSelectAccount: "请选择帐户"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "需要登录才能显示内容"
|
||||
requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。"
|
||||
requireSigninToViewContentsDescription2: "没有 URL 预览(OGP)、内嵌网页、引用帖子的功能的服务器也将无法显示。"
|
||||
requireSigninToViewContentsDescription3: "这些限制可能不适用于联合到远程服务器的内容。"
|
||||
makeNotesFollowersOnlyBefore: "可将过去的帖子设为仅关注者可见"
|
||||
makeNotesFollowersOnlyBeforeDescription: "开启此设定时,超过设定的时间或日期后,帖子将变为仅关注者可见。关闭后帖子的公开状态将恢复成原本的设定。"
|
||||
makeNotesHiddenBefore: "将过去的帖子设为私密"
|
||||
makeNotesHiddenBeforeDescription: "开启此设定时,超过设定的时间或日期后,帖子将变为仅自己可见。关闭后帖子的公开状态将恢复成原本的设定。"
|
||||
mayNotEffectForFederatedNotes: "与远程服务器联合的帖子在远端可能会没有效果。"
|
||||
notesHavePassedSpecifiedPeriod: "超过指定时间的帖子"
|
||||
notesOlderThanSpecifiedDateAndTime: "指定日期前的帖子"
|
||||
_abuseUserReport:
|
||||
forward: "转发"
|
||||
forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
|
||||
|
@ -2157,8 +2175,11 @@ _auth:
|
|||
permissionAsk: "这个应用程序需要以下权限"
|
||||
pleaseGoBack: "请返回到应用程序"
|
||||
callback: "回到应用程序"
|
||||
accepted: "已允许访问"
|
||||
denied: "拒绝访问"
|
||||
scopeUser: "以下面的用户进行操作"
|
||||
pleaseLogin: "在对应用进行授权许可之前,请先登录"
|
||||
byClickingYouWillBeRedirectedToThisUrl: "允许访问后将会自动重定向到以下 URL"
|
||||
_antennaSources:
|
||||
all: "所有帖子"
|
||||
homeTimeline: "已关注用户的帖子"
|
||||
|
@ -2710,3 +2731,9 @@ _embedCodeGen:
|
|||
generateCode: "生成嵌入代码"
|
||||
codeGenerated: "已生成代码"
|
||||
codeGeneratedDescription: "将生成的代码贴到网站上来使用。"
|
||||
_selfXssPrevention:
|
||||
warning: "警告"
|
||||
title: "「在此处粘贴什么东西」是欺诈行为。"
|
||||
description1: "如果在此处粘贴了什么,恶意用户可能会接管账户或者盗取个人资料。"
|
||||
description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。"
|
||||
description3: "详情请看这里。{link}"
|
||||
|
|
|
@ -947,6 +947,9 @@ oneHour: "一小時"
|
|||
oneDay: "一天"
|
||||
oneWeek: "一週"
|
||||
oneMonth: "一個月"
|
||||
threeMonths: "3 個月"
|
||||
oneYear: "1 年"
|
||||
threeDays: "3 日"
|
||||
reflectMayTakeTime: "可能需要一些時間才會出現效果。"
|
||||
failedToFetchAccountInformation: "取得帳戶資訊失敗"
|
||||
rateLimitExceeded: "已超過速率限制"
|
||||
|
@ -1293,6 +1296,21 @@ prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
|
|||
prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
|
||||
yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
|
||||
yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "作者將其設定為需要登入才能顯示。"
|
||||
lockdown: "鎖定"
|
||||
pleaseSelectAccount: "請選擇帳戶"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "須登入以顯示內容"
|
||||
requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。"
|
||||
requireSigninToViewContentsDescription2: "來自不支援 URL 預覽 (OGP)、 網頁嵌入和引用貼文的伺服器,也將停止顯示。"
|
||||
requireSigninToViewContentsDescription3: "這些限制可能不適用於被聯邦發送至遠端伺服器的內容。"
|
||||
makeNotesFollowersOnlyBefore: "讓過去的貼文僅對追隨者顯示"
|
||||
makeNotesFollowersOnlyBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對追隨者顯示。 如果您再次停用它,貼文的公開狀態也會恢復原狀。"
|
||||
makeNotesHiddenBefore: "隱藏過去的貼文"
|
||||
makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。"
|
||||
mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。"
|
||||
notesHavePassedSpecifiedPeriod: "早於指定時間的貼文"
|
||||
notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文"
|
||||
_abuseUserReport:
|
||||
forward: "轉發"
|
||||
forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
|
||||
|
@ -2157,8 +2175,11 @@ _auth:
|
|||
permissionAsk: "此應用程式需要以下權限"
|
||||
pleaseGoBack: "請返回至應用程式"
|
||||
callback: "回到應用程式"
|
||||
accepted: "已授予存取權限"
|
||||
denied: "拒絕訪問"
|
||||
scopeUser: "以下列使用者身分操作"
|
||||
pleaseLogin: "必須登入以提供應用程式的存取權限。"
|
||||
byClickingYouWillBeRedirectedToThisUrl: "如果授予存取權限,就會自動導向到以下的網址"
|
||||
_antennaSources:
|
||||
all: "全部貼文"
|
||||
homeTimeline: "來自已追隨使用者的貼文"
|
||||
|
@ -2416,7 +2437,7 @@ _notification:
|
|||
follow: "追隨中"
|
||||
mention: "提及"
|
||||
reply: "回覆"
|
||||
renote: "轉發貼文"
|
||||
renote: "轉發"
|
||||
quote: "引用"
|
||||
reaction: "反應"
|
||||
pollEnded: "問卷調查結束"
|
||||
|
@ -2710,3 +2731,9 @@ _embedCodeGen:
|
|||
generateCode: "建立嵌入程式碼"
|
||||
codeGenerated: "已產生程式碼"
|
||||
codeGeneratedDescription: "請將產生的程式碼貼到您的網站上。"
|
||||
_selfXssPrevention:
|
||||
warning: "警告"
|
||||
title: "「在此畫面貼上一些內容」完全是個騙局。"
|
||||
description1: "如果您在此處貼上任何內容,惡意使用者可能會接管您的帳戶或竊取您的個人資訊。"
|
||||
description2: "如果您不確切知道要貼上的內容,%c 請立即停止工作並關閉此視窗。"
|
||||
description3: "細節請看這裡。{link}"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2024.10.1-beta.6",
|
||||
"version": "2024.10.2-alpha.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -11,7 +11,7 @@ export default [
|
|||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json', './test/tsconfig.json'],
|
||||
project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
|
|
13
packages/backend/jest.config.fed.cjs
Normal file
13
packages/backend/jest.config.fed.cjs
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
const base = require('./jest.config.cjs');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
testMatch: [
|
||||
'<rootDir>/test-federation/test/**/*.test.ts',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SigninRequiredForShowContents1729333924409 {
|
||||
name = 'SigninRequiredForShowContents1729333924409'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "requireSigninToViewContents" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "requireSigninToViewContents"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class MakeNotesHiddenBefore1729486255072 {
|
||||
name = 'MakeNotesHiddenBefore1729486255072'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesFollowersOnlyBefore" integer`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesHiddenBefore" integer`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesHiddenBefore"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesFollowersOnlyBefore"`);
|
||||
}
|
||||
}
|
|
@ -19,16 +19,18 @@
|
|||
"watch": "node ./scripts/watch.mjs",
|
||||
"restart": "pnpm build && pnpm start",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p test --noEmit",
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"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:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
|
||||
"test:fed": "pnpm jest:fed",
|
||||
"test-and-coverage": "pnpm jest-and-coverage",
|
||||
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
|
||||
"generate-api-json": "node ./scripts/generate_api_json.js"
|
||||
|
|
|
@ -5,11 +5,52 @@
|
|||
|
||||
import Redis from 'ioredis';
|
||||
import { loadConfig } from '../built/config.js';
|
||||
import { createPostgresDataSource } from '../built/postgres.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const redis = new Redis(config.redis);
|
||||
|
||||
redis.on('connect', () => redis.disconnect());
|
||||
redis.on('error', (e) => {
|
||||
throw e;
|
||||
});
|
||||
async function connectToPostgres() {
|
||||
const source = createPostgresDataSource(config);
|
||||
await source.initialize();
|
||||
await source.destroy();
|
||||
}
|
||||
|
||||
async function connectToRedis(redisOptions) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
const redis = new Redis({
|
||||
...redisOptions,
|
||||
lazyConnect: true,
|
||||
reconnectOnError: false,
|
||||
showFriendlyErrorStack: true,
|
||||
});
|
||||
redis.on('error', e => reject(e));
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
resolve();
|
||||
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
|
||||
} finally {
|
||||
redis.disconnect(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If not all of these are defined, the default one gets reused.
|
||||
// so we use a Set to only try connecting once to each **uniq** redis.
|
||||
const promises = Array
|
||||
.from(new Set([
|
||||
config.redis,
|
||||
config.redisForPubsub,
|
||||
config.redisForJobQueue,
|
||||
config.redisForTimelines,
|
||||
config.redisForReactions,
|
||||
]))
|
||||
.map(connectToRedis)
|
||||
.concat([
|
||||
connectToPostgres()
|
||||
]);
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
|
|
@ -83,6 +83,9 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
requireSigninToViewContents: false,
|
||||
makeNotesFollowersOnlyBefore: null,
|
||||
makeNotesHiddenBefore: null,
|
||||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
|
|
|
@ -495,6 +495,9 @@ export class ApRendererService {
|
|||
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
|
||||
_misskey_summary: profile.description,
|
||||
_misskey_followedMessage: profile.followedMessage,
|
||||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
icon: avatar ? this.renderImage(avatar) : null,
|
||||
image: banner ? this.renderImage(banner) : null,
|
||||
tag,
|
||||
|
|
|
@ -556,6 +556,9 @@ const extension_context_definition = {
|
|||
'_misskey_votes': 'misskey:_misskey_votes',
|
||||
'_misskey_summary': 'misskey:_misskey_summary',
|
||||
'_misskey_followedMessage': 'misskey:_misskey_followedMessage',
|
||||
'_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents',
|
||||
'_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore',
|
||||
'_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore',
|
||||
'isCat': 'misskey:isCat',
|
||||
// vcard
|
||||
vcard: 'http://www.w3.org/2006/vcard/ns#',
|
||||
|
|
|
@ -286,6 +286,12 @@ export class ApPersonService implements OnModuleInit {
|
|||
if (user == null) throw new Error('failed to create user: user is null');
|
||||
|
||||
const [avatar, banner] = await Promise.all([icon, image].map(img => {
|
||||
// icon and image may be arrays
|
||||
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
||||
if (Array.isArray(img)) {
|
||||
img = img.find(item => item && item.url) ?? null;
|
||||
}
|
||||
|
||||
// if we have an explicitly missing image, return an
|
||||
// explicitly-null set of values
|
||||
if ((img == null) || (typeof img === 'object' && img.url == null)) {
|
||||
|
@ -410,6 +416,9 @@ export class ApPersonService implements OnModuleInit {
|
|||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
requireSigninToViewContents: (person as any).requireSigninToViewContents === true,
|
||||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
})) as MiRemoteUser;
|
||||
|
||||
|
|
|
@ -14,6 +14,9 @@ export interface IObject {
|
|||
summary?: string;
|
||||
_misskey_summary?: string;
|
||||
_misskey_followedMessage?: string | null;
|
||||
_misskey_requireSigninToViewContents?: boolean;
|
||||
_misskey_makeNotesFollowersOnlyBefore?: number | null;
|
||||
_misskey_makeNotesHiddenBefore?: number | null;
|
||||
published?: string;
|
||||
cc?: ApObject;
|
||||
to?: ApObject;
|
||||
|
|
|
@ -102,50 +102,80 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) {
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
|
||||
// FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある)
|
||||
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
|
||||
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
|
||||
if ((followersOnlyBefore != null)
|
||||
&& (
|
||||
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|
||||
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
|
||||
)
|
||||
) {
|
||||
packedNote.visibility = 'followers';
|
||||
}
|
||||
}
|
||||
|
||||
if (meId === packedNote.userId) return;
|
||||
|
||||
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
|
||||
let hide = false;
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
if (packedNote.user.requireSigninToViewContents && meId == null) {
|
||||
hide = true;
|
||||
}
|
||||
|
||||
if (specified) {
|
||||
hide = false;
|
||||
} else {
|
||||
if (!hide) {
|
||||
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
|
||||
if ((hiddenBefore != null)
|
||||
&& (
|
||||
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|
||||
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
|
||||
)
|
||||
) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
|
||||
if (!specified) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
|
||||
hide = !isFollowing;
|
||||
hide = !isFollowing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,6 +187,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
packedNote.poll = undefined;
|
||||
packedNote.cw = null;
|
||||
packedNote.isHidden = true;
|
||||
// TODO: hiddenReason みたいなのを提供しても良さそう
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -490,6 +490,9 @@ export class UserEntityService implements OnModuleInit {
|
|||
}))) : [],
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
||||
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
||||
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
// NoteEntityService.isPureRenote とよしなにリンク
|
||||
|
||||
type Renote =
|
||||
MiNote & {
|
||||
renoteId: NonNullable<MiNote['renoteId']>
|
||||
|
|
|
@ -202,6 +202,23 @@ export class MiUser {
|
|||
})
|
||||
public isHibernated: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public requireSigninToViewContents: boolean;
|
||||
|
||||
// in sec, マイナスで相対時間
|
||||
@Column('integer', {
|
||||
nullable: true,
|
||||
})
|
||||
public makeNotesFollowersOnlyBefore: number | null;
|
||||
|
||||
// in sec, マイナスで相対時間
|
||||
@Column('integer', {
|
||||
nullable: true,
|
||||
})
|
||||
public makeNotesHiddenBefore: number | null;
|
||||
|
||||
// アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
|
|
@ -115,6 +115,18 @@ export const packedUserLiteSchema = {
|
|||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
requireSigninToViewContents: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
makeNotesFollowersOnlyBefore: {
|
||||
type: 'number',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
makeNotesHiddenBefore: {
|
||||
type: 'number',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
instance: {
|
||||
type: 'object',
|
||||
nullable: false, optional: true,
|
||||
|
|
|
@ -319,6 +319,12 @@ export class FileServerService {
|
|||
);
|
||||
}
|
||||
|
||||
if (!request.headers['user-agent']) {
|
||||
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
|
||||
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
|
||||
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const file = await this.getStreamAndTypeFromUrl(url);
|
||||
if (file === '404') {
|
||||
|
|
|
@ -39,6 +39,17 @@ export class GetterService {
|
|||
return note;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getNoteWithUser(noteId: MiNote['id']) {
|
||||
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] });
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user for API processing
|
||||
*/
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
@ -13,6 +14,49 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
requireRolePolicy: 'canManageAvatarDecorations',
|
||||
kind: 'write:admin:avatar-decorations',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
roleIdsThatCanBeUsedThisDecoration: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -32,14 +76,25 @@ export const paramDef = {
|
|||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private avatarDecorationService: AvatarDecorationService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.avatarDecorationService.create({
|
||||
const created = await this.avatarDecorationService.create({
|
||||
name: ps.name,
|
||||
description: ps.description,
|
||||
url: ps.url,
|
||||
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
|
||||
}, me);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
createdAt: this.idService.parse(created.id).date.toISOString(),
|
||||
updatedAt: null,
|
||||
name: created.name,
|
||||
description: created.description,
|
||||
url: created.url,
|
||||
roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js';
|
||||
import type { MiAnnouncement } from '@/models/Announcement.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
|
|
|
@ -179,6 +179,9 @@ export const paramDef = {
|
|||
autoAcceptFollowed: { type: 'boolean' },
|
||||
noCrawle: { type: 'boolean' },
|
||||
preventAiLearning: { type: 'boolean' },
|
||||
requireSigninToViewContents: { type: 'boolean' },
|
||||
makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true },
|
||||
makeNotesHiddenBefore: { type: 'integer', nullable: true },
|
||||
isBot: { type: 'boolean' },
|
||||
isCat: { type: 'boolean' },
|
||||
injectFeaturedNote: { type: 'boolean' },
|
||||
|
@ -334,6 +337,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
|
||||
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
|
||||
if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning;
|
||||
if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents;
|
||||
if ((typeof ps.makeNotesFollowersOnlyBefore === 'number') || (ps.makeNotesFollowersOnlyBefore === null)) updates.makeNotesFollowersOnlyBefore = ps.makeNotesFollowersOnlyBefore;
|
||||
if ((typeof ps.makeNotesHiddenBefore === 'number') || (ps.makeNotesHiddenBefore === null)) updates.makeNotesHiddenBefore = ps.makeNotesHiddenBefore;
|
||||
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
|
||||
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
|
||||
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
|
||||
|
|
|
@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
||||
const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
|
||||
id: MoreThan(this.idService.gen(Date.now() - (policies.inviteExpirationTime * 60 * 1000))),
|
||||
id: MoreThan(this.idService.gen(Date.now() - (policies.inviteLimitCycle * 60 * 1000))),
|
||||
createdById: me.id,
|
||||
}) : null;
|
||||
|
||||
|
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
code: 'NO_SUCH_NOTE',
|
||||
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
|
||||
},
|
||||
|
||||
signinRequired: {
|
||||
message: 'Signin required.',
|
||||
code: 'SIGNIN_REQUIRED',
|
||||
id: '8e75455b-738c-471d-9f80-62693f33372e',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -44,11 +50,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private getterService: GetterService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNote(ps.noteId).catch(err => {
|
||||
const note = await this.getterService.getNoteWithUser(ps.noteId).catch(err => {
|
||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (note.user!.requireSigninToViewContents && me == null) {
|
||||
throw new ApiError(meta.errors.signinRequired);
|
||||
}
|
||||
|
||||
return await this.noteEntityService.pack(note, me, {
|
||||
detail: true,
|
||||
});
|
||||
|
|
|
@ -42,6 +42,12 @@ export const meta = {
|
|||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||
id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222',
|
||||
},
|
||||
|
||||
signinRequired: {
|
||||
message: 'Signin required.',
|
||||
code: 'SIGNIN_REQUIRED',
|
||||
id: 'd1588a9e-4b4d-4c07-807f-16f1486577a2',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import type {
|
|||
EndedPollNotificationQueue,
|
||||
InboxQueue,
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
|
@ -121,6 +122,7 @@ export class ClientServerService {
|
|||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
|
@ -248,6 +250,7 @@ export class ClientServerService {
|
|||
this.deliverQueue,
|
||||
this.inboxQueue,
|
||||
this.dbQueue,
|
||||
this.relationshipQueue,
|
||||
this.objectStorageQueue,
|
||||
this.userWebhookDeliverQueue,
|
||||
this.systemWebhookDeliverQueue,
|
||||
|
@ -598,12 +601,15 @@ export class ClientServerService {
|
|||
fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => {
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
const note = await this.notesRepository.findOne({
|
||||
where: {
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (note) {
|
||||
if (note && !note.user!.requireSigninToViewContents) {
|
||||
const _note = await this.noteEntityService.pack(note);
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
||||
reply.header('Cache-Control', 'public, max-age=15');
|
||||
|
|
70
packages/backend/test-federation/.config/example.conf
Normal file
70
packages/backend/test-federation/.config/example.conf
Normal file
|
@ -0,0 +1,70 @@
|
|||
# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md
|
||||
|
||||
# For WebSocket
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOST};
|
||||
|
||||
# For SSL domain validation
|
||||
root /var/www/html;
|
||||
location /.well-known/acme-challenge/ { allow all; }
|
||||
location /.well-known/pki-validation/ { allow all; }
|
||||
location / { return 301 https://$server_name$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name ${HOST};
|
||||
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:ssl_session_cache:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt;
|
||||
ssl_certificate /etc/nginx/certificates/$server_name.crt;
|
||||
ssl_certificate_key /etc/nginx/certificates/$server_name.key;
|
||||
|
||||
# SSL protocol settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
|
||||
# Change to your upload limit
|
||||
client_max_body_size 80m;
|
||||
|
||||
# Proxy to Node
|
||||
location / {
|
||||
proxy_pass http://misskey.${HOST}:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
|
||||
# If it's behind another reverse proxy or CDN, remove the following.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
|
||||
# For WebSocket
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Cache settings
|
||||
proxy_cache cache1;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_force_ranges on;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
}
|
||||
}
|
25
packages/backend/test-federation/.config/example.default.yml
Normal file
25
packages/backend/test-federation/.config/example.default.yml
Normal file
|
@ -0,0 +1,25 @@
|
|||
url: https://${HOST}/
|
||||
port: 3000
|
||||
db:
|
||||
host: db.${HOST}
|
||||
port: 5432
|
||||
db: misskey
|
||||
user: postgres
|
||||
pass: postgres
|
||||
dbReplications: false
|
||||
redis:
|
||||
host: redis.test
|
||||
port: 6379
|
||||
id: 'aidx'
|
||||
proxyBypassHosts:
|
||||
- api.deepl.com
|
||||
- api-free.deepl.com
|
||||
- www.recaptcha.net
|
||||
- hcaptcha.com
|
||||
- challenges.cloudflare.com
|
||||
proxyRemoteFiles: true
|
||||
signToActivityPubGet: true
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32',
|
||||
'172.20.0.0/16'
|
||||
]
|
|
@ -0,0 +1,5 @@
|
|||
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
POSTGRES_DB=misskey
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
MK_VERBOSE=true
|
6
packages/backend/test-federation/.gitignore
vendored
Normal file
6
packages/backend/test-federation/.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
certificates
|
||||
volumes
|
||||
.env
|
||||
docker.env
|
||||
*.test.conf
|
||||
*.test.default.yml
|
24
packages/backend/test-federation/README.md
Normal file
24
packages/backend/test-federation/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
## test-federation
|
||||
Test federation between two Misskey servers: `a.test` and `b.test`.
|
||||
|
||||
Before testing, you need to build the entire project, and change working directory to here:
|
||||
```sh
|
||||
pnpm build
|
||||
cd packages/backend/test-federation
|
||||
```
|
||||
|
||||
First, you need to start servers by executing following commands:
|
||||
```sh
|
||||
bash ./setup.sh
|
||||
docker compose up --scale tester=0
|
||||
```
|
||||
|
||||
Then you can run all tests by a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester
|
||||
```
|
||||
|
||||
For testing a specific file, run a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
|
||||
```
|
64
packages/backend/test-federation/compose.a.yml
Normal file
64
packages/backend/test-federation/compose.a.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
services:
|
||||
a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.a.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.conf
|
||||
target: /etc/nginx/conf.d/a.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.crt
|
||||
target: /etc/nginx/certificates/a.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.key
|
||||
target: /etc/nginx/certificates/a.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.a.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.a
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_a:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
ip_range: 172.21.0.0/24
|
64
packages/backend/test-federation/compose.b.yml
Normal file
64
packages/backend/test-federation/compose.b.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
services:
|
||||
b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.b.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.conf
|
||||
target: /etc/nginx/conf.d/b.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.crt
|
||||
target: /etc/nginx/certificates/b.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.key
|
||||
target: /etc/nginx/certificates/b.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.b.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.b
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_b:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
ip_range: 172.22.0.0/24
|
117
packages/backend/test-federation/compose.override.yaml
Normal file
117
packages/backend/test-federation/compose.override.yaml
Normal file
|
@ -0,0 +1,117 @@
|
|||
services:
|
||||
setup:
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
tester:
|
||||
networks:
|
||||
external_network:
|
||||
internal_network:
|
||||
ipv4_address: 172.20.1.1
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js_dev
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
|
||||
daemon:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
|
||||
redis.test:
|
||||
networks:
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
|
||||
a.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.a.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
b.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.b.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
networks:
|
||||
external_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.23.0.0/16
|
||||
ip_range: 172.23.0.0/24
|
||||
internal_network:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
ip_range: 172.20.0.0/24
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
node_modules_dev:
|
||||
node_modules_backend:
|
||||
node_modules_backend_dev:
|
||||
node_modules_misskey-js:
|
||||
node_modules_misskey-js_dev:
|
||||
node_modules_misskey-reversi:
|
101
packages/backend/test-federation/compose.tpl.yml
Normal file
101
packages/backend/test-federation/compose.tpl.yml
Normal file
|
@ -0,0 +1,101 @@
|
|||
services:
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /etc/nginx/certificates/rootCA.crt
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test: service nginx status
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
misskey:
|
||||
image: node:20
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../../../built
|
||||
target: /misskey/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../assets
|
||||
target: /misskey/packages/backend/assets
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../built
|
||||
target: /misskey/packages/backend/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../migration
|
||||
target: /misskey/packages/backend/migration
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../ormconfig.js
|
||||
target: /misskey/packages/backend/ormconfig.js
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/built
|
||||
target: /misskey/packages/misskey-reversi/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/package.json
|
||||
target: /misskey/packages/misskey-reversi/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../healthcheck.sh
|
||||
target: /misskey/healthcheck.sh
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend migrate
|
||||
pnpm -F backend start
|
||||
"
|
||||
healthcheck:
|
||||
test: bash /misskey/healthcheck.sh
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
volumes:
|
||||
healthcheck:
|
||||
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
|
||||
interval: 5s
|
||||
retries: 20
|
133
packages/backend/test-federation/compose.yml
Normal file
133
packages/backend/test-federation/compose.yml
Normal file
|
@ -0,0 +1,133 @@
|
|||
include:
|
||||
- ./compose.a.yml
|
||||
- ./compose.b.yml
|
||||
|
||||
services:
|
||||
setup:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i
|
||||
pnpm -F misskey-js i
|
||||
pnpm -F misskey-reversi i
|
||||
"
|
||||
|
||||
tester:
|
||||
image: node:20
|
||||
depends_on:
|
||||
a.test:
|
||||
condition: service_healthy
|
||||
b.test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../test/resources
|
||||
target: /misskey/packages/backend/test/resources
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./test
|
||||
target: /misskey/packages/backend/test-federation/test
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.cjs
|
||||
target: /misskey/packages/backend/jest.config.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.fed.cjs
|
||||
target: /misskey/packages/backend/jest.config.fed.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
entrypoint: >
|
||||
bash -c '
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F misskey-js i --frozen-lockfile
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
exec "$0" "$@"
|
||||
'
|
||||
command: pnpm -F backend test:fed
|
||||
|
||||
daemon:
|
||||
image: node:20
|
||||
depends_on:
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./daemon.ts
|
||||
target: /misskey/packages/backend/test-federation/daemon.ts
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./tsconfig.json
|
||||
target: /misskey/packages/backend/test-federation/tsconfig.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
pnpm exec tsc -p ./packages/backend/test-federation
|
||||
node ./packages/backend/test-federation/built/daemon.js
|
||||
"
|
||||
|
||||
redis.test:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/redis
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 20
|
38
packages/backend/test-federation/daemon.ts
Normal file
38
packages/backend/test-federation/daemon.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import IPCIDR from 'ip-cidr';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
const TESTER_IP_ADDRESS = '172.20.1.1';
|
||||
|
||||
/**
|
||||
* This should be same as {@link file://./../src/misc/get-ip-hash.ts}.
|
||||
*/
|
||||
function getIpHash(ip: string) {
|
||||
const prefix = IPCIDR.createAddress(ip).mask(64);
|
||||
return `ip-${BigInt('0b' + prefix).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* This prevents hitting rate limit when login.
|
||||
*/
|
||||
export async function purgeLimit(host: string, client: Redis) {
|
||||
const ipHash = getIpHash(TESTER_IP_ADDRESS);
|
||||
const key = `${host}:limit:${ipHash}:signin`;
|
||||
const res = await client.zrange(key, 0, -1);
|
||||
if (res.length !== 0) {
|
||||
console.log(`${key} - ${JSON.stringify(res)}`);
|
||||
await client.del(key);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Daemon started running');
|
||||
|
||||
{
|
||||
const redisClient = new Redis({
|
||||
host: 'redis.test',
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
purgeLimit('a.test', redisClient);
|
||||
purgeLimit('b.test', redisClient);
|
||||
}, 200);
|
||||
}
|
21
packages/backend/test-federation/eslint.config.js
Normal file
21
packages/backend/test-federation/eslint.config.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../shared/eslint.config.js';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
35
packages/backend/test-federation/setup.sh
Normal file
35
packages/backend/test-federation/setup.sh
Normal file
|
@ -0,0 +1,35 @@
|
|||
#!/bin/bash
|
||||
mkdir certificates
|
||||
|
||||
# rootCA
|
||||
openssl genrsa -des3 \
|
||||
-passout pass:rootCA \
|
||||
-out certificates/rootCA.key 4096
|
||||
openssl req -x509 -new -nodes -batch \
|
||||
-key certificates/rootCA.key \
|
||||
-sha256 \
|
||||
-days 1024 \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/rootCA.crt
|
||||
|
||||
# domain
|
||||
function generate {
|
||||
openssl req -new -newkey rsa:2048 -sha256 -nodes \
|
||||
-keyout certificates/$1.key \
|
||||
-subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \
|
||||
-out certificates/$1.csr
|
||||
openssl x509 -req -sha256 \
|
||||
-in certificates/$1.csr \
|
||||
-CA certificates/rootCA.crt \
|
||||
-CAkey certificates/rootCA.key \
|
||||
-CAcreateserial \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/$1.crt \
|
||||
-days 500
|
||||
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
|
||||
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
|
||||
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
|
||||
}
|
||||
|
||||
generate a.test
|
||||
generate b.test
|
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
|
||||
|
||||
describe('Abuse report', () => {
|
||||
describe('Forwarding report', () => {
|
||||
let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[aModerator, bModerator] = await Promise.all([
|
||||
createModerator('a.test'),
|
||||
createModerator('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => {
|
||||
const comment = crypto.randomUUID();
|
||||
await alice.client.request('users/report-abuse', { userId: bobInA.id, comment });
|
||||
const reports = await aModerator.client.request('admin/abuse-user-reports', {});
|
||||
const report = reports.filter(report => report.comment === comment)[0];
|
||||
await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
|
||||
await sleep();
|
||||
|
||||
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
|
||||
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
|
||||
// NOTE: reporter is not Alice, and is not moderator in A
|
||||
strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
|
||||
strictEqual(reportInB.targetUserId, bob.id);
|
||||
|
||||
// NOTE: cannot forward multiple times
|
||||
await rejects(
|
||||
async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
strictEqual(err.info.e.message, 'The report has already been forwarded.');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
224
packages/backend/test-federation/test/block.test.ts
Normal file
224
packages/backend/test-federation/test/block.test.ts
Normal file
|
@ -0,0 +1,224 @@
|
|||
import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Block', () => {
|
||||
describe('Check follow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot follow if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot follow even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can follow if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
test.skip('Remove follower when block them', async () => {
|
||||
test('before block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
test('after block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reply', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reply if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Can reply if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote;
|
||||
|
||||
await resolveRemoteNote('b.test', reply.id, alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reaction', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reaction if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot reaction even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can reaction if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' });
|
||||
|
||||
const _note = await alice.client.request('notes/show', { noteId: note.id });
|
||||
deepStrictEqual(_note.reactions, { '😅': 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check mention', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
/** NOTE: You should mute the target to stop receiving notifications */
|
||||
test('Can mention and notified even if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const text = `@${alice.username}@a.test plz unblock me!`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
175
packages/backend/test-federation/test/drive.test.ts
Normal file
175
packages/backend/test-federation/test/drive.test.ts
Normal file
|
@ -0,0 +1,175 @@
|
|||
import assert, { strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Drive', () => {
|
||||
describe('Upload image in a.test and resolve from b.test', () => {
|
||||
let uploader: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
uploader = await createAccount('a.test');
|
||||
});
|
||||
|
||||
let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Upload', () => {
|
||||
beforeAll(async () => {
|
||||
image = await uploadFile('a.test', uploader);
|
||||
const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
imageInB = noteInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency of DriveFile', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(image, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(image, imageInB, [
|
||||
'id',
|
||||
'createdAt',
|
||||
'size',
|
||||
'url',
|
||||
'thumbnailUrl',
|
||||
'userId',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Update', () => {
|
||||
beforeAll(async () => {
|
||||
updatedImage = await uploader.client.request('drive/files/update', {
|
||||
fileId: image.id,
|
||||
name: 'updated_192.jpg',
|
||||
isSensitive: true,
|
||||
});
|
||||
|
||||
updatedImageInB = await bAdmin.client.request('drive/files/show', {
|
||||
fileId: imageInB.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`);
|
||||
|
||||
// FIXME: not updated with `drive/files/update`
|
||||
strictEqual(updatedImage.isSensitive, true);
|
||||
strictEqual(updatedImage.name, 'updated_192.jpg');
|
||||
strictEqual(updatedImageInB.isSensitive, false);
|
||||
strictEqual(updatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
let reupdatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Re-update with attaching to Note', () => {
|
||||
beforeAll(async () => {
|
||||
const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote;
|
||||
const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin);
|
||||
assert(noteWithUpdatedImageInB.files != null);
|
||||
strictEqual(noteWithUpdatedImageInB.files.length, 1);
|
||||
reupdatedImageInB = noteWithUpdatedImageInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`);
|
||||
|
||||
// `isSensitive` is updated
|
||||
strictEqual(reupdatedImageInB.isSensitive, true);
|
||||
// FIXME: but `name` is not updated
|
||||
strictEqual(reupdatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive flag', () => {
|
||||
describe('isSensitive is federated in delivering to followers', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
strictEqual(notes.length, 1);
|
||||
const noteInB = notes[0];
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSensitive is federated in resolving', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote;
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12208 */
|
||||
describe('isSensitive is federated in replying', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote;
|
||||
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice);
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote;
|
||||
await sleep();
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import assert, { deepStrictEqual, strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Emoji', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
assert(noteInB.emojis != null);
|
||||
assert(emoji.name in noteInB.emojis);
|
||||
strictEqual(noteInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
|
||||
deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
assert(emoji.name in renewedaliceInB.emojis);
|
||||
strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
|
||||
deepStrictEqual({ ...noteInB.emojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
|
||||
deepStrictEqual({ ...noteInB.reactionEmojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
deepStrictEqual({ ...renewedaliceInB.emojis }, {});
|
||||
});
|
||||
});
|
52
packages/backend/test-federation/test/move.test.ts
Normal file
52
packages/backend/test-federation/test/move.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import assert, { strictEqual } from 'node:assert';
|
||||
import { createAccount, type LoginUser, sleep } from './utils.js';
|
||||
|
||||
describe('Move', () => {
|
||||
test('Minimum move', async () => {
|
||||
const [alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/11320 */
|
||||
describe('Following relation is transferred after move', () => {
|
||||
let alice: LoginUser, bob: LoginUser, carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
// Follow @carol@a.test ==> @alice@a.test
|
||||
await carol.client.request('following/create', { userId: alice.id });
|
||||
|
||||
// Move @alice@a.test ==> @bob@b.test
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check from follower', async () => {
|
||||
const following = await carol.client.request('users/following', { userId: carol.id });
|
||||
strictEqual(following.length, 2);
|
||||
const followees = following.map(({ followee }) => followee);
|
||||
assert(followees.every(followee => followee != null));
|
||||
assert(followees.some(({ id, url }) => id === alice.id && url === null));
|
||||
assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`));
|
||||
});
|
||||
|
||||
test('Check from followee', async () => {
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 1);
|
||||
const follower = followers[0].follower;
|
||||
assert(follower != null);
|
||||
strictEqual(follower.url, `https://a.test/@${carol.username}`);
|
||||
});
|
||||
});
|
||||
});
|
317
packages/backend/test-federation/test/note.test.ts
Normal file
317
packages/backend/test-federation/test/note.test.ts
Normal file
|
@ -0,0 +1,317 @@
|
|||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
describe('Note', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Note content', () => {
|
||||
test('Consistency of Public Note', async () => {
|
||||
const image = await uploadFile('a.test', alice);
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'I am Alice!',
|
||||
fileIds: [image.id],
|
||||
poll: {
|
||||
choices: ['neko', 'inu'],
|
||||
multiple: false,
|
||||
expiredAfter: 60 * 60 * 1000,
|
||||
},
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
/** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */
|
||||
'fileIds',
|
||||
'files',
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12409 */
|
||||
'reactionAcceptance',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
|
||||
test('Consistency of reply', async () => {
|
||||
const _replyedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
replyId: _replyedNote.id,
|
||||
})).createdNote;
|
||||
// NOTE: the repliedCount is incremented, so fetch again
|
||||
const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id });
|
||||
strictEqual(replyedNote.repliesCount, 1);
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'replyId',
|
||||
'reply',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.replyId != null);
|
||||
assert(resolvedNote.reply != null);
|
||||
deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [
|
||||
'id',
|
||||
// TODO: why clippedCount loses consistency?
|
||||
'clippedCount',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
// flaky because this is parallelly incremented, so let's check it below
|
||||
'repliesCount',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
|
||||
await sleep();
|
||||
|
||||
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
|
||||
strictEqual(resolvedReplyedNote.repliesCount, 1);
|
||||
});
|
||||
|
||||
test('Consistency of Renote', async () => {
|
||||
// NOTE: the renoteCount is not incremented, so no need to fetch again
|
||||
const renotedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
renoteId: renotedNote.id,
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'renoteId',
|
||||
'renote',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.renoteId != null);
|
||||
assert(resolvedNote.renote != null);
|
||||
deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other props', () => {
|
||||
test('localOnly', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
rejects(
|
||||
async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
|
||||
(err: any) => {
|
||||
/**
|
||||
* FIXME: this error is not handled
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12736
|
||||
*/
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Delete is derivered to followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user\'s note for moderation', () => {
|
||||
let note: Misskey.entities.Note;
|
||||
|
||||
test('Alice post is deleted in B', async () => {
|
||||
note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const bMod = await createModerator('b.test');
|
||||
await bMod.client.request('notes/delete', { noteId: noteInB.id });
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: implement soft deletion as well as user?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/11437
|
||||
*/
|
||||
test.failing('Not found even if resolve again', async () => {
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reaction', () => {
|
||||
describe('Consistency', () => {
|
||||
test('Unicode reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, reaction);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
|
||||
test('Custom emoji reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Acceptance', () => {
|
||||
test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, '❤');
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: this may be unexpected behavior?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12409
|
||||
*/
|
||||
test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test', { isSensitive: true });
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Poll', () => {
|
||||
describe('Any remote user\'s vote is delivered to the author', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
});
|
||||
|
||||
test('Bob creates poll and receives a vote from Carol', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local user\'s vote is delivered to the author\'s remote followers', () => {
|
||||
let bobRemoteFollower: LoginUser, localVoter: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[
|
||||
bobRemoteFollower,
|
||||
localVoter,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
// NOTE: resolve before voting
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
|
||||
await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
107
packages/backend/test-federation/test/notification.test.ts
Normal file
107
packages/backend/test-federation/test/notification.test.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Notification', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow', () => {
|
||||
test('Get notification when follow', async () => {
|
||||
await assertNotificationReceived(
|
||||
'b.test', bob,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id,
|
||||
true,
|
||||
);
|
||||
|
||||
await bob.client.request('following/delete', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Get notification when get followed', async () => {
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'follow' && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
|
||||
});
|
||||
|
||||
describe('Note', () => {
|
||||
test('Get notification when get a reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }),
|
||||
notification =>
|
||||
notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when replied', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when renoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when quoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when mentioned', async () => {
|
||||
const text = `@${alice.username}@a.test`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
|
@ -0,0 +1,328 @@
|
|||
import { strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Timeline', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
|
||||
type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
|
||||
const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
|
||||
['antenna', 'antennas/notes'],
|
||||
['globalTimeline', 'notes/global-timeline'],
|
||||
['homeTimeline', 'notes/timeline'],
|
||||
['hybridTimeline', 'notes/hybrid-timeline'],
|
||||
['localTimeline', 'notes/local-timeline'],
|
||||
['roleTimeline', 'roles/notes'],
|
||||
['hashtag', 'notes/search-by-tag'],
|
||||
['userList', 'notes/user-list-timeline'],
|
||||
]);
|
||||
|
||||
async function postAndCheckReception<C extends TimelineChannel>(
|
||||
timelineChannel: C,
|
||||
expect: boolean,
|
||||
noteParams: Misskey.entities.NotesCreateRequest = {},
|
||||
channelParams: Misskey.Channels[C]['params'] = {},
|
||||
) {
|
||||
let note: Misskey.entities.Note | undefined;
|
||||
const text = noteParams.text ?? crypto.randomUUID();
|
||||
const streamingFired = await isFired(
|
||||
'b.test', bob, timelineChannel,
|
||||
async () => {
|
||||
note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote;
|
||||
},
|
||||
'note', msg => msg.text === text,
|
||||
channelParams,
|
||||
);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpoint = timelineMap.get(timelineChannel)!;
|
||||
const params: Misskey.Endpoints[typeof endpoint]['req'] =
|
||||
endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } :
|
||||
endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } :
|
||||
endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } :
|
||||
endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
|
||||
{};
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
|
||||
const endpointFired = noteInB != null;
|
||||
strictEqual(endpointFired, expect);
|
||||
|
||||
// Let's check Delete reception
|
||||
if (expect) {
|
||||
const streamingFired = await isNoteUpdatedEventFired(
|
||||
'b.test', bob, noteInB!.id,
|
||||
async () => await alice.client.request('notes/delete', { noteId: note!.id }),
|
||||
msg => msg.type === 'deleted' && msg.id === noteInB!.id,
|
||||
);
|
||||
strictEqual(streamingFired, true);
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
|
||||
strictEqual(endpointFired, true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('homeTimeline', () => {
|
||||
// NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste
|
||||
const homeTimeline = 'homeTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s localOnly Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { localOnly: true });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s invisible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: can receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14083
|
||||
*/
|
||||
test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: cannot receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14084
|
||||
*/
|
||||
test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote;
|
||||
await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localTimeline', () => {
|
||||
const localTimeline = 'localTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Don\'t receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(localTimeline, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybridTimeline', () => {
|
||||
const hybridTimeline = 'hybridTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('globalTimeline', () => {
|
||||
const globalTimeline = 'globalTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, true);
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('userList', () => {
|
||||
const userList = 'userList';
|
||||
|
||||
let list: Misskey.entities.UserList;
|
||||
|
||||
beforeAll(async () => {
|
||||
list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' });
|
||||
await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(userList, true, {}, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashtag', () => {
|
||||
const hashtag = 'hashtag';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleTimeline', () => {
|
||||
const roleTimeline = 'roleTimeline';
|
||||
|
||||
let role: Misskey.entities.Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
role = await createRole('b.test', {
|
||||
name: 'Remote Users',
|
||||
description: 'Remote users are assigned to this role.',
|
||||
condFormula: {
|
||||
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
|
||||
type: 'isRemote' as never,
|
||||
},
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bAdmin.client.request('admin/roles/delete', { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Cannot test
|
||||
describe.skip('antenna', () => {
|
||||
const antenna = 'antenna';
|
||||
|
||||
let bobAntenna: Misskey.entities.Antenna;
|
||||
|
||||
beforeAll(async () => {
|
||||
bobAntenna = await bob.client.request('antennas/create', {
|
||||
name: 'Bob\'s Egosurfing Antenna',
|
||||
src: 'all',
|
||||
keywords: [['Bob']],
|
||||
excludeKeywords: [],
|
||||
users: [],
|
||||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
withReplies: true,
|
||||
withFile: true,
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bob.client.request('antennas/delete', { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
});
|
560
packages/backend/test-federation/test/user.test.ts
Normal file
560
packages/backend/test-federation/test/user.test.ts
Normal file
|
@ -0,0 +1,560 @@
|
|||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
const [aAdmin, bAdmin] = await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
fetchAdmin('b.test'),
|
||||
]);
|
||||
|
||||
describe('User', () => {
|
||||
describe('Profile', () => {
|
||||
describe('Consistency of profile', () => {
|
||||
let alice: LoginUser;
|
||||
let aliceWatcher: LoginUser;
|
||||
let aliceWatcherInB: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
alice = await createAccount('a.test');
|
||||
[
|
||||
aliceWatcher,
|
||||
aliceWatcherInB,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Check consistency', async () => {
|
||||
const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id });
|
||||
const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB);
|
||||
const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id });
|
||||
|
||||
// console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [
|
||||
'id',
|
||||
'host',
|
||||
'avatarUrl',
|
||||
'instance',
|
||||
'badgeRoles',
|
||||
'url',
|
||||
'uri',
|
||||
'createdAt',
|
||||
'lastFetchedAt',
|
||||
'publicReactions',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffVisibility is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
// NOTE: follow each other
|
||||
await Promise.all([
|
||||
alice.client.request('following/create', { userId: bobInA.id }),
|
||||
bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
]);
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Visibility set public by default', async () => {
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'public');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
/** FIXME: not working */
|
||||
test.skip('Setting private for followersVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followersVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followersVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
test.skip('Setting private for followingVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followingVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followingVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'private');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCat is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Not isCat for default', () => {
|
||||
strictEqual(aliceInB.isCat, false);
|
||||
});
|
||||
|
||||
test('Becoming a cat is sent to their followers', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('i/update', { isCat: true });
|
||||
await sleep();
|
||||
|
||||
const res = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(res.isCat, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pinning Notes', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
});
|
||||
|
||||
test('Pinning localOnly Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
test('Pinning followers-only Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
let pinnedNote: Misskey.entities.Note;
|
||||
|
||||
test('Pinning normal Note is delivered', async () => {
|
||||
pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
|
||||
const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
|
||||
strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
|
||||
});
|
||||
|
||||
test('Unpinning normal Note is delivered', async () => {
|
||||
await alice.client.request('i/unpin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow / Unfollow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
true,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unfollow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/delete', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
false,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow requests', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await alice.client.request('i/update', { isLocked: true });
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and cancel', () => {
|
||||
describe('Bob sends follow request to Alice', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have a request', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 1);
|
||||
strictEqual(requests[0].followee.id, alice.id);
|
||||
strictEqual(requests[0].follower.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alice cancels it', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have no requests', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and reject', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/reject', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob should have no requests', async () => {
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Bob doesn\'t follow Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and accept', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/accept', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob follows Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
strictEqual(following[0].followeeId, aliceInB.id);
|
||||
strictEqual(following[0].followerId, bob.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice deleted themself', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await alice.client.request('i/delete-account', { password: alice.password });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user for moderation', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, then Alice gets deleted in B server', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
/**
|
||||
* FIXME: remote account is not deleted!
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14728
|
||||
*/
|
||||
const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
assert(deletedAlice.id, aliceInB.id);
|
||||
|
||||
// TODO: why still following relation?
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'ALREADY_FOLLOWING');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice tries to follow Bob, but it is not processed', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const following = await alice.client.request('users/following', { userId: alice.id });
|
||||
strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept
|
||||
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 0); // Alice's Follow is not processed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suspension', () => {
|
||||
describe('Check suspend/unsuspend consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
|
||||
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // FIXME: followers are not deleted??
|
||||
|
||||
/**
|
||||
* FIXME: still rejected!
|
||||
* seems to can't process Undo Delete activity because it is not implemented
|
||||
* related @see https://github.com/misskey-dev/misskey/issues/13273
|
||||
*/
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// FIXME: resolving also fails
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* instead of simple unsuspension, let's tell existence by following from Alice
|
||||
*/
|
||||
test('Alice can follow Bob', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(bobFollowers.length, 1); // followed by Alice
|
||||
assert(bobFollowers[0].follower != null);
|
||||
const renewedaliceInB = bobFollowers[0].follower;
|
||||
assert(aliceInB.username === renewedaliceInB.username);
|
||||
assert(aliceInB.host === renewedaliceInB.host);
|
||||
assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // following are deleted
|
||||
|
||||
// Bob tries to follow Alice
|
||||
await bob.client.request('following/create', { userId: renewedaliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(aliceFollowers.length, 1);
|
||||
|
||||
// FIXME: but resolving still fails ...
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
307
packages/backend/test-federation/test/utils.ts
Normal file
307
packages/backend/test-federation/test/utils.ts
Normal file
|
@ -0,0 +1,307 @@
|
|||
import { deepStrictEqual, strictEqual } from 'assert';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export const ADMIN_PARAMS = { username: 'admin', password: 'admin' };
|
||||
const ADMIN_CACHE = new Map<Host, SigninResponse>();
|
||||
|
||||
await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
fetchAdmin('b.test'),
|
||||
]);
|
||||
|
||||
type SigninResponse = Omit<Misskey.entities.SigninFlowResponse & { finished: true }, 'finished'>;
|
||||
|
||||
export type LoginUser = SigninResponse & {
|
||||
client: Misskey.api.APIClient;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** used for avoiding overload and some endpoints */
|
||||
export type Request = <
|
||||
E extends keyof Misskey.Endpoints,
|
||||
P extends Misskey.Endpoints[E]['req'],
|
||||
>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
) => Promise<Misskey.api.SwitchCaseResponseType<E, P>>;
|
||||
|
||||
type Host = 'a.test' | 'b.test';
|
||||
|
||||
export async function sleep(ms = 200): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function signin(
|
||||
host: Host,
|
||||
params: Misskey.entities.SigninFlowRequest,
|
||||
): Promise<SigninResponse> {
|
||||
// wait for a second to prevent hit rate limit
|
||||
await sleep(1000);
|
||||
|
||||
return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params)
|
||||
.then(res => {
|
||||
strictEqual(res.finished, true);
|
||||
if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res);
|
||||
return res;
|
||||
})
|
||||
.then(({ id, i }) => ({ id, i }))
|
||||
.catch(async err => {
|
||||
if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') {
|
||||
await sleep(Math.random() * 2000);
|
||||
return await signin(host, params);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse | undefined> {
|
||||
const client = new Misskey.api.APIClient({ origin: `https://${host}` });
|
||||
return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => {
|
||||
ADMIN_CACHE.set(host, {
|
||||
id: res.id,
|
||||
// @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this
|
||||
i: res.token,
|
||||
});
|
||||
return res as Misskey.entities.SignupResponse;
|
||||
}).then(async res => {
|
||||
await client.request('admin/roles/update-default-policies', {
|
||||
policies: {
|
||||
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
|
||||
rateLimitFactor: 0 as never,
|
||||
},
|
||||
}, res.token);
|
||||
return res;
|
||||
}).catch(err => {
|
||||
if (err.info.e.message === 'access denied') return undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAdmin(host: Host): Promise<LoginUser> {
|
||||
const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS)
|
||||
.catch(async err => {
|
||||
if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') {
|
||||
await createAdmin(host);
|
||||
return await signin(host, ADMIN_PARAMS);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
return {
|
||||
...admin,
|
||||
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }),
|
||||
...ADMIN_PARAMS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAccount(host: Host): Promise<LoginUser> {
|
||||
const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20);
|
||||
const password = crypto.randomUUID().replaceAll('-', '');
|
||||
const admin = await fetchAdmin(host);
|
||||
await admin.client.request('admin/accounts/create', { username, password });
|
||||
const signinRes = await signin(host, { username, password });
|
||||
|
||||
return {
|
||||
...signinRes,
|
||||
client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }),
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createModerator(host: Host): Promise<LoginUser> {
|
||||
const user = await createAccount(host);
|
||||
const role = await createRole(host, {
|
||||
name: 'Moderator',
|
||||
isModerator: true,
|
||||
});
|
||||
const admin = await fetchAdmin(host);
|
||||
await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
host: Host,
|
||||
params: Partial<Misskey.entities.AdminRolesCreateRequest> = {},
|
||||
): Promise<Misskey.entities.Role> {
|
||||
const admin = await fetchAdmin(host);
|
||||
return await admin.client.request('admin/roles/create', {
|
||||
name: 'Some role',
|
||||
description: 'Role for testing',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
target: 'conditional',
|
||||
condFormula: {},
|
||||
isPublic: true,
|
||||
isModerator: false,
|
||||
isAdministrator: false,
|
||||
isExplorable: true,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
displayOrder: 0,
|
||||
policies: {},
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveRemoteUser(
|
||||
host: Host,
|
||||
id: string,
|
||||
from: LoginUser,
|
||||
): Promise<Misskey.entities.UserDetailedNotMe> {
|
||||
const uri = `https://${host}/users/${id}`;
|
||||
return await from.client.request('ap/show', { uri })
|
||||
.then(res => {
|
||||
strictEqual(res.type, 'User');
|
||||
strictEqual(res.object.uri, uri);
|
||||
return res.object;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveRemoteNote(
|
||||
host: Host,
|
||||
id: string,
|
||||
from: LoginUser,
|
||||
): Promise<Misskey.entities.Note> {
|
||||
const uri = `https://${host}/notes/${id}`;
|
||||
return await from.client.request('ap/show', { uri })
|
||||
.then(res => {
|
||||
strictEqual(res.type, 'Note');
|
||||
strictEqual(res.object.uri, uri);
|
||||
return res.object;
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
path = '../../test/resources/192.jpg',
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
const filename = path.split('/').pop() ?? 'untitled';
|
||||
const blob = new Blob([await readFile(join(__dirname, path))]);
|
||||
|
||||
const body = new FormData();
|
||||
body.append('i', user.i);
|
||||
body.append('force', 'true');
|
||||
body.append('file', blob);
|
||||
body.append('name', filename);
|
||||
|
||||
return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body })
|
||||
.then(async res => await res.json());
|
||||
}
|
||||
|
||||
export async function addCustomEmoji(
|
||||
host: Host,
|
||||
param?: Partial<Misskey.entities.AdminEmojiAddRequest>,
|
||||
path?: string,
|
||||
): Promise<Misskey.entities.EmojiDetailed> {
|
||||
const admin = await fetchAdmin(host);
|
||||
const name = crypto.randomUUID().replaceAll('-', '');
|
||||
const file = await uploadFile(host, admin, path);
|
||||
return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param });
|
||||
}
|
||||
|
||||
export function deepStrictEqualWithExcludedFields<T>(actual: T, expected: T, excludedFields: (keyof T)[]) {
|
||||
const _actual = structuredClone(actual);
|
||||
const _expected = structuredClone(expected);
|
||||
for (const obj of [_actual, _expected]) {
|
||||
for (const field of excludedFields) {
|
||||
delete obj[field];
|
||||
}
|
||||
}
|
||||
deepStrictEqual(_actual, _expected);
|
||||
}
|
||||
|
||||
export async function isFired<C extends keyof Misskey.Channels, T extends keyof Misskey.Channels[C]['events']>(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
channel: C,
|
||||
trigger: () => Promise<unknown>,
|
||||
type: T,
|
||||
// @ts-expect-error TODO: why getting error here?
|
||||
cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
|
||||
params?: Misskey.Channels[C]['params'],
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
const connection = stream.useChannel(channel, params);
|
||||
connection.on(type as any, ((msg: any) => {
|
||||
if (cond(msg)) {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
}) as any);
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
await trigger().then(() => {
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
}).catch(err => {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export async function isNoteUpdatedEventFired(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
noteId: string,
|
||||
trigger: () => Promise<unknown>,
|
||||
cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
stream.send('s', { id: noteId });
|
||||
stream.on('noteUpdated', msg => {
|
||||
if (cond(msg)) {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
await trigger().then(() => {
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
}).catch(err => {
|
||||
stream.close();
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export async function assertNotificationReceived(
|
||||
receiverHost: Host,
|
||||
receiver: LoginUser,
|
||||
trigger: () => Promise<unknown>,
|
||||
cond: (notification: Misskey.entities.Notification) => boolean,
|
||||
expect: boolean,
|
||||
) {
|
||||
const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpointFired = await receiver.client.request('i/notifications', {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
.then(([notification]) => notification != null ? cond(notification) : false);
|
||||
strictEqual(endpointFired, expect);
|
||||
}
|
114
packages/backend/test-federation/tsconfig.json
Normal file
114
packages/backend/test-federation/tsconfig.json
Normal file
|
@ -0,0 +1,114 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "NodeNext", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./built", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"daemon.ts",
|
||||
"./test/**/*.ts"
|
||||
]
|
||||
}
|
|
@ -21,6 +21,7 @@ import { url } from '@@/js/config.js';
|
|||
import { parseEmbedParams } from '@@/js/embed-page.js';
|
||||
import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
|
||||
import { serverContext } from '@/server-context.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
import type { Theme } from '@/theme.js';
|
||||
|
||||
|
@ -127,6 +128,27 @@ window.onunhandledrejection = null;
|
|||
|
||||
removeSplash();
|
||||
|
||||
//#region Self-XSS 対策メッセージ
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.warning}`,
|
||||
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.title}`,
|
||||
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description1}`,
|
||||
'font-size: 16px; font-weight: 700;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description2}`,
|
||||
'font-size: 16px;',
|
||||
'font-size: 20px; font-weight: 700; color: #f00;',
|
||||
);
|
||||
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
|
||||
//#endregion
|
||||
|
||||
function removeSplash() {
|
||||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
|
|
|
@ -12,7 +12,7 @@ import '@/style.scss';
|
|||
import { mainBoot } from '@/boot/main-boot.js';
|
||||
import { subBoot } from '@/boot/sub-boot.js';
|
||||
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete'];
|
||||
|
||||
if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
|
||||
subBoot();
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { del, get, set } from '@/scripts/idb-proxy.js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
|
||||
|
@ -165,7 +165,18 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
|
|||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData: Partial<Account>) {
|
||||
export function updateAccount(accountData: Account) {
|
||||
if (!$i) return;
|
||||
for (const key of Object.keys($i)) {
|
||||
delete $i[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
miLocalStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function updateAccountPartial(accountData: Partial<Account>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
|
@ -224,26 +235,6 @@ export async function openAccountMenu(opts: {
|
|||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
|
||||
function showSigninDialog() {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
addAccount(res.id, res.i);
|
||||
success();
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function createAccount() {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
|
||||
done: (res: Misskey.entities.SignupResponse) => {
|
||||
addAccount(res.id, res.token);
|
||||
switchAccountWithToken(res.token);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
async function switchAccount(account: Misskey.entities.UserDetailed) {
|
||||
const storedAccounts = await getAccounts();
|
||||
const found = storedAccounts.find(x => x.id === account.id);
|
||||
|
@ -312,10 +303,22 @@ export async function openAccountMenu(opts: {
|
|||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => { showSigninDialog(); },
|
||||
action: () => {
|
||||
getAccountWithSigninDialog().then(res => {
|
||||
if (res != null) {
|
||||
success();
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => { createAccount(); },
|
||||
action: () => {
|
||||
getAccountWithSignupDialog().then(res => {
|
||||
if (res != null) {
|
||||
switchAccountWithToken(res.token);
|
||||
}
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
|
@ -336,6 +339,40 @@ export async function openAccountMenu(opts: {
|
|||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
await addAccount(res.id, res.i);
|
||||
resolve({ id: res.id, token: res.i });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
|
||||
done: async (res: Misskey.entities.SignupResponse) => {
|
||||
await addAccount(res.id, res.token);
|
||||
resolve({ id: res.id, token: res.token });
|
||||
},
|
||||
cancelled: () => {
|
||||
resolve(null);
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (_DEV_) {
|
||||
(window as any).$i = $i;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import directives from '@/directives/index.js';
|
|||
import components from '@/components/index.js';
|
||||
import { applyTheme } from '@/scripts/theme.js';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
|
||||
import { updateI18n } from '@/i18n.js';
|
||||
import { updateI18n, i18n } from '@/i18n.js';
|
||||
import { $i, refreshAccount, login } from '@/account.js';
|
||||
import { defaultStore, ColdDeviceStorage } from '@/store.js';
|
||||
import { fetchInstance, instance } from '@/instance.js';
|
||||
|
@ -269,6 +269,27 @@ export async function common(createVue: () => App<Element>) {
|
|||
|
||||
removeSplash();
|
||||
|
||||
//#region Self-XSS 対策メッセージ
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.warning}`,
|
||||
'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.title}`,
|
||||
'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description1}`,
|
||||
'font-size: 16px; font-weight: 700;',
|
||||
);
|
||||
console.log(
|
||||
`%c${i18n.ts._selfXssPrevention.description2}`,
|
||||
'font-size: 16px;',
|
||||
'font-size: 20px; font-weight: 700; color: #f00;',
|
||||
);
|
||||
console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' }));
|
||||
//#endregion
|
||||
|
||||
return {
|
||||
isClientUpdated,
|
||||
app,
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
*/
|
||||
|
||||
import { createApp, defineAsyncComponent, markRaw } from 'vue';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import { common } from './common.js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { alert, confirm, popup, post, toast } from '@/os.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import * as sound from '@/scripts/sound.js';
|
||||
import { $i, signout, updateAccount } from '@/account.js';
|
||||
import { $i, signout, updateAccountPartial } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
|
@ -231,11 +231,41 @@ export async function mainBoot() {
|
|||
}
|
||||
|
||||
if (!claimedAchievements.includes('justPlainLucky')) {
|
||||
window.setInterval(() => {
|
||||
let justPlainLuckyTimer: number | null = null;
|
||||
let lastVisibilityChangedAt = Date.now();
|
||||
|
||||
function claimPlainLucky() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.floor(Math.random() * 20000) === 0) {
|
||||
claimAchievement('justPlainLucky');
|
||||
} else {
|
||||
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
|
||||
}
|
||||
}, 1000 * 10);
|
||||
}
|
||||
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
const now = Date.now();
|
||||
|
||||
if (document.visibilityState === 'visible') {
|
||||
// タブを高速で切り替えたら取得処理が何度も走るのを防ぐ
|
||||
if ((now - lastVisibilityChangedAt) < 1000 * 10) {
|
||||
justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
|
||||
} else {
|
||||
claimPlainLucky();
|
||||
}
|
||||
} else if (justPlainLuckyTimer != null) {
|
||||
window.clearTimeout(justPlainLuckyTimer);
|
||||
justPlainLuckyTimer = null;
|
||||
}
|
||||
|
||||
lastVisibilityChangedAt = now;
|
||||
}, { passive: true });
|
||||
|
||||
claimPlainLucky();
|
||||
}
|
||||
|
||||
if (!claimedAchievements.includes('client30min')) {
|
||||
|
@ -291,11 +321,11 @@ export async function mainBoot() {
|
|||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateAccount(i);
|
||||
updateAccountPartial(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
hasUnreadNotification: false,
|
||||
unreadNotificationsCount: 0,
|
||||
});
|
||||
|
@ -303,39 +333,39 @@ export async function mainBoot() {
|
|||
|
||||
main.on('unreadNotification', () => {
|
||||
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
hasUnreadNotification: true,
|
||||
unreadNotificationsCount,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadMention', () => {
|
||||
updateAccount({ hasUnreadMentions: true });
|
||||
updateAccountPartial({ hasUnreadMentions: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadMentions', () => {
|
||||
updateAccount({ hasUnreadMentions: false });
|
||||
updateAccountPartial({ hasUnreadMentions: false });
|
||||
});
|
||||
|
||||
main.on('unreadSpecifiedNote', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: true });
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: true });
|
||||
});
|
||||
|
||||
main.on('readAllUnreadSpecifiedNotes', () => {
|
||||
updateAccount({ hasUnreadSpecifiedNotes: false });
|
||||
updateAccountPartial({ hasUnreadSpecifiedNotes: false });
|
||||
});
|
||||
|
||||
main.on('readAllAntennas', () => {
|
||||
updateAccount({ hasUnreadAntenna: false });
|
||||
updateAccountPartial({ hasUnreadAntenna: false });
|
||||
});
|
||||
|
||||
main.on('unreadAntenna', () => {
|
||||
updateAccount({ hasUnreadAntenna: true });
|
||||
updateAccountPartial({ hasUnreadAntenna: true });
|
||||
sound.playMisskeySfx('antenna');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateAccount({ hasUnreadAnnouncement: false });
|
||||
updateAccountPartial({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
|
|
|
@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.root" class="_gaps_s">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder :withSpacer="false">
|
||||
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
|
||||
<template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template>
|
||||
|
@ -151,6 +151,4 @@ function showMenu(ev: MouseEvent) {
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -29,7 +29,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i, updateAccount } from '@/account.js';
|
||||
import { $i, updateAccountPartial } from '@/account.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
announcement: Misskey.entities.Announcement;
|
||||
|
@ -51,7 +51,7 @@ async function ok() {
|
|||
|
||||
modal.value?.close();
|
||||
misskeyApi('i/read-announcement', { announcementId: props.announcement.id });
|
||||
updateAccount({
|
||||
updateAccountPartial({
|
||||
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkAuthConfirm from './MkAuthConfirm.vue';
|
||||
void MkAuthConfirm;
|
450
packages/frontend/src/components/MkAuthConfirm.vue
Normal file
450
packages/frontend/src/components/MkAuthConfirm.vue
Normal file
|
@ -0,0 +1,450 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
:enterActiveClass="$style.transition_enterActive"
|
||||
:leaveActiveClass="$style.transition_leaveActive"
|
||||
:enterFromClass="$style.transition_enterFrom"
|
||||
:leaveToClass="$style.transition_leaveTo"
|
||||
|
||||
:inert="_waiting"
|
||||
>
|
||||
<div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps">
|
||||
<div :class="$style.header" class="_gaps_s">
|
||||
<div :class="$style.iconFallback">
|
||||
<i class="ti ti-user"></i>
|
||||
</div>
|
||||
<div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div>
|
||||
<div :class="$style.accountSelectorList">
|
||||
<template v-for="[id, user] in users">
|
||||
<input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/>
|
||||
<label :for="'account-' + id" :class="$style.accountSelectorItem">
|
||||
<MkAvatar :user="user" :class="$style.accountSelectorAvatar"/>
|
||||
<div :class="$style.accountSelectorBody">
|
||||
<MkUserName :user="user" :class="$style.accountSelectorName"/>
|
||||
<MkAcct :user="user" :class="$style.accountSelectorAcct"/>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
<button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount">
|
||||
<div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]">
|
||||
<i class="ti ti-user-plus"></i>
|
||||
</div>
|
||||
<div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps">
|
||||
<div :class="$style.header" class="_gaps_s">
|
||||
<img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/>
|
||||
<div v-else :class="$style.iconFallback">
|
||||
<i class="ti ti-apps"></i>
|
||||
</div>
|
||||
<div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div>
|
||||
</div>
|
||||
<div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot">
|
||||
<div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div>
|
||||
<div :class="$style.permissionListWrapper">
|
||||
<ul :class="$style.permissionList">
|
||||
<li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="consentAdditionalInfo"></slot>
|
||||
<div>
|
||||
<div :class="$style.accountSelectorLabel">
|
||||
{{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button>
|
||||
</div>
|
||||
<div :class="$style.accountSelectorList">
|
||||
<div :class="[$style.accountSelectorItem, $style.static]">
|
||||
<MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/>
|
||||
<div :class="$style.accountSelectorBody">
|
||||
<MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/>
|
||||
<MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton>
|
||||
<MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s">
|
||||
<div :class="$style.header" class="_gaps_s">
|
||||
<div :class="$style.iconFallback">
|
||||
<i class="ti ti-check"></i>
|
||||
</div>
|
||||
<div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div>
|
||||
<div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s">
|
||||
<div :class="$style.header" class="_gaps_s">
|
||||
<div :class="$style.iconFallback">
|
||||
<i class="ti ti-x"></i>
|
||||
</div>
|
||||
<div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s">
|
||||
<div :class="$style.header" class="_gaps_s">
|
||||
<div :class="$style.iconFallback">
|
||||
<i class="ti ti-x"></i>
|
||||
</div>
|
||||
<div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-if="_waiting" :class="$style.waitingRoot">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string;
|
||||
icon?: string;
|
||||
permissions?: (typeof Misskey.permissions[number])[];
|
||||
manualWaiting?: boolean;
|
||||
waitOnDeny?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'accept', token: string): void;
|
||||
(ev: 'deny', token: string): void;
|
||||
}>();
|
||||
|
||||
const waiting = ref(true);
|
||||
const _waiting = computed(() => waiting.value || props.manualWaiting);
|
||||
const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect');
|
||||
|
||||
const selectedUser = ref<string | null>(null);
|
||||
|
||||
const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>());
|
||||
|
||||
async function init() {
|
||||
waiting.value = true;
|
||||
|
||||
users.value.clear();
|
||||
|
||||
if ($i) {
|
||||
users.value.set($i.id, $i);
|
||||
}
|
||||
|
||||
const accounts = await getAccounts();
|
||||
|
||||
const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
|
||||
|
||||
if (accountIdsToFetch.length > 0) {
|
||||
const usersRes = await misskeyApi('users/show', {
|
||||
userIds: accountIdsToFetch,
|
||||
});
|
||||
|
||||
for (const user of usersRes) {
|
||||
if (users.value.has(user.id)) continue;
|
||||
|
||||
users.value.set(user.id, {
|
||||
...user,
|
||||
token: accounts.find(a => a.id === user.id)!.token,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
waiting.value = false;
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
function clickAddAccount(ev: MouseEvent) {
|
||||
selectedUser.value = null;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => {
|
||||
getAccountWithSigninDialog().then(async (res) => {
|
||||
if (res != null) {
|
||||
os.success();
|
||||
await init();
|
||||
if (users.value.has(res.id)) {
|
||||
selectedUser.value = res.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => {
|
||||
getAccountWithSignupDialog().then(async (res) => {
|
||||
if (res != null) {
|
||||
os.success();
|
||||
await init();
|
||||
if (users.value.has(res.id)) {
|
||||
selectedUser.value = res.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function clickChooseAccount() {
|
||||
if (selectedUser.value === null) return;
|
||||
|
||||
phase.value = 'consent';
|
||||
}
|
||||
|
||||
function clickBackToAccountSelect() {
|
||||
selectedUser.value = null;
|
||||
phase.value = 'accountSelect';
|
||||
}
|
||||
|
||||
function clickCancel() {
|
||||
if (selectedUser.value === null) return;
|
||||
|
||||
const user = users.value.get(selectedUser.value)!;
|
||||
|
||||
const token = user.token;
|
||||
|
||||
if (props.waitOnDeny) {
|
||||
waiting.value = true;
|
||||
}
|
||||
emit('deny', token);
|
||||
}
|
||||
|
||||
async function clickAccept() {
|
||||
if (selectedUser.value === null) return;
|
||||
|
||||
const user = users.value.get(selectedUser.value)!;
|
||||
|
||||
const token = user.token;
|
||||
|
||||
waiting.value = true;
|
||||
emit('accept', token);
|
||||
}
|
||||
|
||||
function showUI(state: 'success' | 'denied' | 'failed') {
|
||||
phase.value = state;
|
||||
waiting.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showUI,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_enterActive,
|
||||
.transition_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||
}
|
||||
.transition_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
.transition_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-x: hidden;
|
||||
overflow-x: clip;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.waitingRoot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: color-mix(in srgb, var(--MI_THEME-panel), transparent 50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0 auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.icon,
|
||||
.iconFallback {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
background-color: #fff;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.iconFallback {
|
||||
border-radius: 50%;
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
text-align: center;
|
||||
line-height: 54px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.headerText,
|
||||
.headerTextSub {
|
||||
text-align: center;
|
||||
word-break: normal;
|
||||
word-break: auto-phrase;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.permissionRoot {
|
||||
padding: 16px;
|
||||
border-radius: var(--MI-radius);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
}
|
||||
|
||||
.permissionListWrapper {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: var(--MI-radius);
|
||||
background-color: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.permissionList {
|
||||
margin: 0 0 0 1.5em;
|
||||
padding: 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.accountSelectorLabel {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.accountSelectorList {
|
||||
border-radius: var(--MI-radius);
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.accountSelectorRadio {
|
||||
position: absolute;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible + .accountSelectorItem {
|
||||
outline: 2px solid var(--MI_THEME-accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&:checked:focus-visible + .accountSelectorItem {
|
||||
outline-color: #fff;
|
||||
}
|
||||
|
||||
&:checked + .accountSelectorItem {
|
||||
background: var(--MI_THEME-accent);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.accountSelectorItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--MI_THEME-buttonHoverBg);
|
||||
}
|
||||
|
||||
&.static {
|
||||
cursor: unset;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accountSelectorAddAccountRoot {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accountSelectorBody {
|
||||
padding: 0 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accountSelectorAvatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.accountSelectorAddAccountAvatar {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
font-size: 16px;
|
||||
line-height: 45px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.accountSelectorName {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.accountSelectorAcct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
|
@ -117,8 +117,8 @@ async function requestRender() {
|
|||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? 'dark' : 'light',
|
||||
callback: callback,
|
||||
'expired-callback': callback,
|
||||
'error-callback': callback,
|
||||
'expired-callback': () => callback(undefined),
|
||||
'error-callback': () => callback(undefined),
|
||||
});
|
||||
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
|
||||
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
|
||||
|
|
|
@ -64,26 +64,30 @@ const showBody = ref(props.expanded);
|
|||
const ignoreOmit = ref(false);
|
||||
const omitted = ref(false);
|
||||
|
||||
function enter(el) {
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
|
||||
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
|
||||
}
|
||||
|
||||
function afterEnter(el) {
|
||||
el.style.height = null;
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el) {
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el) {
|
||||
el.style.height = null;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
const calcOmit = () => {
|
||||
|
|
|
@ -128,14 +128,14 @@ export default defineComponent({
|
|||
return children;
|
||||
};
|
||||
|
||||
function onBeforeLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function onBeforeLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = `${el.offsetTop}px`;
|
||||
el.style.left = `${el.offsetLeft}px`;
|
||||
}
|
||||
|
||||
function onLeaveCancelled(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function onLeaveCancelled(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
}
|
||||
|
|
|
@ -157,7 +157,12 @@ const ilFilesObserver = new IntersectionObserver(
|
|||
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
|
||||
);
|
||||
|
||||
const sortModeSelect = ref('+createdAt');
|
||||
|
||||
watch(folder, () => emit('cd', folder.value));
|
||||
watch(sortModeSelect, () => {
|
||||
fetch();
|
||||
});
|
||||
|
||||
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
|
@ -558,6 +563,7 @@ async function fetch() {
|
|||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
limit: filesMax + 1,
|
||||
sort: sortModeSelect.value,
|
||||
}).then(fetchedFiles => {
|
||||
if (fetchedFiles.length === filesMax + 1) {
|
||||
moreFiles.value = true;
|
||||
|
@ -607,6 +613,7 @@ function fetchMoreFiles() {
|
|||
type: props.type,
|
||||
untilId: files.value.at(-1)?.id,
|
||||
limit: max + 1,
|
||||
sort: sortModeSelect.value,
|
||||
}).then(files => {
|
||||
if (files.length === max + 1) {
|
||||
moreFiles.value = true;
|
||||
|
@ -642,6 +649,43 @@ function getMenu() {
|
|||
type: 'label',
|
||||
});
|
||||
|
||||
menu.push({
|
||||
type: 'parent',
|
||||
text: i18n.ts.sort,
|
||||
icon: 'ti ti-arrows-sort',
|
||||
children: [{
|
||||
text: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+createdAt'; },
|
||||
active: sortModeSelect.value === '+createdAt',
|
||||
}, {
|
||||
text: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-createdAt'; },
|
||||
active: sortModeSelect.value === '-createdAt',
|
||||
}, {
|
||||
text: `${i18n.ts.size} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+size'; },
|
||||
active: sortModeSelect.value === '+size',
|
||||
}, {
|
||||
text: `${i18n.ts.size} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-size'; },
|
||||
active: sortModeSelect.value === '-size',
|
||||
}, {
|
||||
text: `${i18n.ts.name} (${i18n.ts.descendingOrder})`,
|
||||
icon: 'ti ti-sort-descending-letters',
|
||||
action: () => { sortModeSelect.value = '+name'; },
|
||||
active: sortModeSelect.value === '+name',
|
||||
}, {
|
||||
text: `${i18n.ts.name} (${i18n.ts.ascendingOrder})`,
|
||||
icon: 'ti ti-sort-ascending-letters',
|
||||
action: () => { sortModeSelect.value = '-name'; },
|
||||
active: sortModeSelect.value === '-name',
|
||||
}],
|
||||
});
|
||||
|
||||
if (folder.value) {
|
||||
menu.push({
|
||||
text: i18n.ts.renameFolder,
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody">
|
||||
<header :class="$style.header" class="_button" @click="showBody = !showBody">
|
||||
<div :class="$style.title"><div><slot name="header"></slot></div></div>
|
||||
<div :class="$style.divider"></div>
|
||||
<button class="_button" :class="$style.button">
|
||||
|
@ -32,21 +32,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
const miLocalStoragePrefix = 'ui:folder:' as const;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
expanded?: boolean;
|
||||
persistKey?: string;
|
||||
persistKey?: string | null;
|
||||
}>(), {
|
||||
expanded: true,
|
||||
persistKey: null,
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLDivElement>();
|
||||
const bg = ref<string>();
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const parentBg = ref<string | null>(null);
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
|
||||
|
||||
watch(showBody, () => {
|
||||
|
@ -55,47 +57,34 @@ watch(showBody, () => {
|
|||
}
|
||||
});
|
||||
|
||||
function enter(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
}
|
||||
|
||||
function afterEnter(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
el.style.height = 'unset';
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
el.style.height = 'unset';
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function getParentBg(el?: HTMLElement | null): string {
|
||||
if (el == null || el.tagName === 'BODY') return 'var(--MI_THEME-bg)';
|
||||
const background = el.style.background || el.style.backgroundColor;
|
||||
if (background) {
|
||||
return background;
|
||||
} else {
|
||||
return getParentBg(el.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
const rawBg = getParentBg(rootEl.value);
|
||||
const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
_bg.setAlpha(0.85);
|
||||
bg.value = _bg.toRgbString();
|
||||
parentBg.value = getBgColor(rootEl.value?.parentElement);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -121,6 +110,7 @@ onMounted(() => {
|
|||
top: var(--MI-stickyTop, 0px);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
|
||||
backdrop-filter: var(--MI-blur, blur(20px));
|
||||
background-color: color(from v-bind("parentBg ?? 'var(--bg)'") srgb r g b / 0.85);
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
|
@ -56,8 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, shallowRef, ref } from 'vue';
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
|
@ -69,40 +70,35 @@ const props = withDefaults(defineProps<{
|
|||
withSpacer: true,
|
||||
});
|
||||
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
|
||||
function enter(el) {
|
||||
function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
|
||||
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
|
||||
}
|
||||
|
||||
function afterEnter(el) {
|
||||
el.style.height = null;
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el) {
|
||||
function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el) {
|
||||
el.style.height = null;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
|
@ -117,7 +113,7 @@ function toggle() {
|
|||
|
||||
onMounted(() => {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const parentBg = getBgColor(rootEl.value!.parentElement!);
|
||||
const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent';
|
||||
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
|
||||
bgSame.value = parentBg === myBg;
|
||||
});
|
||||
|
|
|
@ -37,13 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
@ -80,7 +80,7 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
|
|||
}
|
||||
|
||||
async function onClick() {
|
||||
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
|
||||
pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
|
||||
|
||||
wait.value = true;
|
||||
|
||||
|
|
|
@ -12,13 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
|
||||
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA v-else v-click-anime :to="item.to" class="item" @click.passive="close()">
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span>
|
||||
<span v-else-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span>
|
||||
</MkA>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -139,7 +139,6 @@ function close() {
|
|||
left: 32px;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 8px;
|
||||
animation: global-blink 1s infinite;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
top: 16px;
|
||||
|
|
|
@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</MkA>
|
||||
<a
|
||||
|
@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
|
@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||
<div v-if="item.indicate" :class="$style.item_content">
|
||||
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
|
@ -161,7 +161,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -639,7 +639,6 @@ onBeforeUnmount(() => {
|
|||
align-items: center;
|
||||
color: var(--MI_THEME-indicator);
|
||||
font-size: 12px;
|
||||
animation: global-blink 1s infinite;
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
|
|
@ -26,11 +26,11 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
|
|||
import MkModal from './MkModal.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withOkButton: boolean;
|
||||
withCloseButton: boolean;
|
||||
okButtonDisabled: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
withOkButton?: boolean;
|
||||
withCloseButton?: boolean;
|
||||
okButtonDisabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>(), {
|
||||
withOkButton: false,
|
||||
withCloseButton: true,
|
||||
|
|
|
@ -227,6 +227,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(false));
|
||||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
|
@ -299,7 +300,7 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
|
|||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && !defaultStore.state.tl.filter.withSensitive && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
|
||||
if (inTimeline && !tl_withSensitive.value && noteToCheck.files?.some((v) => v.isSensitive)) return 'sensitiveMute';
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -419,7 +420,7 @@ if (!props.mock) {
|
|||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
|
||||
|
@ -429,7 +430,7 @@ function renote(viaKeyboard = false) {
|
|||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
@ -442,7 +443,7 @@ function reply(): void {
|
|||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
@ -563,7 +564,7 @@ function showRenoteMenu(): void {
|
|||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
|
|
|
@ -207,6 +207,7 @@ import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
|
|||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
|
@ -230,7 +231,6 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
|
|||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
@ -404,7 +404,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
|||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
|
||||
|
@ -412,7 +412,7 @@ function renote() {
|
|||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
|
@ -423,7 +423,7 @@ function reply(): void {
|
|||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
@ -499,7 +499,7 @@ async function clip(): Promise<void> {
|
|||
|
||||
function showRenoteMenu(): void {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
|
|
|
@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { sum } from '@/scripts/array.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
|
@ -85,7 +85,7 @@ if (props.poll.expiresAt) {
|
|||
const vote = async (id) => {
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
|
|
|
@ -65,10 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
|
||||
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
|
||||
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
|
||||
<div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
|
||||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
|
@ -201,6 +201,7 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'
|
|||
const imeText = ref('');
|
||||
const showingOptions = ref(false);
|
||||
const textAreaReadOnly = ref(false);
|
||||
const justEndedComposition = ref(false);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
|
@ -573,7 +574,13 @@ function clear() {
|
|||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
|
||||
|
||||
if (ev.key === 'Escape') emit('esc');
|
||||
// justEndedComposition.value is for Safari, which keyDown occurs after compositionend.
|
||||
// ev.isComposing is for another browsers.
|
||||
if (ev.key === 'Escape' && !justEndedComposition.value && !ev.isComposing) emit('esc');
|
||||
}
|
||||
|
||||
function onKeyup(ev: KeyboardEvent) {
|
||||
justEndedComposition.value = false;
|
||||
}
|
||||
|
||||
function onCompositionUpdate(ev: CompositionEvent) {
|
||||
|
@ -582,6 +589,7 @@ function onCompositionUpdate(ev: CompositionEvent) {
|
|||
|
||||
function onCompositionEnd(ev: CompositionEvent) {
|
||||
imeText.value = '';
|
||||
justEndedComposition.value = true;
|
||||
}
|
||||
|
||||
async function onPaste(ev: ClipboardEvent) {
|
||||
|
|
|
@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@keydown.space.enter="show"
|
||||
>
|
||||
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
|
||||
<select
|
||||
<div
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
v-adaptive-border
|
||||
tabindex="-1"
|
||||
:class="$style.inputCore"
|
||||
|
@ -26,55 +25,48 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@mousedown.prevent="() => {}"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<slot></slot>
|
||||
</select>
|
||||
<div style="pointer-events: none;">{{ currentValueText ?? '' }}</div>
|
||||
<div style="display: none;">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
|
||||
</div>
|
||||
<div :class="$style.caption"><slot name="caption"></slot></div>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null;
|
||||
modelValue: string | number | null;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
inline?: boolean;
|
||||
manualSave?: boolean;
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'changeByUser', value: string | null): void;
|
||||
(ev: 'update:modelValue', value: string | null): void;
|
||||
(ev: 'update:modelValue', value: string | number | null): void;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const { modelValue, autofocus } = toRefs(props);
|
||||
const v = ref(modelValue.value);
|
||||
const focused = ref(false);
|
||||
const opening = ref(false);
|
||||
const changed = ref(false);
|
||||
const invalid = ref(false);
|
||||
const filled = computed(() => v.value !== '' && v.value != null);
|
||||
const currentValueText = ref<string | null>(null);
|
||||
const inputEl = ref<HTMLObjectElement | null>(null);
|
||||
const prefixEl = ref<HTMLElement | null>(null);
|
||||
const suffixEl = ref<HTMLElement | null>(null);
|
||||
|
@ -85,26 +77,6 @@ const height =
|
|||
36;
|
||||
|
||||
const focus = () => container.value?.focus();
|
||||
const onInput = (ev) => {
|
||||
changed.value = true;
|
||||
};
|
||||
|
||||
const updated = () => {
|
||||
changed.value = false;
|
||||
emit('update:modelValue', v.value);
|
||||
};
|
||||
|
||||
watch(modelValue, newValue => {
|
||||
v.value = newValue;
|
||||
});
|
||||
|
||||
watch(v, () => {
|
||||
if (!props.manualSave) {
|
||||
updated();
|
||||
}
|
||||
|
||||
invalid.value = inputEl.value?.validity.badInput ?? true;
|
||||
});
|
||||
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
|
@ -134,6 +106,31 @@ onMounted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
watch(modelValue, () => {
|
||||
const scanOptions = (options: VNodeChild[]) => {
|
||||
for (const vnode of options) {
|
||||
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
|
||||
if (vnode.type === 'optgroup') {
|
||||
const optgroup = vnode;
|
||||
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
|
||||
} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
|
||||
const fragment = vnode;
|
||||
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
|
||||
} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
|
||||
// nop?
|
||||
} else {
|
||||
const option = vnode;
|
||||
if (option.props?.value === modelValue.value) {
|
||||
currentValueText.value = option.children as string;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scanOptions(slots.default!());
|
||||
}, { immediate: true });
|
||||
|
||||
function show() {
|
||||
if (opening.value) return;
|
||||
focus();
|
||||
|
@ -146,11 +143,9 @@ function show() {
|
|||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children as string,
|
||||
active: computed(() => v.value === option.props?.value),
|
||||
active: computed(() => modelValue.value === option.props?.value),
|
||||
action: () => {
|
||||
v.value = option.props?.value;
|
||||
changed.value = true;
|
||||
emit('changeByUser', v.value);
|
||||
emit('update:modelValue', option.props?.value);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -248,7 +243,8 @@ function show() {
|
|||
.inputCore {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: v-bind("height + 'px'");
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
|
|
@ -24,11 +24,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkInput>
|
||||
|
||||
<div v-if="needCaptcha">
|
||||
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
||||
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
|
||||
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
||||
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha"/>
|
||||
</div>
|
||||
|
||||
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
|
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="dialog"
|
||||
:width="500"
|
||||
:height="600"
|
||||
@close="dialog?.close()"
|
||||
@close="onClose"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.signup }}</template>
|
||||
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<template v-if="!isAcceptedServerRule">
|
||||
<XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/>
|
||||
<XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
|
||||
|
@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', res: Misskey.entities.SignupResponse): void;
|
||||
(ev: 'cancelled'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
|
@ -55,6 +56,11 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
|||
|
||||
const isAcceptedServerRule = ref(false);
|
||||
|
||||
function onClose() {
|
||||
emit('cancelled');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onSignup(res: Misskey.entities.SignupResponse) {
|
||||
emit('done', res);
|
||||
dialog.value?.close();
|
||||
|
|
|
@ -38,6 +38,7 @@ const props = withDefaults(defineProps<{
|
|||
sound?: boolean;
|
||||
withRenotes?: boolean;
|
||||
withReplies?: boolean;
|
||||
withSensitive?: boolean;
|
||||
onlyFiles?: boolean;
|
||||
}>(), {
|
||||
withRenotes: true,
|
||||
|
@ -51,6 +52,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
provide('inTimeline', true);
|
||||
provide('tl_withSensitive', computed(() => props.withSensitive));
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
type TimelineQueryType = {
|
||||
|
@ -248,6 +250,9 @@ function refreshEndpointAndChannel() {
|
|||
// IDが切り替わったら切り替え先のTLを表示させたい
|
||||
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
|
||||
|
||||
// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
|
||||
watch(() => props.withSensitive, reloadTimeline);
|
||||
|
||||
// 初回表示用
|
||||
refreshEndpointAndChannel();
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="chosen && !shouldHide" :class="$style.root">
|
||||
<div v-if="chosen && !shouldHide">
|
||||
<div
|
||||
v-if="!showMenu"
|
||||
:class="[$style.main, {
|
||||
|
@ -120,10 +120,6 @@ function reduceFrequency(): void {
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.main {
|
||||
text-align: center;
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ export type Tab = {
|
|||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
@ -120,14 +120,14 @@ function onTabWheel(ev: WheelEvent) {
|
|||
|
||||
let entering = false;
|
||||
|
||||
async function enter(element: Element) {
|
||||
async function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
entering = true;
|
||||
const el = element as HTMLElement;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
el.offsetWidth; // force reflow
|
||||
el.style.width = elementWidth + 'px';
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
nextTick(() => {
|
||||
entering = false;
|
||||
|
@ -136,22 +136,23 @@ async function enter(element: Element) {
|
|||
setTimeout(renderTab, 170);
|
||||
}
|
||||
|
||||
function afterEnter(element: Element) {
|
||||
//el.style.width = '';
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
// element.style.width = '';
|
||||
}
|
||||
|
||||
async function leave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
async function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = elementWidth + 'px';
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
el.offsetWidth; // force reflow
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
}
|
||||
|
||||
function afterLeave(element: Element) {
|
||||
const el = element as HTMLElement;
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.width = '';
|
||||
}
|
||||
|
||||
|
|
|
@ -4,19 +4,11 @@
|
|||
*/
|
||||
|
||||
import { Directive } from 'vue';
|
||||
import { getBgColor } from '@/scripts/get-bg-color.js';
|
||||
|
||||
export default {
|
||||
mounted(src, binding, vn) {
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
|
||||
return style.backgroundColor;
|
||||
} else {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
const parentBg = getBgColor(src.parentElement) ?? 'transparent';
|
||||
|
||||
const myBg = window.getComputedStyle(src).backgroundColor;
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue