Compare commits

...

43 commits

Author SHA1 Message Date
github-actions[bot]
9ea7340da6 Bump version to 2025.8.0-alpha.12 2025-08-15 13:44:57 +00:00
anatawa12
60f7278aff
fix: Remote Note Cleaning will delete notes embedded in a page (#16408)
* feat: preserve number of pages referencing the note

* chore: delete pages on account delete

* fix: notes on the pages are removed by CleanRemoteNotes

* test: add the simplest test for page embedded notes

* fix: section block is not considered

* fix: section block is not considered in migration

* chore: remove comments from columns

* revert unnecessary change

* add pageCount to webhook test

* fix type error on backend
2025-08-15 22:39:55 +09:00
syuilo
bae92a944d enhance(frontend): improve enableInfiniteScroll stability
Close #16318
2025-08-15 12:40:37 +09:00
syuilo
7d30768769 fix(frontend): Botプロテクションの設定の変更検知が正しくない問題を修正 2025-08-15 12:10:14 +09:00
github-actions[bot]
e444942c4e Bump version to 2025.8.0-alpha.11 2025-08-14 08:02:14 +00:00
饺子w (Yumechi)
90b9609341
enhance: performance for CleanRemoteNotesProcessorService (#16404)
* enhance: performance for CleanRemoteNotesProcessorService

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* suggestions

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* docs

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* change initial limit to 100

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* robustness for transient race conditions

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* handle cursors in postgres

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* robustness: transient errors and timeout handling

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

* use '0' as initial cursor

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>

---------

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2025-08-14 16:54:28 +09:00
syuilo
c25a922928 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2025-08-14 16:50:59 +09:00
syuilo
d26169ea32 Update about-misskey.vue 2025-08-14 16:50:56 +09:00
github-actions[bot]
8839d8d679 Bump version to 2025.8.0-alpha.10 2025-08-13 02:01:57 +00:00
syuilo
ad6af74eef
New Crowdin updates (#16394)
* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Chinese Simplified)
2025-08-13 10:59:40 +09:00
Sayamame-beans
7bb43329bb
fix(frontend): メンション補完のためのサジェストが正しく表示されない問題を修正 (#16401)
* fix(frontend): mention-syntax detection for autocomplete doesn't work properly

* docs(changelog): update changelog
2025-08-13 10:51:23 +09:00
syuilo
4c41930554
Update CHANGELOG.md 2025-08-11 15:54:18 +09:00
syuilo
295f42b986
New Crowdin updates (#16386)
* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Russian)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Japanese, Kansai)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Romanian)

* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Arabic)

* New translations ja-jp.yml (Czech)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Norwegian)

* New translations ja-jp.yml (Polish)

* New translations ja-jp.yml (Portuguese)

* New translations ja-jp.yml (Slovak)

* New translations ja-jp.yml (Swedish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Ukrainian)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Vietnamese)

* New translations ja-jp.yml (Indonesian)

* New translations ja-jp.yml (Bengali)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Uzbek)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Italian)
2025-08-11 12:25:59 +09:00
syuilo
299f9e3115 fix test 2025-08-11 12:09:25 +09:00
syuilo
1d8e183883 fix test 2025-08-11 12:01:32 +09:00
zyoshoka
f242892382
fix(workflow): correct references to built image's ID (#16391) 2025-08-10 23:54:06 +09:00
syuilo
ecc033f101 fix(backend): fix type errors caused by dependency update
https: //github.com/misskey-dev/misskey/pull/16308
Co-Authored-By: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-08-09 17:41:01 +09:00
renovate[bot]
684dbfd626
fix(deps): update [backend] update dependencies (#16201)
* fix(deps): update [backend] update dependencies

* Update HttpRequestService.ts

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-08-09 17:00:59 +09:00
github-actions[bot]
aa5c42997f Bump version to 2025.8.0-alpha.9 2025-08-09 07:45:10 +00:00
github-actions[bot]
e7b666f567 Bump version to 2025.8.0-alpha.8 2025-08-09 06:04:17 +00:00
renovate[bot]
0f7c0ed053
chore(deps): update [misskey-js] update dependencies (#16346)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 14:48:02 +09:00
syuilo
1e92bb4a0a chore(backend): remove unused codes 2025-08-09 14:44:36 +09:00
かっこかり
b5b7914073
enhance: ユーザー検索を制限できるように (#16380)
* enhance: ユーザー検索を制限できるように

* Update Changelog
2025-08-09 14:41:11 +09:00
syuilo
7595bff43b fix(backend): prevent run repeatable job immediately
Fix #16357
2025-08-09 14:37:09 +09:00
github-actions[bot]
72864fcbd0 Bump version to 2025.8.0-alpha.7 2025-08-09 05:27:57 +00:00
syuilo
1b0de39f92
Update CHANGELOG.md 2025-08-09 14:27:48 +09:00
renovate[bot]
d8a137cb6c
chore(deps): update [tools] update dependencies (#16348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 14:26:52 +09:00
かっこかり
ddac2fb7a1
fix(frontend-builder): 言語バンドルの作成状況がログに残るように (#16385) 2025-08-09 14:26:03 +09:00
かっこかり
b1b335d55a
fix: チャットの利用可否ポリシーの考慮漏れを修正 (#16259)
* fix: チャットの利用可否ポリシーの考慮漏れを修正

* 🎨
2025-08-09 14:25:31 +09:00
かっこかり
0586dd98cb
fix(deps): regenerate lockfile (#16384) 2025-08-09 14:20:18 +09:00
syuilo
504f886065
Update CHANGELOG.md 2025-08-09 14:14:16 +09:00
Sayamame-beans
2931eb0aad
Fix: チャンネルのハイライトページにノートが表示されない問題を修正 (#16364)
* fix(frontend): unable to see channel's featured notes

* docs(changelog): update changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-08-09 14:13:37 +09:00
anatawa12
103d5a4b44
fix frontend tests broken with aiscript 1.0.0 (#16377)
* test: update test for aiscript 1.0: line info in error

* test: update test for aiscript 1.0: keyword in object literal
2025-08-09 14:12:17 +09:00
かっこかり
785b85ee46
enhance(frontend): 画像エフェクトのUI改善 (#16191)
* enhance(frontend): 画像エフェクトの改善

* enhance: i18n colorClampAdvanced

* fix: missing translation

* enhance: i18n blockNoise

* fix lint

* fix: narrow down fx defs types

* fix

* fix: watermark用エフェクトは別で定義し直す

* fix lint

* ImageEffectorをwatermarkに隠蔽

* watermark関連の定義を完全に分離

* refactor

* fix

* ぼかし効果 -> スムージング

* refactor: remove unnecessary `as const`

* Update Changelog
2025-08-09 14:11:19 +09:00
anatawa12
8bd84a0ec4
fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題 (#15987)
* fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題

* changelog Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正

* reduce requests to retrieve timeline name
2025-08-09 10:43:07 +09:00
renovate[bot]
9539995458
fix(deps): update [root] update dependencies (#16349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 10:34:12 +09:00
syuilo
e67ff36e57 enhance(frontend): AiScriptのcode-splittingが正しく行われるように 2025-08-08 21:50:05 +09:00
github-actions[bot]
96a165d729 Bump version to 2025.8.0-alpha.6 2025-08-08 12:36:36 +00:00
Sayamame-beans
215725a3ac
Fix: SystemWebhook設定でsecretを空に出来ない問題を修正 (#16371)
* fix(backend): allow system-webhook secret to be empty

* docs(changelog): update changelog

* chore: run build-misskey-js-with-types
2025-08-08 21:35:02 +09:00
syuilo
3da04fcae4
New Crowdin updates (#16360)
* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Turkish)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Spanish)
2025-08-08 21:31:59 +09:00
tamaina
85e3e49688
fix(backend): Fix and create unit test of CleanRemoteNotesProcessorService (#16368)
* wip

* test(backend): CleanRemoteNotesProcessorService (basic)

* test(backend): CleanRemoteNotesProcessorService (advanced)

* ✌️

* a

* split initiator query

* no order by

* ???

* old → older
2025-08-08 21:31:31 +09:00
syuilo
076a83466e 🎨 2025-08-08 21:26:53 +09:00
syuilo
aaf3f343ea Update about-misskey.vue 2025-08-08 21:20:23 +09:00
116 changed files with 4581 additions and 3271 deletions

View file

@ -25,7 +25,7 @@ jobs:
cp ./compose_example.yml ./compose.yml
- run: |
docker compose up -d web
docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest
docker tag "$(docker compose images --format json web | jq -r '.[] | .ID')" misskey-web:latest
- run: |
cmd="dockle --exit-code 1 misskey-web:latest ${image_name}"
echo "> ${cmd}"

View file

@ -13,10 +13,13 @@
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
- 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました
- 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました
- mfm.jsをアップデートしました
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
- Enhance: acctに `.` が入っているユーザーのメンションに対応
- Fix: Unicode絵文字に隣接する異体字セレクタ`U+FE0F`)が絵文字として認識される問題を修正
- Enhance: ユーザー検索をロールポリシーで制限できるように
### Client
- Feat: AiScriptが1.0に更新されました
@ -30,17 +33,26 @@
- URLに`?safemode=true`を付ける
- PWAのショートカットで Safemode を選択して起動する
- Feat: ページのタブバーを下部に表示できるように
- Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました
- Enhance: コントロールパネルを検索できるように
- Enhance: トルコ語 (tr-TR) に対応
- Enhance: 言語別のスクリプトバンドルを生成するように
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
- Enhance: 画像エフェクトのパラメータ名の多言語対応
- Enhance: 依存ソフトウェアの更新
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
- Fix: テーマエディタが動作しない問題を修正
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
- Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正
- Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正
### Server
- Enhance: ノートの削除処理の効率化
- Enhance: 全体的なパフォーマンスの向上
- Enhance: 依存ソフトウェアの更新
- Fix: SystemWebhook設定でsecretを空に出来ない問題を修正
## 2025.7.0

View file

@ -1599,3 +1599,9 @@ _watermarkEditor:
type: "نوع"
image: "صور"
advanced: "متقدم"
_imageEffector:
_fxProps:
scale: "الحجم"
size: "الحجم"
color: "اللون"
opacity: "الشفافية"

View file

@ -1357,3 +1357,10 @@ _watermarkEditor:
text: "লেখা"
image: "ছবি"
advanced: "উন্নত"
_imageEffector:
_fxProps:
scale: "আকার"
size: "আকার"
color: "রং"
opacity: "অস্বচ্ছতা"
lightness: "উজ্জ্বল করুন"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Fent servir espais crearà expressions AND si l'ex
hiddenTags: "Etiquetes ocultes"
hiddenTagsDescription: "La visibilitat de totes les notes que continguin qualsevol de les paraules configurades seran, automàticament, afegides a \"Inici\". Pots llistar diferents paraules separant les per línies noves."
notesSearchNotAvailable: "La cerca de notes no es troba disponible."
usersSearchNotAvailable: "La cerca d'usuaris no està disponible."
license: "Llicència"
unfavoriteConfirm: "Esborrar dels favorits?"
myClips: "Els meus retalls"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
showUrlPreview: "Mostrar vista prèvia d'URL"
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
showPageTabBarBottom: "Mostrar les pestanyes de les línies de temps a la part inferior"
_chat:
showSenderName: "Mostrar el nom del remitent"
sendOnEnter: "Introdueix per enviar"
@ -1998,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius."
canHideAds: "Pot amagar la publicitat"
canSearchNotes: "Pot cercar notes"
canSearchUsers: "Pot cercar usuaris"
canUseTranslator: "Pot fer servir el traductor"
avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars"
canImportAntennas: "Autoritza la importació d'antenes "
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "Tipus"
image: "Imatges"
advanced: "Avançat"
angle: "Angle"
stripe: "Bandes"
stripeWidth: "Amplada de la banda"
stripeFrequency: "Freqüència de la banda"
angle: "Angle"
polkadot: "Lunars"
checker: "Escacs"
polkadotMainDotOpacity: "Opacitat del lunar principal"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "Efecte"
addEffect: "Afegeix un efecte"
discardChangesConfirm: "Vols descartar els canvis i sortir?"
nothingToConfigure: "No hi ha opcions de configuració disponibles"
_fxs:
chromaticAberration: "Aberració cromàtica"
glitch: "Glitch"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "Escacs"
blockNoise: "Bloqueig de soroll"
tearing: "Trencament d'imatge "
_fxProps:
angle: "Angle"
scale: "Mida"
size: "Mida"
color: "Color"
opacity: "Opacitat"
normalize: "Normalitzar"
amount: "Quantitat"
lightness: "Brillantor"
contrast: "Contrast"
hue: "Tonalitat"
brightness: "Brillantor"
saturation: "Saturació"
max: "Màxim"
min: "Mínim"
direction: "Direcció "
phase: "Fase"
frequency: "Freqüència "
strength: "Intensitat"
glitchChannelShift: "Canvi de canal "
seed: "Llindar"
redComponent: "Component vermell"
greenComponent: "Component verd"
blueComponent: "Component blau"
threshold: "Llindar"
centerX: "Centre de X"
centerY: "Centre de Y"
zoomLinesSmoothing: "Suavitzat"
zoomLinesSmoothingDescription: "Els paràmetres de suavitzat i amplada de línia en augmentar no es poden fer servir junts."
zoomLinesThreshold: "Amplada de línia a l'augmentar "
zoomLinesMaskSize: "Diàmetre del centre"
zoomLinesBlack: "Obscurir"
drafts: "Esborrany "
_drafts:
select: "Seleccionar esborrany"

View file

@ -2053,3 +2053,10 @@ _watermarkEditor:
type: "Typ"
image: "Obrázky"
advanced: "Pokročilé"
_imageEffector:
_fxProps:
scale: "Velikost"
size: "Velikost"
color: "Barva"
opacity: "Průhlednost"
lightness: "Zesvětlit"

View file

@ -3147,10 +3147,10 @@ _watermarkEditor:
type: "Art"
image: "Bilder"
advanced: "Fortgeschritten"
angle: "Winkel"
stripe: "Streifen"
stripeWidth: "Linienbreite"
stripeFrequency: "Linienanzahl"
angle: "Winkel"
polkadot: "Punktmuster"
polkadotMainDotOpacity: "Deckkraft des Hauptpunktes"
polkadotMainDotRadius: "Größe des Hauptpunktes"
@ -3173,6 +3173,13 @@ _imageEffector:
distort: "Verzerrung"
stripe: "Streifen"
polkadot: "Punktmuster"
_fxProps:
angle: "Winkel"
scale: "Größe"
size: "Größe"
color: "Farbe"
opacity: "Transparenz"
lightness: "Erhellen"
drafts: "Entwurf"
_drafts:
select: "Entwurf auswählen"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Using spaces will create AND expressions and surro
hiddenTags: "Hidden hashtags"
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
notesSearchNotAvailable: "Note search is unavailable."
usersSearchNotAvailable: "User search is not available."
license: "License"
unfavoriteConfirm: "Really remove from favorites?"
myClips: "My clips"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "When real-time mode is on, content is updated in real time regardless of this setting."
showUrlPreview: "Show URL preview"
showAvailableReactionsFirstInNote: "Show available reactions at the top."
showPageTabBarBottom: "Show page tab bar at the bottom"
_chat:
showSenderName: "Show sender's name"
sendOnEnter: "Press Enter to send"
@ -1998,19 +2000,20 @@ _role:
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
canHideAds: "Can hide ads"
canSearchNotes: "Usage of note search"
canSearchUsers: "User search"
canUseTranslator: "Translator usage"
avatarDecorationLimit: "Maximum number of avatar decorations that can be applied"
canImportAntennas: "Allow importing antennas"
canImportBlocking: "Allow importing blocking"
canImportFollowing: "Allow importing following"
canImportMuting: "Allow importing muting"
canImportUserLists: "Allow importing lists"
chatAvailability: "Allow Chat"
avatarDecorationLimit: "Maximum number of avatar decorations"
canImportAntennas: "Can import antennas"
canImportBlocking: "Can import blocking"
canImportFollowing: "Can import following"
canImportMuting: "Can import muting"
canImportUserLists: "Can import lists"
chatAvailability: "Chat"
uploadableFileTypes: "Uploadable file types"
uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)"
uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification."
noteDraftLimit: "Number of possible drafts of server notes"
watermarkAvailable: "Availability of watermark function"
watermarkAvailable: "Watermark function"
_condition:
roleAssignedTo: "Assigned to manual roles"
isLocal: "Local user"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "Type"
image: "Images"
advanced: "Advanced"
angle: "Angle"
stripe: "Stripes"
stripeWidth: "Line width"
stripeFrequency: "Lines count"
angle: "Angle"
polkadot: "Polkadot"
checker: "Checker"
polkadotMainDotOpacity: "Opacity of the main dot"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "Effects"
addEffect: "Add Effects"
discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes."
nothingToConfigure: "No configurable options available"
_fxs:
chromaticAberration: "Chromatic Aberration"
glitch: "Glitch"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "Checker"
blockNoise: "Block Noise"
tearing: "Tearing"
_fxProps:
angle: "Angle"
scale: "Size"
size: "Size"
color: "Color"
opacity: "Opacity"
normalize: "Normalize"
amount: "Amount"
lightness: "Lighten"
contrast: "Contrast"
hue: "Hue"
brightness: "Brightness"
saturation: "Saturation"
max: "Maximum"
min: "Minimum"
direction: "Direction"
phase: "Phase"
frequency: "Frequency"
strength: "Strength"
glitchChannelShift: "Channel shift"
seed: "Seed value"
redComponent: "Red component"
greenComponent: "Green component"
blueComponent: "Blue component"
threshold: "Threshold"
centerX: "Center X"
centerY: "Center Y"
zoomLinesSmoothing: "Smoothing"
zoomLinesSmoothingDescription: "Smoothing and zoom line width cannot be used together."
zoomLinesThreshold: "Zoom line width"
zoomLinesMaskSize: "Center diameter"
zoomLinesBlack: "Make black"
drafts: "Drafts"
_drafts:
select: "Select Draft"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Si se usan espacios se crearán expresiones AND y
hiddenTags: "Hashtags ocultos"
hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea."
notesSearchNotAvailable: "No se puede buscar una nota"
usersSearchNotAvailable: "La búsqueda de usuarios no está disponible."
license: "Licencia"
unfavoriteConfirm: "¿Desea quitar de favoritos?"
myClips: "Mis clips"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
showUrlPreview: "Mostrar la vista previa de la URL"
showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior."
showPageTabBarBottom: "Mostrar la barra de pestañas de la página en la parte inferior."
_chat:
showSenderName: "Mostrar el nombre del remitente"
sendOnEnter: "Intro para enviar"
@ -1998,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
canHideAds: "Puede ocultar anuncios"
canSearchNotes: "Uso de la búsqueda de notas"
canSearchUsers: "Uso de la búsqueda de usuarios"
canUseTranslator: "Uso de traductor"
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
canImportAntennas: "Permitir la importación de antenas"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "Tipo"
image: "Imágenes"
advanced: "Avanzado"
angle: "Ángulo"
stripe: "Rayas"
stripeWidth: "Anchura de línea"
stripeFrequency: "Número de líneas."
angle: "Ángulo"
polkadot: "Lunares"
checker: "verificador"
polkadotMainDotOpacity: "Opacidad del círculo principal"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "Efecto"
addEffect: "Añadir Efecto"
discardChangesConfirm: "¿Ignorar cambios y salir?"
nothingToConfigure: "No hay opciones configurables disponibles."
_fxs:
chromaticAberration: "Aberración Cromática"
glitch: "Glitch"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "Corrector"
blockNoise: "Bloquear Ruido"
tearing: "Rasgado de Imagen (Tearing)"
_fxProps:
angle: "Ángulo"
scale: "Tamaño"
size: "Tamaño"
color: "Color"
opacity: "Opacidad"
normalize: "Normalización"
amount: "Cantidad"
lightness: "Brillo"
contrast: "Contraste"
hue: "Tonalidad"
brightness: "Brillo"
saturation: "Saturación"
max: "Valor máximo"
min: "Valor mínimo"
direction: "Dirección"
phase: "Fase"
frequency: "Frecuencia"
strength: "Intensidad"
glitchChannelShift: "cambio de canal de imagen"
seed: "Valor de la semilla"
redComponent: "Componente rojo"
greenComponent: "Componente Verde"
blueComponent: "Componente Azul"
threshold: "Umbral"
centerX: "Centrar X"
centerY: "Centrar Y"
zoomLinesSmoothing: "Suavizado"
zoomLinesSmoothingDescription: "El suavizado y el ancho de línea de zoom no se pueden utilizar juntos."
zoomLinesThreshold: "Ancho de línea del zoom"
zoomLinesMaskSize: "Diámetro del centro"
zoomLinesBlack: "Hacer oscuro"
drafts: "Borrador"
_drafts:
select: "Seleccionar borradores"

View file

@ -2372,3 +2372,11 @@ _watermarkEditor:
image: "Images"
advanced: "Avancé"
angle: "Angle"
_imageEffector:
_fxProps:
angle: "Angle"
scale: "Taille"
size: "Taille"
color: "Couleur"
opacity: "Transparence"
lightness: "Clair"

View file

@ -2627,3 +2627,11 @@ _watermarkEditor:
image: "Gambar"
advanced: "Tingkat lanjut"
angle: "Sudut"
_imageEffector:
_fxProps:
angle: "Sudut"
scale: "Ukuran"
size: "Ukuran"
color: "Warna"
opacity: "Opasitas"
lightness: "Menerangkan"

146
locales/index.d.ts vendored
View file

@ -4386,6 +4386,10 @@ export interface Locale extends ILocale {
*
*/
"notesSearchNotAvailable": string;
/**
*
*/
"usersSearchNotAvailable": string;
/**
*
*/
@ -7799,6 +7803,10 @@ export interface Locale extends ILocale {
*
*/
"canSearchNotes": string;
/**
*
*/
"canSearchUsers": string;
/**
*
*/
@ -12203,6 +12211,10 @@ export interface Locale extends ILocale {
*
*/
"advanced": string;
/**
*
*/
"angle": string;
/**
*
*/
@ -12215,10 +12227,6 @@ export interface Locale extends ILocale {
*
*/
"stripeFrequency": string;
/**
*
*/
"angle": string;
/**
*
*/
@ -12261,6 +12269,10 @@ export interface Locale extends ILocale {
*
*/
"discardChangesConfirm": string;
/**
*
*/
"nothingToConfigure": string;
"_fxs": {
/**
*
@ -12327,6 +12339,132 @@ export interface Locale extends ILocale {
*/
"tearing": string;
};
"_fxProps": {
/**
*
*/
"angle": string;
/**
*
*/
"scale": string;
/**
*
*/
"size": string;
/**
*
*/
"color": string;
/**
*
*/
"opacity": string;
/**
*
*/
"normalize": string;
/**
*
*/
"amount": string;
/**
*
*/
"lightness": string;
/**
*
*/
"contrast": string;
/**
*
*/
"hue": string;
/**
*
*/
"brightness": string;
/**
*
*/
"saturation": string;
/**
*
*/
"max": string;
/**
*
*/
"min": string;
/**
*
*/
"direction": string;
/**
*
*/
"phase": string;
/**
*
*/
"frequency": string;
/**
*
*/
"strength": string;
/**
*
*/
"glitchChannelShift": string;
/**
*
*/
"seed": string;
/**
*
*/
"redComponent": string;
/**
*
*/
"greenComponent": string;
/**
*
*/
"blueComponent": string;
/**
*
*/
"threshold": string;
/**
* X
*/
"centerX": string;
/**
* Y
*/
"centerY": string;
/**
*
*/
"zoomLinesSmoothing": string;
/**
*
*/
"zoomLinesSmoothingDescription": string;
/**
*
*/
"zoomLinesThreshold": string;
/**
*
*/
"zoomLinesMaskSize": string;
/**
*
*/
"zoomLinesBlack": string;
};
};
/**
*

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Gli spazi creano la relazione \"E\" tra parole (qu
hiddenTags: "Hashtag nascosti"
hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga."
notesSearchNotAvailable: "Non è possibile cercare tra le Note."
usersSearchNotAvailable: "La ricerca profili non è disponibile."
license: "Licenza"
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
myClips: "Le mie Clip"
@ -1198,7 +1199,7 @@ replies: "Risposte"
renotes: "Rinota"
loadReplies: "Leggi le risposte"
loadConversation: "Leggi la conversazione"
pinnedList: "Elenco in primo piano"
pinnedList: "Lista in primo piano"
keepScreenOn: "Mantenere lo schermo acceso"
verifiedLink: "Abbiamo confermato la validità di questo collegamento"
notifyNotes: "Notifica nuove Note"
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "Livello predefinito di compressione immagini"
defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine."
inMinutes: "min"
inDays: "giorni"
safeModeEnabled: "La modalità sicura è attiva"
pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la modalità sicura è attiva."
customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva."
themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente."
_order:
newest: "Prima i più recenti"
oldest: "Meno recenti prima"
@ -1461,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
showUrlPreview: "Mostra anteprima dell'URL"
showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto"
showPageTabBarBottom: "Visualizza le schede della pagina nella parte inferiore"
_chat:
showSenderName: "Mostra il nome del mittente"
sendOnEnter: "Invio spedisce"
@ -1634,6 +1640,10 @@ _serverSettings:
fanoutTimelineDbFallback: "Elaborazione dati alternativa"
fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline."
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
remoteNotesCleaning: "Pulizia automatica dei contenuti remoti"
remoteNotesCleaning_description: "Se abilitata, verranno periodicamente rimosse le vecchie Note remote senza relazioni, per ridurre il sovraccarico del sistema."
remoteNotesCleaningMaxProcessingDuration: "Durata massima del processo di pulizia"
remoteNotesCleaningExpiryDaysForEachNotes: "Periodo minimo di conservazione delle note"
inquiryUrl: "URL di contatto"
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
openRegistration: "Registrazioni aperte"
@ -1652,6 +1662,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "Visibilità dei contenuti generati dagli utenti ai non utenti"
userGeneratedContentsVisibilityForVisitor_description: "Questa funzionalità è utile per impedire che contenuti remoti inappropriati e difficili da moderare vengano inavvertitamente resi pubblici su Internet tramite il proprio server."
userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server."
restartServerSetupWizardConfirm_title: "Vuoi ripetere la procedura guidata di configurazione iniziale del server?"
restartServerSetupWizardConfirm_text: "Verranno ripristinate alcune tue impostazioni personalizzate."
_userGeneratedContentsVisibilityForVisitor:
all: "Tutto pubblico"
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
@ -1988,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
canHideAds: "Nascondere i banner"
canSearchNotes: "Ricercare nelle Note"
canSearchUsers: "Può cercare profili"
canUseTranslator: "Tradurre le Note"
avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili"
canImportAntennas: "Può importare Antenne"
@ -3062,6 +3075,7 @@ _bootErrors:
otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache"
otherOption2: "Avviare il client predefinito"
otherOption3: "Avviare lo strumento di riparazione"
otherOption4: "Avvia Misskey in modalità sicura"
_search:
searchScopeAll: "Tutte"
searchScopeLocal: "Locale"
@ -3098,6 +3112,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "Collegandosi a una rete di server distribuiti, denominata Fediverso, potrai scambiare contenuti con altri server, tramite il protocollo di comunicazione ActivityPub."
doYouConnectToFediverse_description2: "Connettersi al Fediverso è anche detto \"federazione\"."
youCanConfigureMoreFederationSettingsLater: "Puoi svolgere la configurazione avanzata anche dopo. Ad esempio specificando quali server possono federarsi."
remoteContentsCleaning: "Pulizia automatica dei contenuti in arrivo"
remoteContentsCleaning_description: "Con la federazione funzionante, riceverai sempre più contenuti. Abilitando la pulizia automatica, i contenuti non referenziati e obsoleti verranno rimossi automaticamente dai tuoi server, risparmiando spazio di archiviazione."
adminInfo: "Informazioni sull'amministratore"
adminInfo_description: "Imposta le informazioni dell'amministratore utilizzate per accettare le richieste."
adminInfo_mustBeFilled: "Questa operazione è necessaria su un server aperto o se è attiva la federazione."
@ -3150,10 +3166,10 @@ _watermarkEditor:
type: "Tipo"
image: "Immagini"
advanced: "Avanzato"
angle: "Angolo"
stripe: "Strisce"
stripeWidth: "Larghezza della linea"
stripeFrequency: "Il numero di linee"
angle: "Angolo"
polkadot: "A pallini"
checker: "revisore"
polkadotMainDotOpacity: "Opacità del punto principale"
@ -3165,6 +3181,7 @@ _imageEffector:
title: "Effetto"
addEffect: "Aggiungi effetto"
discardChangesConfirm: "Scarta le modifiche ed esci?"
nothingToConfigure: "Nessuna impostazione configurabile."
_fxs:
chromaticAberration: "Aberrazione cromatica"
glitch: "Glitch"
@ -3182,6 +3199,38 @@ _imageEffector:
checker: "revisore"
blockNoise: "Attenua rumore"
tearing: "Strappa immagine"
_fxProps:
angle: "Angolo"
scale: "Dimensioni"
size: "Dimensioni"
color: "Colore"
opacity: "Opacità"
normalize: "Normalizza"
amount: "Quantità"
lightness: "Chiaro"
contrast: "Contrasto"
hue: "Tinta"
brightness: "Luminosità"
saturation: "Saturazione"
max: "Valore massimo"
min: "Valore minimo"
direction: "Orientamento"
phase: "Fasare"
frequency: "Frequenza"
strength: "Forza"
glitchChannelShift: "Glitch cambio canale"
seed: "Seme"
redComponent: "Rosso composito"
greenComponent: "Verde composito"
blueComponent: "Blu composito"
threshold: "Soglia"
centerX: "Centro orizzontale"
centerY: "Centro verticale"
zoomLinesSmoothing: "Levigatura"
zoomLinesSmoothingDescription: "Non si possono usare insieme la levigatura e la larghezza della linea centrale."
zoomLinesThreshold: "Limite delle linee zoom"
zoomLinesMaskSize: "Ampiezza del diametro"
zoomLinesBlack: "Bande nere"
drafts: "Bozza"
_drafts:
select: "Selezionare bozza"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "スペースで区切るとAND指定になり、
hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。"
usersSearchNotAvailable: "ユーザー検索は利用できません。"
license: "ライセンス"
unfavoriteConfirm: "お気に入り解除しますか?"
myClips: "自分のクリップ"
@ -2020,6 +2021,7 @@ _role:
descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。"
canHideAds: "広告の非表示"
canSearchNotes: "ノート検索の利用"
canSearchUsers: "ユーザー検索の利用"
canUseTranslator: "翻訳機能の利用"
avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
canImportAntennas: "アンテナのインポートを許可"
@ -3267,10 +3269,10 @@ _watermarkEditor:
type: "タイプ"
image: "画像"
advanced: "高度"
angle: "角度"
stripe: "ストライプ"
stripeWidth: "ラインの幅"
stripeFrequency: "ラインの数"
angle: "角度"
polkadot: "ポルカドット"
checker: "チェッカー"
polkadotMainDotOpacity: "メインドットの不透明度"
@ -3283,6 +3285,7 @@ _imageEffector:
title: "エフェクト"
addEffect: "エフェクトを追加"
discardChangesConfirm: "変更を破棄して終了しますか?"
nothingToConfigure: "設定項目はありません"
_fxs:
chromaticAberration: "色収差"
@ -3302,6 +3305,39 @@ _imageEffector:
blockNoise: "ブロックノイズ"
tearing: "ティアリング"
_fxProps:
angle: "角度"
scale: "サイズ"
size: "サイズ"
color: "色"
opacity: "不透明度"
normalize: "正規化"
amount: "量"
lightness: "明るさ"
contrast: "コントラスト"
hue: "色相"
brightness: "輝度"
saturation: "彩度"
max: "最大値"
min: "最小値"
direction: "方向"
phase: "位相"
frequency: "頻度"
strength: "強さ"
glitchChannelShift: "ズレ"
seed: "シード値"
redComponent: "赤色成分"
greenComponent: "緑色成分"
blueComponent: "青色成分"
threshold: "しきい値"
centerX: "中心X"
centerY: "中心Y"
zoomLinesSmoothing: "スムージング"
zoomLinesSmoothingDescription: "スムージングと集中線の幅の設定は併用できません。"
zoomLinesThreshold: "集中線の幅"
zoomLinesMaskSize: "中心径"
zoomLinesBlack: "黒色にする"
drafts: "下書き"
_drafts:
select: "下書きを選択"

View file

@ -3020,6 +3020,13 @@ _watermarkEditor:
angle: "角度"
_imageEffector:
discardChangesConfirm: "変更をせんで終わるか?"
_fxProps:
angle: "角度"
scale: "大きさ"
size: "大きさ"
color: "色"
opacity: "不透明度"
lightness: "明るさ"
_drafts:
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
cannotCreateDraft: "この内容で下書きは作れへんな。"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며,
hiddenTags: "숨긴 해시태그"
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
usersSearchNotAvailable: "유저 검색을 이용하실 수 없습니다."
license: "라이선스"
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
myClips: "내 클립"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
showUrlPreview: "URL 미리보기 표시"
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
showPageTabBarBottom: "페이지의 탭 바를 아래쪽에 표시"
_chat:
showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기"
@ -1998,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
canHideAds: "광고 숨기기"
canSearchNotes: "노트 검색 이용 가능 여부"
canSearchUsers: "유저 검색 이용"
canUseTranslator: "번역 기능의 사용"
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
canImportAntennas: "안테나 가져오기 허용"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "종류"
image: "이미지"
advanced: "고급"
angle: "각도"
stripe: "줄무늬"
stripeWidth: "라인의 폭"
stripeFrequency: "라인의 수"
angle: "각도"
polkadot: "물방울 무늬"
checker: "체크 무늬"
polkadotMainDotOpacity: "주요 물방울의 불투명도"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "이펙트"
addEffect: "이펙트를 추가"
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
nothingToConfigure: "설정 항목이 없습니다."
_fxs:
chromaticAberration: "색수차"
glitch: "글리치"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "체크 무늬"
blockNoise: "노이즈 방지"
tearing: "티어링"
_fxProps:
angle: "각도"
scale: "크기"
size: "크기"
color: "색"
opacity: "불투명도"
normalize: "노멀라이즈"
amount: "양"
lightness: "밝음"
contrast: "대비"
hue: "색조"
brightness: "밝기"
saturation: "채도"
max: "최대 값"
min: "최소 값"
direction: "방향"
phase: "위상"
frequency: "빈도"
strength: "강도"
glitchChannelShift: "글리치"
seed: "시드 값"
redComponent: "빨간색 요소"
greenComponent: "녹색 요소"
blueComponent: "파란색 요소"
threshold: "한계 값"
centerX: "X축 중심"
centerY: "Y축 중심"
zoomLinesSmoothing: "다듬기"
zoomLinesSmoothingDescription: "다듬기와 집중선 폭 설정은 같이 쓸 수 없습니다."
zoomLinesThreshold: "집중선 폭"
zoomLinesMaskSize: "중앙 값"
zoomLinesBlack: "검은색으로 하기"
drafts: "초안"
_drafts:
select: "초안 선택"

View file

@ -742,3 +742,8 @@ _watermarkEditor:
text: "Tekst"
type: "Type"
image: "Bilder"
_imageEffector:
_fxProps:
scale: "Størrelse"
size: "Størrelse"
color: "Farge"

View file

@ -1593,3 +1593,10 @@ _watermarkEditor:
type: "Typ"
image: "Zdjęcia"
advanced: "Zaawansowane"
_imageEffector:
_fxProps:
scale: "Rozmiar"
size: "Rozmiar"
color: "Kolor"
opacity: "Przezroczystość"
lightness: "Rozjaśnij"

View file

@ -3150,10 +3150,10 @@ _watermarkEditor:
type: "Tipo"
image: "imagem"
advanced: "Avançado"
angle: "Ângulo"
stripe: "Listras"
stripeWidth: "Largura da linha"
stripeFrequency: "Número de linhas"
angle: "Ângulo"
polkadot: "Bolinhas"
checker: "Xadrez"
polkadotMainDotOpacity: "Opacidade da bolinha principal"
@ -3182,6 +3182,13 @@ _imageEffector:
checker: "Xadrez"
blockNoise: "Bloquear Ruído"
tearing: "Descontinuidade"
_fxProps:
angle: "Ângulo"
scale: "Tamanho"
size: "Tamanho"
color: "Cor"
opacity: "Opacidade"
lightness: "Esclarecer"
drafts: "Rascunhos"
_drafts:
select: "Selecionar Rascunho"

View file

@ -1400,3 +1400,7 @@ _watermarkEditor:
type: "Tip"
image: "Imagini"
advanced: "Avansat"
_imageEffector:
_fxProps:
scale: "Dimensiune"
size: "Dimensiune"

View file

@ -2257,4 +2257,12 @@ _watermarkEditor:
image: "Изображения"
advanced: "Для продвинутых"
angle: "Угол"
_imageEffector:
_fxProps:
angle: "Угол"
scale: "Размер"
size: "Размер"
color: "Цвет"
opacity: "Непрозрачность"
lightness: "Осветление"
drafts: "Черновик"

View file

@ -1459,3 +1459,10 @@ _watermarkEditor:
type: "Typ"
image: "Obrázky"
advanced: "Rozšírené"
_imageEffector:
_fxProps:
scale: "Veľkosť"
size: "Veľkosť"
color: "Farba"
opacity: "Priehľadnosť"
lightness: "Zosvetliť"

View file

@ -716,3 +716,8 @@ _search:
_watermarkEditor:
scale: "Storlek"
image: "Bilder"
_imageEffector:
_fxProps:
scale: "Storlek"
size: "Storlek"
color: "Färg"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "ถ้าแยกด้วยเว้นวร
hiddenTags: "แฮชแท็กที่ซ่อนอยู่"
hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่"
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน"
usersSearchNotAvailable: "การค้นหาผู้ใช้ไม่พร้อมใช้งาน"
license: "ใบอนุญาต"
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
myClips: "คลิปของฉัน"
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "ความละเอียดเริ่ม
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
inMinutes: "นาที"
inDays: "วัน"
safeModeEnabled: "โหมดปลอดภัยถูกเปิดใช้งาน"
pluginsAreDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน ปลั๊กอินทั้งหมดจึงถูกปิดใช้งาน"
customCssIsDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน CSS แบบกำหนดเองจึงไม่ได้ถูกนำมาใช้"
themeIsDefaultBecauseSafeMode: "ในระหว่างที่โหมดปลอดภัยถูกเปิดใช้งาน จะใช้ธีมเริ่มต้น เมื่อปิดโหมดปลอดภัยจะกลับคืนดังเดิม"
_order:
newest: "เรียงจากใหม่ไปเก่า"
oldest: "เรียงจากเก่าไปใหม่"
@ -1461,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
showUrlPreview: "แสดงตัวอย่าง URL"
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
showPageTabBarBottom: "แสดงแท็บบาร์ของเพจที่ด้านล่าง"
_chat:
showSenderName: "แสดงชื่อผู้ส่ง"
sendOnEnter: "กด Enter เพื่อส่ง"
@ -1994,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
canHideAds: "ซ่อนโฆษณา"
canSearchNotes: "การใช้การค้นหาโน้ต"
canSearchUsers: "ค้นหาผู้ใช้"
canUseTranslator: "การใช้งานแปล"
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
@ -3068,6 +3075,7 @@ _bootErrors:
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
otherOption3: "เปิดเครื่องมือซ่อมแซม"
otherOption4: "เริ่มทำงาน Misskey ในโหมดปลอดภัย"
_search:
searchScopeAll: "ทั้งหมด"
searchScopeLocal: "ท้องถิ่น"
@ -3158,10 +3166,10 @@ _watermarkEditor:
type: "รูปแบบ"
image: "รูปภาพ"
advanced: "ขั้นสูง"
angle: "แองเกิล"
stripe: "ริ้ว"
stripeWidth: "ความกว้างเส้น"
stripeFrequency: "จำนวนเส้น"
angle: "แองเกิล"
polkadot: "ลายจุด"
checker: "ช่องตาราง"
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
@ -3173,6 +3181,7 @@ _imageEffector:
title: "เอฟเฟกต์"
addEffect: "เพิ่มเอฟเฟกต์"
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า"
_fxs:
chromaticAberration: "ความคลาดสี"
glitch: "กลิตช์"
@ -3190,6 +3199,38 @@ _imageEffector:
checker: "ช่องตาราง"
blockNoise: "บล็อกที่มีการรบกวน"
tearing: "ฉีกขาด"
_fxProps:
angle: "แองเกิล"
scale: "ขนาด"
size: "ขนาด"
color: "สี"
opacity: "ความทึบแสง"
normalize: "นอร์มัลไลซ์"
amount: "จำนวน"
lightness: "สว่าง"
contrast: "คอนทราสต์"
hue: "HUE"
brightness: "ความสว่าง"
saturation: "ความอิ่มตัว"
max: "สูงสุด"
min: "ต่ำสุด"
direction: "ทิศทาง"
phase: "ระยะ"
frequency: "ความถี่"
strength: "ความแรง"
glitchChannelShift: "ความเคลื่อน"
seed: "ซีด"
redComponent: "ส่วนสีแดง"
greenComponent: "ส่วนสีเขียว"
blueComponent: "ส่วนสีน้ำเงิน"
threshold: "เทรชโฮลด์"
centerX: "กลาง X"
centerY: "กลาง Y"
zoomLinesSmoothing: "ทำให้สมูธ"
zoomLinesSmoothingDescription: "ตั้งให้สมูธไม่สามารถใช้ร่วมกับตั้งความกว้างเส้นรวมศูนย์ได้"
zoomLinesThreshold: "ความกว้างเส้นรวมศูนย์"
zoomLinesMaskSize: "ขนาดพื้นที่ตรงกลาง"
zoomLinesBlack: "ทำให้ดำ"
drafts: "ร่าง"
_drafts:
select: "เลือกฉบับร่าง"

File diff suppressed because it is too large Load diff

View file

@ -1648,3 +1648,10 @@ _watermarkEditor:
type: "Тип"
image: "Зображення"
advanced: "Розширені"
_imageEffector:
_fxProps:
scale: "Розмір"
size: "Розмір"
color: "Колір"
opacity: "Непрозорість"
lightness: "Яскравість"

View file

@ -1102,3 +1102,7 @@ _watermarkEditor:
type: "turi"
image: "Rasmlar"
advanced: "Murakkab"
_imageEffector:
_fxProps:
color: "Rang"
lightness: "Yoritish"

View file

@ -2091,3 +2091,11 @@ _watermarkEditor:
image: "Hình ảnh"
advanced: "Nâng cao"
angle: "Góc"
_imageEffector:
_fxProps:
angle: "Góc"
scale: "Kích thước"
size: "Kích thước"
color: "Màu sắc"
opacity: "Độ trong suốt"
lightness: "Độ sáng"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "AND 条件用空格分隔,正则表达式用斜
hiddenTags: "隐藏标签"
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
notesSearchNotAvailable: "帖子检索不可用"
usersSearchNotAvailable: "用户检索不可用"
license: "许可信息"
unfavoriteConfirm: "确定要取消收藏吗?"
myClips: "我的便签"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
showUrlPreview: "显示 URL 预览"
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
showPageTabBarBottom: "在下方显示页面标签栏"
_chat:
showSenderName: "显示发送者的名字"
sendOnEnter: "回车键发送"
@ -1998,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "可以隐藏广告"
canSearchNotes: "是否可以搜索帖子"
canSearchUsers: "使用用户检索"
canUseTranslator: "使用翻译功能"
avatarDecorationLimit: "可添加头像挂件的最大个数"
canImportAntennas: "允许导入天线"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "类型"
image: "图片"
advanced: "高级"
angle: "角度"
stripe: "条纹"
stripeWidth: "线条宽度"
stripeFrequency: "线条数量"
angle: "角度"
polkadot: "波点"
checker: "检查"
polkadotMainDotOpacity: "主波点的不透明度"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "效果"
addEffect: "添加效果"
discardChangesConfirm: "丢弃当前设置并退出?"
nothingToConfigure: "还没有设置"
_fxs:
chromaticAberration: "色差"
glitch: "故障"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "检查"
blockNoise: "块状噪点"
tearing: "撕裂"
_fxProps:
angle: "角度"
scale: "大小"
size: "大小"
color: "颜色"
opacity: "不透明度"
normalize: "标准化"
amount: "数量"
lightness: "浅色"
contrast: "对比度"
hue: "色调"
brightness: "亮度"
saturation: "饱和度"
max: "最大值"
min: "最小值"
direction: "方向"
phase: "相位"
frequency: "频率"
strength: "强度"
glitchChannelShift: "错位"
seed: "种子"
redComponent: "红色成分"
greenComponent: "绿色成分"
blueComponent: "蓝色成分"
threshold: "阈值"
centerX: "中心 X "
centerY: "中心 Y"
zoomLinesSmoothing: "平滑"
zoomLinesSmoothingDescription: "平滑和集中线宽度设置不能同时使用。"
zoomLinesThreshold: "集中线宽度"
zoomLinesMaskSize: "中心直径"
zoomLinesBlack: "变成黑色"
drafts: "草稿"
_drafts:
select: "选择草稿"

View file

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "空格代表「以及」AND斜線包圍
hiddenTags: "隱藏標籤"
hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。"
notesSearchNotAvailable: "無法使用搜尋貼文功能。"
usersSearchNotAvailable: "無法使用使用者搜尋功能。"
license: "授權"
unfavoriteConfirm: "要取消收錄我的最愛嗎?"
myClips: "我的摘錄"
@ -1373,7 +1374,7 @@ inDays: "日"
safeModeEnabled: "啟用安全模式"
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
themeIsDefaultBecauseSafeMode: "啟用安全模式時將使用預設主題,關閉安全模式時將恢復預設主題。"
themeIsDefaultBecauseSafeMode: "在安全模式啟用期間將使用預設主題。關閉安全模式後會恢復原本的設定。"
_order:
newest: "最新的在前"
oldest: "最舊的在前"
@ -1465,6 +1466,7 @@ _settings:
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
showUrlPreview: "顯示網址預覽"
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
showPageTabBarBottom: "在底部顯示頁面的標籤列"
_chat:
showSenderName: "顯示發送者的名稱"
sendOnEnter: "按下 Enter 發送訊息"
@ -1549,7 +1551,7 @@ _initialAccountSetting:
theseSettingsCanEditLater: "這裡的設定可以在之後變更。"
youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。"
followUsers: "為了構築時間軸,試著追隨您感興趣的使用者吧。"
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自{name}的通知了。"
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自 {name} 的通知了。"
initialAccountSettingCompleted: "初始設定完成了!"
haveFun: "盡情享受{name}吧!"
youCanContinueTutorial: "您可以繼續學習如何使用{name}(Misskey),也可以就此打住,立即開始使用。"
@ -1998,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "不顯示廣告"
canSearchNotes: "可否搜尋貼文"
canSearchUsers: "可使用使用者搜尋功能"
canUseTranslator: "使用翻譯功能"
avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
canImportAntennas: "允許匯入天線"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "類型"
image: "圖片"
advanced: "進階"
angle: "角度"
stripe: "條紋"
stripeWidth: "線條寬度"
stripeFrequency: "線條數量"
angle: "角度"
polkadot: "波卡圓點"
checker: "棋盤格"
polkadotMainDotOpacity: "主圓點的不透明度"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "特效"
addEffect: "新增特效"
discardChangesConfirm: "捨棄更改並退出嗎?"
nothingToConfigure: "無可設定的項目"
_fxs:
chromaticAberration: "色差"
glitch: "異常雜訊效果"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "棋盤格"
blockNoise: "阻擋雜訊"
tearing: "撕裂"
_fxProps:
angle: "角度"
scale: "大小"
size: "大小"
color: "顏色"
opacity: "透明度"
normalize: "正規化"
amount: "數量"
lightness: "亮度"
contrast: "對比度"
hue: "色相"
brightness: "亮度"
saturation: "彩度"
max: "最大值"
min: "最小值"
direction: "方向"
phase: "相位"
frequency: "頻率"
strength: "強度"
glitchChannelShift: "偏移"
seed: "種子值"
redComponent: "紅色成分"
greenComponent: "綠色成分"
blueComponent: "青色成分"
threshold: "閾值"
centerX: "X中心座標"
centerY: "Y中心座標"
zoomLinesSmoothing: "平滑化"
zoomLinesSmoothingDescription: "平滑化與集中線寬度設定不能同時使用。"
zoomLinesThreshold: "集中線的寬度"
zoomLinesMaskSize: "中心直徑"
zoomLinesBlack: "變成黑色"
drafts: "草稿\n"
_drafts:
select: "選擇草槁"

View file

@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2025.8.0-alpha.5",
"version": "2025.8.0-alpha.12",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.14.0",
"workspaces": [
"packages/frontend-shared",
"packages/frontend",
@ -53,7 +53,7 @@
},
"dependencies": {
"cssnano": "7.1.0",
"esbuild": "0.25.6",
"esbuild": "0.25.8",
"execa": "9.6.0",
"fast-glob": "3.3.3",
"glob": "11.0.3",
@ -62,20 +62,20 @@
"postcss": "8.5.6",
"tar": "7.4.3",
"terser": "5.43.1",
"typescript": "5.8.3"
"typescript": "5.9.2"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/node": "22.16.4",
"@typescript-eslint/eslint-plugin": "8.37.0",
"@typescript-eslint/parser": "8.37.0",
"@types/node": "22.17.1",
"@typescript-eslint/eslint-plugin": "8.39.0",
"@typescript-eslint/parser": "8.39.0",
"cross-env": "7.0.3",
"cypress": "14.5.2",
"eslint": "9.31.0",
"cypress": "14.5.4",
"eslint": "9.33.0",
"globals": "16.3.0",
"ncp": "2.0.0",
"pnpm": "10.13.1",
"start-server-and-test": "2.0.12"
"pnpm": "10.14.0",
"start-server-and-test": "2.0.13"
},
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.22.0"

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class PageCountInNote1755168347001 {
name = 'PageCountInNote1755168347001'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "pageCount" smallint NOT NULL DEFAULT '0'`);
// Update existing notes
// block_list CTE collects all page blocks on the pages including child blocks in the section blocks.
// The clipped_notes CTE counts how many distinct pages each note block is referenced in.
// Finally, we update the note table with the count of pages for each referenced note.
await queryRunner.query(`
WITH RECURSIVE block_list AS (
(
SELECT
page.id as page_id,
block as block
FROM page
CROSS JOIN LATERAL jsonb_array_elements(page.content) block
WHERE block->>'type' = 'note' OR block->>'type' = 'section'
)
UNION ALL
(
SELECT
block_list.page_id,
child_block AS block
FROM LATERAL (
SELECT page_id, block
FROM block_list
WHERE block_list.block->>'type' = 'section'
) block_list
CROSS JOIN LATERAL jsonb_array_elements(block_list.block->'children') child_block
WHERE child_block->>'type' = 'note' OR child_block->>'type' = 'section'
)
),
clipped_notes AS (
SELECT
(block->>'note') AS note_id,
COUNT(distinct block_list.page_id) AS count
FROM block_list
WHERE block_list.block->>'type' = 'note'
GROUP BY block->>'note'
)
UPDATE note
SET "pageCount" = clipped_notes.count
FROM clipped_notes
WHERE note.id = clipped_notes.note_id;
`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "pageCount"`);
}
}

View file

@ -38,17 +38,17 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.12.0",
"@swc/core-darwin-x64": "1.12.0",
"@swc/core-darwin-arm64": "1.13.3",
"@swc/core-darwin-x64": "1.13.3",
"@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.12.0",
"@swc/core-linux-arm64-gnu": "1.12.0",
"@swc/core-linux-arm64-musl": "1.12.0",
"@swc/core-linux-x64-gnu": "1.12.0",
"@swc/core-linux-x64-musl": "1.12.0",
"@swc/core-win32-arm64-msvc": "1.12.0",
"@swc/core-win32-ia32-msvc": "1.12.0",
"@swc/core-win32-x64-msvc": "1.12.0",
"@swc/core-linux-arm-gnueabihf": "1.13.3",
"@swc/core-linux-arm64-gnu": "1.13.3",
"@swc/core-linux-arm64-musl": "1.13.3",
"@swc/core-linux-x64-gnu": "1.13.3",
"@swc/core-linux-x64-musl": "1.13.3",
"@swc/core-win32-arm64-msvc": "1.13.3",
"@swc/core-win32-ia32-msvc": "1.13.3",
"@swc/core-win32-x64-msvc": "1.13.3",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.9",
@ -68,8 +68,8 @@
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.826.0",
"@aws-sdk/lib-storage": "3.826.0",
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/lib-storage": "3.864.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.2",
"@fastify/cookie": "11.0.2",
@ -80,19 +80,19 @@
"@fastify/static": "8.2.0",
"@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.1",
"@napi-rs/canvas": "0.1.71",
"@nestjs/common": "11.1.3",
"@nestjs/core": "11.1.3",
"@nestjs/testing": "11.1.3",
"@misskey-dev/summaly": "5.2.3",
"@napi-rs/canvas": "0.1.77",
"@nestjs/common": "11.1.6",
"@nestjs/core": "11.1.6",
"@nestjs/testing": "11.1.6",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "8.55.0",
"@sentry/profiling-node": "8.55.0",
"@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.7.7",
"@swc/core": "1.12.0",
"@swc/cli": "0.7.8",
"@swc/core": "1.13.3",
"@twemoji/parser": "16.0.0",
"@types/redis-info": "3.0.3",
"accepts": "1.3.8",
@ -102,10 +102,10 @@
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
"bullmq": "5.53.2",
"bullmq": "5.56.9",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.2",
"chalk": "5.4.1",
"chalk": "5.5.0",
"chalk-template": "1.1.0",
"chokidar": "4.0.3",
"cli-highlight": "2.1.11",
@ -113,18 +113,18 @@
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "5.3.3",
"fastify": "5.4.0",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
"file-type": "19.6.0",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.3",
"form-data": "4.0.4",
"got": "14.4.7",
"happy-dom": "16.8.1",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
"ioredis": "5.6.1",
"ioredis": "5.7.0",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
"is-svg": "5.1.0",
@ -136,7 +136,7 @@
"juice": "11.0.1",
"meilisearch": "0.51.0",
"mfm-js": "0.25.0",
"microformats-parser": "2.0.3",
"microformats-parser": "2.0.4",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
@ -152,7 +152,7 @@
"os-utils": "0.0.14",
"otpauth": "9.4.0",
"parse5": "7.3.0",
"pg": "8.16.0",
"pg": "8.16.3",
"pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@ -174,25 +174,25 @@
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.27.1",
"systeminformation": "5.27.7",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.24",
"typescript": "5.8.3",
"typeorm": "0.3.25",
"typescript": "5.9.2",
"ulid": "2.4.0",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.2",
"ws": "8.18.3",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.19",
"@sentry/vue": "9.28.0",
"@nestjs/platform-express": "10.4.20",
"@sentry/vue": "9.45.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.38",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.3",
"@types/bcryptjs": "2.4.6",
@ -209,12 +209,12 @@
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "22.15.31",
"@types/node": "22.17.1",
"@types/nodemailer": "6.4.17",
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.15.4",
"@types/pg": "8.15.5",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
@ -230,11 +230,11 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.34.0",
"@typescript-eslint/parser": "8.34.0",
"@typescript-eslint/eslint-plugin": "8.39.0",
"@typescript-eslint/parser": "8.39.0",
"aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"execa": "8.0.1",
"fkill": "9.0.0",
"jest": "29.7.0",
@ -242,6 +242,6 @@
"nodemon": "3.1.10",
"pid-port": "1.0.2",
"simple-oauth2": "5.1.0",
"supertest": "7.1.1"
"supertest": "7.1.4"
}
}

View file

@ -78,6 +78,7 @@ import { ChannelFollowingService } from './ChannelFollowingService.js';
import { ChatService } from './ChatService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { PageService } from './PageService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
@ -227,6 +228,7 @@ const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService',
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@ -379,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChatService,
RegistryApiService,
ReversiService,
PageService,
ChartLoggerService,
FederationChart,
@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChatService,
$RegistryApiService,
$ReversiService,
$PageService,
$ChartLoggerService,
$FederationChart,
@ -676,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChatService,
RegistryApiService,
ReversiService,
PageService,
FederationChart,
NotesChart,
@ -822,6 +827,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChatService,
$RegistryApiService,
$ReversiService,
$PageService,
$FederationChart,
$NotesChart,

View file

@ -6,6 +6,7 @@
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import * as stream from 'node:stream';
import ipaddr from 'ipaddr.js';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
@ -26,12 +27,6 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[];
};
declare module 'node:http' {
interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
}
}
class HttpRequestServiceAgent extends http.Agent {
constructor(
private config: Config,
@ -41,11 +36,11 @@ class HttpRequestServiceAgent extends http.Agent {
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
const address = socket.remoteAddress;
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
@ -80,11 +75,11 @@ class HttpsRequestServiceAgent extends https.Agent {
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
const address = socket.remoteAddress;
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));

View file

@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
type NotesRepository,
MiPage,
type PagesRepository,
MiDriveFile,
type UsersRepository,
MiNote,
} from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export interface PageBody {
title: string;
name: string;
summary: string | null;
content: Array<Record<string, any>>;
variables: Array<Record<string, any>>;
script: string;
eyeCatchingImage?: MiDriveFile | null;
font: string;
alignCenter: boolean;
hideTitleWhenPinned: boolean;
}
@Injectable()
export class PageService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private idService: IdService,
) {
}
@bindThis
public async create(
me: MiUser,
body: PageBody,
): Promise<MiPage> {
await this.pagesRepository.findBy({
userId: me.id,
name: body.name,
}).then(result => {
if (result.length > 0) {
throw new IdentifiableError('1a79e38e-3d83-4423-845b-a9d83ff93b61');
}
});
const page = await this.pagesRepository.insertOne(new MiPage({
id: this.idService.gen(),
updatedAt: new Date(),
title: body.title,
name: body.name,
summary: body.summary,
content: body.content,
variables: body.variables,
script: body.script,
eyeCatchingImageId: body.eyeCatchingImage ? body.eyeCatchingImage.id : null,
userId: me.id,
visibility: 'public',
alignCenter: body.alignCenter,
hideTitleWhenPinned: body.hideTitleWhenPinned,
font: body.font,
}));
const referencedNotes = this.collectReferencedNotes(page.content);
if (referencedNotes.length > 0) {
await this.notesRepository.increment({ id: In(referencedNotes) }, 'pageCount', 1);
}
return page;
}
@bindThis
public async update(
me: MiUser,
pageId: MiPage['id'],
body: Partial<PageBody>,
): Promise<void> {
await this.db.transaction(async (transaction) => {
const page = await transaction.findOne(MiPage, {
where: {
id: pageId,
},
lock: { mode: 'for_no_key_update' },
});
if (page == null) {
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
}
if (page.userId !== me.id) {
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
}
if (body.name != null) {
await transaction.findBy(MiPage, {
id: Not(pageId),
userId: me.id,
name: body.name,
}).then(result => {
if (result.length > 0) {
throw new IdentifiableError('d05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4');
}
});
}
await transaction.update(MiPage, page.id, {
updatedAt: new Date(),
title: body.title,
name: body.name,
summary: body.summary === undefined ? page.summary : body.summary,
content: body.content,
variables: body.variables,
script: body.script,
alignCenter: body.alignCenter,
hideTitleWhenPinned: body.hideTitleWhenPinned,
font: body.font,
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
});
console.log("page.content", page.content);
if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content);
const afterReferencedNotes = this.collectReferencedNotes(body.content);
const removedNotes = beforeReferencedNotes.filter(noteId => !afterReferencedNotes.includes(noteId));
const addedNotes = afterReferencedNotes.filter(noteId => !beforeReferencedNotes.includes(noteId));
if (removedNotes.length > 0) {
await transaction.decrement(MiNote, { id: In(removedNotes) }, 'pageCount', 1);
}
if (addedNotes.length > 0) {
await transaction.increment(MiNote, { id: In(addedNotes) }, 'pageCount', 1);
}
}
});
}
@bindThis
public async delete(me: MiUser, pageId: MiPage['id']): Promise<void> {
await this.db.transaction(async (transaction) => {
const page = await transaction.findOne(MiPage, {
where: {
id: pageId,
},
lock: { mode: 'pessimistic_write' }, // same lock level as DELETE
});
if (page == null) {
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
}
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
}
await transaction.delete(MiPage, page.id);
if (page.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
this.moderationLogService.log(me, 'deletePage', {
pageId: page.id,
pageUserId: page.userId,
pageUserUsername: user.username,
page,
});
}
const referencedNotes = this.collectReferencedNotes(page.content);
if (referencedNotes.length > 0) {
await transaction.decrement(MiNote, { id: In(referencedNotes) }, 'pageCount', 1);
}
});
}
collectReferencedNotes(content: MiPage['content']): string[] {
const referencingNotes = new Set<string>();
const recursiveCollect = (content: unknown[]) => {
for (const contentElement of content) {
if (typeof contentElement === 'object'
&& contentElement !== null
&& 'type' in contentElement) {
if (contentElement.type === 'note'
&& 'note' in contentElement
&& typeof contentElement.note === 'string') {
referencingNotes.add(contentElement.note);
}
if (contentElement.type === 'section'
&& 'children' in contentElement
&& Array.isArray(contentElement.children)) {
recursiveCollect(contentElement.children);
}
}
}
};
recursiveCollect(content);
return [...referencingNotes];
}
}

View file

@ -103,6 +103,7 @@ export class QueueService {
for (const def of REPEATABLE_SYSTEM_JOB_DEF) {
this.systemQueue.upsertJobScheduler(def.name, {
pattern: def.pattern,
immediately: false,
}, {
name: def.name,
opts: {

View file

@ -43,6 +43,7 @@ export type RolePolicies = {
canManageCustomEmojis: boolean;
canManageAvatarDecorations: boolean;
canSearchNotes: boolean;
canSearchUsers: boolean;
canUseTranslator: boolean;
canHideAds: boolean;
driveCapacityMb: number;
@ -82,6 +83,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canManageCustomEmojis: false,
canManageAvatarDecorations: false,
canSearchNotes: false,
canSearchUsers: true,
canUseTranslator: true,
canHideAds: false,
driveCapacityMb: 100,
@ -402,6 +404,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canSearchUsers: calc('canSearchUsers', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),

View file

@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteCount: 10,
repliesCount: 5,
clippedCount: 0,
pageCount: 0,
reactions: {},
visibility: 'public',
uri: null,

View file

@ -114,6 +114,13 @@ export class MiNote {
})
public clippedCount: number;
// The number of note page blocks referencing this note.
// This column is used by Remote Note Cleaning and manually updated rather than automatically with triggers.
@Column('smallint', {
default: 0,
})
public pageCount: number;
@Column('jsonb', {
default: {},
})

View file

@ -212,6 +212,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
canSearchUsers: {
type: 'boolean',
optional: false, nullable: false,
},
canUseTranslator: {
type: 'boolean',
optional: false, nullable: false,

View file

@ -5,9 +5,9 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common';
import { And, Brackets, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js';
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@ -25,11 +25,8 @@ export class CleanRemoteNotesProcessorService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteFavoritesRepository)
private noteFavoritesRepository: NoteFavoritesRepository,
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
@Inject(DI.db)
private db: DataSource,
private idService: IdService,
private queueLoggerService: QueueLoggerService,
@ -37,12 +34,22 @@ export class CleanRemoteNotesProcessorService {
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes');
}
@bindThis
private computeProgress(minId: string, maxId: string, cursorLeft: string) {
const minTs = this.idService.parse(minId).date.getTime();
const maxTs = this.idService.parse(maxId).date.getTime();
const cursorTs = this.idService.parse(cursorLeft).date.getTime();
return ((cursorTs - minTs) / (maxTs - minTs)) * 100;
}
@bindThis
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
deletedCount: number;
oldest: number | null;
newest: number | null;
skipped?: boolean;
skipped: boolean;
transientErrors: number;
}> {
if (!this.meta.enableRemoteNotesCleaning) {
this.logger.info('Remote notes cleaning is disabled, skipping...');
@ -51,6 +58,7 @@ export class CleanRemoteNotesProcessorService {
oldest: null,
newest: null,
skipped: true,
transientErrors: 0,
};
}
@ -59,7 +67,106 @@ export class CleanRemoteNotesProcessorService {
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
const startAt = Date.now();
const MAX_NOTE_COUNT_PER_QUERY = 50;
//#region queries
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
// The condition for removing the notes.
// The note must be:
// - old enough (older than the newestLimit)
// - a remote note (userHost is not null).
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
const removalCriteria = [
'note."id" < :newestLimit',
'note."clippedCount" = 0',
'note."pageCount" = 0',
'note."userHost" IS NOT NULL',
'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")',
'NOT EXISTS (SELECT 1 FROM note_favorite WHERE "noteId" = note."id")',
].join(' AND ');
const minId = (await this.notesRepository.createQueryBuilder('note')
.select('MIN(note.id)', 'minId')
.where({
id: LessThan(newestLimit),
userHost: Not(IsNull()),
replyId: IsNull(),
renoteId: IsNull(),
})
.getRawOne<{ minId?: MiNote['id'] }>())?.minId;
if (!minId) {
this.logger.info('No notes can possibly be deleted, skipping...');
return {
deletedCount: 0,
oldest: null,
newest: null,
skipped: false,
transientErrors: 0,
};
}
// start with a conservative limit and adjust it based on the query duration
const minimumLimit = 10;
let currentLimit = 100;
let cursorLeft = '0';
const candidateNotesCteName = 'candidate_notes';
// tree walk down all root notes, short-circuit when the first unremovable note is found
const candidateNotesQueryBase = this.notesRepository.createQueryBuilder('note')
.select('note."id"', 'id')
.addSelect('note."replyId"', 'replyId')
.addSelect('note."renoteId"', 'renoteId')
.addSelect('note."id"', 'rootId')
.addSelect('TRUE', 'isRemovable')
.addSelect('TRUE', 'isBase')
.where('note."id" > :cursorLeft')
.andWhere(removalCriteria)
.andWhere({ replyId: IsNull(), renoteId: IsNull() });
const candidateNotesQueryInductive = this.notesRepository.createQueryBuilder('note')
.select('note.id', 'id')
.addSelect('note."replyId"', 'replyId')
.addSelect('note."renoteId"', 'renoteId')
.addSelect('parent."rootId"', 'rootId')
.addSelect(removalCriteria, 'isRemovable')
.addSelect('FALSE', 'isBase')
.innerJoin(candidateNotesCteName, 'parent', 'parent."id" = note."replyId" OR parent."id" = note."renoteId"')
.where('parent."isRemovable" = TRUE');
// A note tree can be deleted if there are no unremovable rows with the same rootId.
//
// `candidate_notes` will have the following structure after recursive query (some columns omitted):
// After performing a LEFT JOIN with `candidate_notes` as `unremovable`,
// the note tree containing unremovable notes will be anti-joined.
// For removable rows, the `unremovable` columns will have `NULL` values.
// | id | rootId | isRemovable |
// |-----|--------|-------------|
// | aaa | aaa | TRUE |
// | bbb | aaa | FALSE |
// | ccc | aaa | FALSE |
// | ddd | ddd | TRUE |
// | eee | ddd | TRUE |
// | fff | fff | TRUE |
// | ggg | ggg | FALSE |
//
const candidateNotesQuery = this.db.createQueryBuilder()
.select(`"${candidateNotesCteName}"."id"`, 'id')
.addSelect('unremovable."id" IS NULL', 'isRemovable')
.addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
.addCommonTableExpression(
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
candidateNotesCteName,
{ recursive: true },
)
.from(candidateNotesCteName, candidateNotesCteName)
.leftJoin(candidateNotesCteName, 'unremovable', `unremovable."rootId" = "${candidateNotesCteName}"."rootId" AND unremovable."isRemovable" = FALSE`)
.groupBy(`"${candidateNotesCteName}"."id"`)
.addGroupBy('unremovable."id" IS NULL');
const stats = {
deletedCount: 0,
@ -67,111 +174,107 @@ export class CleanRemoteNotesProcessorService {
newest: null as number | null,
};
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
let cursor = '0'; // oldest note ID to start from
while (true) {
let lowThroughputWarned = false;
let transientErrors = 0;
for (;;) {
//#region check time
const batchBeginAt = Date.now();
// We use string literals instead of query builder for several reasons:
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
const elapsed = batchBeginAt - startAt;
// The condition for removing the notes.
// The note must be:
// - old enough (older than the newestLimit)
// - a remote note (userHost is not null).
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
const removeCondition = 'note.id < :newestLimit'
+ ' AND note."clippedCount" = 0'
+ ' AND note."userHost" IS NOT NULL'
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
// This is safe because notes are only pinned by the user who created them.
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
// We cannot use userId trick because users can favorite notes from other users.
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
;
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
const initiatorQuery = `
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
FROM "note" "note" WHERE ${removeCondition} AND "note"."id" > :cursor ORDER BY "note"."id" ASC LIMIT ${MAX_NOTE_COUNT_PER_QUERY}`;
// The union query queries the related notes and replies related to the initiator query
const unionQuery = `
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
FROM "note" "note"
INNER JOIN "related_notes" "rn"
ON "note"."replyId" = rn.id
OR "note"."renoteId" = rn.id
OR "note"."id" = rn."replyId"
OR "note"."id" = rn."renoteId"
`;
const recursiveQuery = `(${initiatorQuery}) UNION (${unionQuery})`;
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
.select('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.groupBy('rn."initiatorId"')
.having(`bool_and(${removeCondition})`);
const notesQuery = this.notesRepository.createQueryBuilder('note')
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
.select('note.id', 'id')
.addSelect('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.where(`rn."initiatorId" IN (${ removableInitiatorNotesQuery.getQuery() })`)
.setParameters({ cursor, newestLimit });
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.getRawMany();
const fetchedCount = notes.length;
// update the cursor to the newest initiatorId found in the fetched notes.
// We don't use 'id' since the note can be newer than the initiator note.
for (const note of notes) {
if (cursor < note.initiatorId) {
cursor = note.initiatorId;
}
}
if (notes.length > 0) {
await this.notesRepository.delete(notes.map(note => note.id));
for (const note of notes) {
const t = this.idService.parse(note.id).date.getTime();
if (stats.oldest === null || t < stats.oldest) {
stats.oldest = t;
}
if (stats.newest === null || t > stats.newest) {
stats.newest = t;
}
}
stats.deletedCount += notes.length;
}
job.log(`Deleted ${notes.length} of ${fetchedCount}; ${Date.now() - batchBeginAt}ms`);
const elapsed = Date.now() - startAt;
const progress = this.computeProgress(minId, newestLimit, cursorLeft > minId ? cursorLeft : minId);
if (elapsed >= maxDuration) {
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
job.log('Reached maximum duration, stopping cleaning.');
job.log(`Reached maximum duration of ${maxDuration}ms, stopping... (last cursor: ${cursorLeft}, final progress ${progress}%)`);
job.updateProgress(100);
break;
}
job.updateProgress((elapsed / maxDuration) * 100);
const wallClockUsage = elapsed / maxDuration;
if (wallClockUsage > 0.5 && progress < 50 && !lowThroughputWarned) {
const msg = `Not projected to finish in time! (wall clock usage ${wallClockUsage * 100}% at ${progress}%, current limit ${currentLimit})`;
this.logger.warn(msg);
job.log(msg);
lowThroughputWarned = true;
}
job.updateProgress(progress);
//#endregion
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
const queryBegin = performance.now();
let noteIds = null;
try {
noteIds = await candidateNotesQuery.setParameters(
{ newestLimit, cursorLeft },
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
} catch (e) {
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
continue;
}
throw e;
}
if (noteIds.length === 0) {
job.log('No more notes to clean.');
break;
}
const queryDuration = performance.now() - queryBegin;
// try to adjust such that each query takes about 1~5 seconds and reasonable NodeJS heap so the task stays responsive
// this should not oscillate..
if (queryDuration > 5000 || noteIds.length > 5000) {
currentLimit = Math.floor(currentLimit * 0.5);
} else if (queryDuration < 1000 && noteIds.length < 1000) {
currentLimit = Math.floor(currentLimit * 1.5);
}
// clamp to a sane range
currentLimit = Math.min(Math.max(currentLimit, minimumLimit), 5000);
const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
if (deletableNoteIds.length > 0) {
try {
await this.notesRepository.delete(deletableNoteIds);
for (const id of deletableNoteIds) {
const t = this.idService.parse(id).date.getTime();
if (stats.oldest === null || t < stats.oldest) {
stats.oldest = t;
}
if (stats.newest === null || t > stats.newest) {
stats.newest = t;
}
}
stats.deletedCount += deletableNoteIds.length;
} catch (e) {
// check for integrity violation errors (class 23) that might have occurred between the check and the delete
// we can safely continue to the next batch
if (e instanceof QueryFailedError && e.driverError?.code?.startsWith('23')) {
transientErrors++;
job.log(`Error deleting notes: ${e} (transient race condition?)`);
} else {
throw e;
}
}
}
cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
job.log(`Deleted ${noteIds.length} notes; ${Date.now() - batchBeginAt}ms`);
if (process.env.NODE_ENV !== 'test') {
await setTimeout(Math.min(1000 * 5, queryDuration)); // Wait a moment to avoid overwhelming the db
}
};
if (transientErrors > 0) {
const msg = `${transientErrors} transient errors occurred while cleaning remote notes. You may need a second pass to complete the cleaning.`;
this.logger.warn(msg);
job.log(msg);
}
this.logger.succ('cleaning of remote notes completed.');
return {
@ -179,6 +282,7 @@ export class CleanRemoteNotesProcessorService {
oldest: stats.oldest,
newest: stats.newest,
skipped: false,
transientErrors,
};
}
}

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { DriveFilesRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
@ -14,6 +14,7 @@ import type { MiNote } from '@/models/Note.js';
import { EmailService } from '@/core/EmailService.js';
import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js';
import { PageService } from '@/core/PageService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js';
@ -35,7 +36,11 @@ export class DeleteAccountProcessorService {
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
private driveService: DriveService,
private pageService: PageService,
private emailService: EmailService,
private queueLoggerService: QueueLoggerService,
private searchService: SearchService,
@ -112,6 +117,28 @@ export class DeleteAccountProcessorService {
this.logger.succ('All of files deleted');
}
{
// delete pages. Necessary for decrementing pageCount of notes.
while (true) {
const pages = await this.pagesRepository.find({
where: {
userId: user.id,
},
take: 100,
order: {
id: 1,
},
});
if (pages.length === 0) {
break;
}
for (const page of pages) {
await this.pageService.delete(user, page.id);
}
}
}
{ // Send email notification
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.email && profile.emailVerified) {

View file

@ -48,8 +48,8 @@ export const paramDef = {
},
secret: {
type: 'string',
minLength: 1,
maxLength: 1024,
default: '',
},
},
required: [
@ -57,7 +57,6 @@ export const paramDef = {
'name',
'on',
'url',
'secret',
],
} as const;

View file

@ -52,8 +52,8 @@ export const paramDef = {
},
secret: {
type: 'string',
minLength: 1,
maxLength: 1024,
default: '',
},
},
required: [
@ -62,7 +62,6 @@ export const paramDef = {
'name',
'on',
'url',
'secret',
],
} as const;

View file

@ -32,6 +32,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'read');
await this.chatService.readAllChatMessages(me.id);
});
}

View file

@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ChatService } from '@/core/ChatService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -60,14 +61,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
private chatService: ChatService,
private chatEntityService: ChatEntityService,
private queryService: QueryService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const isModerator = await this.roleService.isModerator(me);
if (!isModerator) {
await this.chatService.checkChatAvailability(me.id, 'read');
}
const file = await this.driveFilesRepository.findOneBy({
id: ps.fileId,
userId: await this.roleService.isModerator(me) ? undefined : me.id,
userId: isModerator ? undefined : me.id,
});
if (file == null) {

View file

@ -5,12 +5,13 @@
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { MiPage, pageNameSchema } from '@/models/Page.js';
import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js';
import { pageNameSchema } from '@/models/Page.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.js';
import { PageService } from '@/core/PageService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -77,11 +78,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
private pageEntityService: PageEntityService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
let eyeCatchingImage = null;
let eyeCatchingImage: MiDriveFile | null = null;
if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
@ -102,24 +103,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
});
const page = await this.pagesRepository.insertOne(new MiPage({
id: this.idService.gen(),
updatedAt: new Date(),
title: ps.title,
name: ps.name,
summary: ps.summary,
content: ps.content,
variables: ps.variables,
script: ps.script,
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
userId: me.id,
visibility: 'public',
alignCenter: ps.alignCenter,
hideTitleWhenPinned: ps.hideTitleWhenPinned,
font: ps.font,
}));
try {
const page = await this.pageService.create(me, {
...ps,
eyeCatchingImage,
summary: ps.summary ?? null,
});
return await this.pageEntityService.pack(page);
return await this.pageEntityService.pack(page);
} catch (err) {
if (err instanceof IdentifiableError && err.id === '1a79e38e-3d83-4423-845b-a9d83ff93b61') {
throw new ApiError(meta.errors.nameAlreadyExists);
}
throw err;
}
});
}
}

View file

@ -4,12 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile, PagesRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { PageService } from '@/core/PageService.js';
export const meta = {
tags: ['pages'],
@ -44,36 +46,17 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
private pageService: PageService,
) {
super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.pagesRepository.delete(page.id);
if (page.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
this.moderationLogService.log(me, 'deletePage', {
pageId: page.id,
pageUserId: page.userId,
pageUserUsername: user.username,
page,
});
try {
await this.pageService.delete(me, ps.pageId);
} catch (err) {
if (err instanceof IdentifiableError) {
if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
}
throw err;
}
});
}

View file

@ -4,13 +4,14 @@
*/
import ms from 'ms';
import { Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { pageNameSchema } from '@/models/Page.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { PageService } from '@/core/PageService.js';
export const meta = {
tags: ['pages'],
@ -75,57 +76,37 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
) {
super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
if (page.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
try {
let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
if (eyeCatchingImage != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: eyeCatchingImage,
userId: me.id,
});
if (ps.eyeCatchingImageId != null) {
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
userId: me.id,
});
if (eyeCatchingImage == null) {
throw new ApiError(meta.errors.noSuchFile);
}
}
if (ps.name != null) {
await this.pagesRepository.findBy({
id: Not(ps.pageId),
userId: me.id,
name: ps.name,
}).then(result => {
if (result.length > 0) {
throw new ApiError(meta.errors.nameAlreadyExists);
if (eyeCatchingImage == null) {
throw new ApiError(meta.errors.noSuchFile);
}
});
}
}
await this.pagesRepository.update(page.id, {
updatedAt: new Date(),
title: ps.title,
name: ps.name,
summary: ps.summary === undefined ? page.summary : ps.summary,
content: ps.content,
variables: ps.variables,
script: ps.script,
alignCenter: ps.alignCenter,
hideTitleWhenPinned: ps.hideTitleWhenPinned,
font: ps.font,
eyeCatchingImageId: ps.eyeCatchingImageId,
});
await this.pageService.update(me, ps.pageId, {
...ps,
eyeCatchingImage,
});
} catch (err) {
if (err instanceof IdentifiableError) {
if (err.id === '66aefd3c-fdb2-4a71-85ae-cc18bea85d3f') throw new ApiError(meta.errors.noSuchPage);
if (err.id === 'd0017699-8256-46f1-aed4-bc03bed73616') throw new ApiError(meta.errors.accessDenied);
if (err.id === 'd05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4') throw new ApiError(meta.errors.nameAlreadyExists);
}
throw err;
}
});
}
}

View file

@ -13,6 +13,7 @@ export const meta = {
tags: ['users'],
requireCredential: false,
requiredRolePolicy: 'canSearchUsers',
description: 'Search for users.',

View file

@ -32,7 +32,6 @@ export default class Connection {
public subscriber: StreamEventEmitter;
private channels: Channel[] = [];
private subscribingNotes: Partial<Record<string, number>> = {};
private cachedNotes: Packed<'Note'>[] = [];
public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set();
@ -132,26 +131,6 @@ export default class Connection {
this.sendMessageToWs(data.type, data.body);
}
@bindThis
public cacheNote(note: Packed<'Note'>) {
const add = (note: Packed<'Note'>) => {
const existIndex = this.cachedNotes.findIndex(n => n.id === note.id);
if (existIndex > -1) {
this.cachedNotes[existIndex] = note;
return;
}
this.cachedNotes.unshift(note);
if (this.cachedNotes.length > 32) {
this.cachedNotes.splice(32);
}
};
add(note);
if (note.reply) add(note.reply);
if (note.renote) add(note.renote);
}
@bindThis
private onReadNotification(payload: JsonValue | undefined) {
this.notificationService.readAllNotification(this.user!.id);

View file

@ -43,8 +43,6 @@ class AntennaChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
this.connection.cacheNote(note);
this.send('note', note);
} else {
this.send(data.type, data.body);

View file

@ -49,8 +49,6 @@ class ChannelChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -65,8 +65,6 @@ class GlobalTimelineChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -53,8 +53,6 @@ class HashtagChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -86,8 +86,6 @@ class HomeTimelineChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -100,8 +100,6 @@ class HybridTimelineChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -75,8 +75,6 @@ class LocalTimelineChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -39,7 +39,6 @@ class MainChannel extends Channel {
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true,
});
this.connection.cacheNote(note);
data.body.note = note;
}
break;
@ -52,7 +51,6 @@ class MainChannel extends Channel {
const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true,
});
this.connection.cacheNote(note);
data.body = note;
}
break;

View file

@ -118,8 +118,6 @@ class UserListChannel extends Channel {
}
}
this.connection.cacheNote(note);
this.send('note', note);
}

View file

@ -190,7 +190,8 @@ export async function uploadFile(
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 buffer = await readFile(join(__dirname, path));
const blob = new Blob([new Uint8Array(buffer)]);
const body = new FormData();
body.append('i', user.i);

View file

@ -40,6 +40,7 @@ describe('NoteCreateService', () => {
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
pageCount: 0,
reactions: {},
visibility: 'public',
uri: null,

View file

@ -23,6 +23,7 @@ const base: MiNote = {
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
pageCount: 0,
reactions: {},
visibility: 'public',
uri: null,

View file

@ -0,0 +1,652 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import ms from 'ms';
import {
type MiNote,
type MiUser,
type NotesRepository,
type NoteFavoritesRepository,
type UserNotePiningsRepository,
type UsersRepository,
type UserProfilesRepository,
MiMeta,
} from '@/models/_.js';
import { CleanRemoteNotesProcessorService } from '@/queue/processors/CleanRemoteNotesProcessorService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
describe('CleanRemoteNotesProcessorService', () => {
let app: TestingModule;
let service: CleanRemoteNotesProcessorService;
let idService: IdService;
let notesRepository: NotesRepository;
let noteFavoritesRepository: NoteFavoritesRepository;
let userNotePiningsRepository: UserNotePiningsRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
// Local user
let alice: MiUser;
// Remote user 1
let bob: MiUser;
// Remote user 2
let carol: MiUser;
const meta = new MiMeta();
// Mock job object
const createMockJob = () => ({
log: jest.fn(),
updateProgress: jest.fn(),
});
async function createUser(data: Partial<MiUser> = {}) {
const id = idService.gen();
const un = data.username || secureRndstr(16);
const user = await usersRepository
.insert({
id,
username: un,
usernameLower: un.toLowerCase(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
await userProfilesRepository.save({
userId: id,
});
return user;
}
async function createNote(data: Partial<MiNote>, user: MiUser, time?: number): Promise<MiNote> {
const id = idService.gen(time);
const note = await notesRepository
.insert({
id: id,
text: `note_${id}`,
userId: user.id,
userHost: user.host,
visibility: 'public',
...data,
})
.then(x => notesRepository.findOneByOrFail(x.identifiers[0]));
return note;
}
beforeAll(async () => {
app = await Test
.createTestingModule({
imports: [
GlobalModule,
],
providers: [
CleanRemoteNotesProcessorService,
IdService,
{
provide: QueueLoggerService,
useFactory: () => ({
logger: {
createSubLogger: () => ({
info: jest.fn(),
warn: jest.fn(),
succ: jest.fn(),
}),
},
}),
},
],
})
.overrideProvider(DI.meta).useFactory({ factory: () => meta })
.compile();
service = app.get(CleanRemoteNotesProcessorService);
idService = app.get(IdService);
notesRepository = app.get(DI.notesRepository);
noteFavoritesRepository = app.get(DI.noteFavoritesRepository);
userNotePiningsRepository = app.get(DI.userNotePiningsRepository);
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
alice = await createUser({ username: 'alice', host: null });
bob = await createUser({ username: 'bob', host: 'remote1.example.com' });
carol = await createUser({ username: 'carol', host: 'remote2.example.com' });
app.enableShutdownHooks();
});
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Set default meta values
meta.enableRemoteNotesCleaning = true;
meta.remoteNotesCleaningMaxProcessingDurationInMinutes = 0.3;
meta.remoteNotesCleaningExpiryDaysForEachNotes = 30;
}, 60 * 1000);
afterEach(async () => {
// Clean up test data
await Promise.all([
notesRepository.createQueryBuilder().delete().execute(),
userNotePiningsRepository.createQueryBuilder().delete().execute(),
noteFavoritesRepository.createQueryBuilder().delete().execute(),
]);
}, 60 * 1000);
afterAll(async () => {
await app.close();
});
describe('basic', () => {
test('should skip cleaning when enableRemoteNotesCleaning is false', async () => {
meta.enableRemoteNotesCleaning = false;
const job = createMockJob();
const result = await service.process(job as any);
expect(result).toEqual({
deletedCount: 0,
oldest: null,
newest: null,
skipped: true,
transientErrors: 0,
});
});
test('should return success result when enableRemoteNotesCleaning is true and no notes to clean', async () => {
const job = createMockJob();
await createNote({}, alice);
const result = await service.process(job as any);
expect(result).toEqual({
deletedCount: 0,
oldest: null,
newest: null,
skipped: false,
transientErrors: 0,
});
}, 3000);
test('should clean remote notes and return stats', async () => {
// Remote notes
const remoteNotes = await Promise.all([
createNote({}, bob),
createNote({}, carol),
createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
createNote({}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000), // Note older than expiry
]);
// Local notes
const localNotes = await Promise.all([
createNote({}, alice),
createNote({}, alice, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000),
]);
const job = createMockJob();
const result = await service.process(job as any);
expect(result).toEqual({
deletedCount: 2,
oldest: expect.any(Number),
newest: expect.any(Number),
skipped: false,
transientErrors: 0,
});
// Check side-by-side from all notes
const remainingNotes = await notesRepository.find();
expect(remainingNotes.length).toBe(4);
expect(remainingNotes.some(n => n.id === remoteNotes[0].id)).toBe(true);
expect(remainingNotes.some(n => n.id === remoteNotes[1].id)).toBe(true);
expect(remainingNotes.some(n => n.id === remoteNotes[2].id)).toBe(false);
expect(remainingNotes.some(n => n.id === remoteNotes[3].id)).toBe(false);
expect(remainingNotes.some(n => n.id === localNotes[0].id)).toBe(true);
expect(remainingNotes.some(n => n.id === localNotes[1].id)).toBe(true);
});
});
describe('advanced', () => {
// お気に入り
test('should not delete note that is favorited by any user', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Favorite the note
await noteFavoritesRepository.save({
id: idService.gen(),
userId: alice.id,
noteId: olderRemoteNote.id,
});
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
expect(remainingNote).not.toBeNull();
});
// ピン留め
test('should not delete note that is pinned by the user', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Pin the note by the user who created it
await userNotePiningsRepository.save({
id: idService.gen(),
userId: bob.id, // Same user as the note creator
noteId: olderRemoteNote.id,
});
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
const remainingNote = await notesRepository.findOneBy({ id: olderRemoteNote.id });
expect(remainingNote).not.toBeNull();
});
// クリップ
test('should not delete note that is clipped', async () => {
const job = createMockJob();
// Create old remote note that is clipped
const clippedNote = await createNote({
clippedCount: 1, // Clipped
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
expect(remainingNote).not.toBeNull();
});
// ページ
test('should not delete note that is embedded in a page', async () => {
const job = createMockJob();
// Create old remote note that is embedded in a page
const clippedNote = await createNote({
pageCount: 1, // Embedded in a page
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
const remainingNote = await notesRepository.findOneBy({ id: clippedNote.id });
expect(remainingNote).not.toBeNull();
});
// 古いreply, renoteが含まれている時の挙動
test('should handle reply/renote relationships correctly', async () => {
const job = createMockJob();
// Create old remote notes with reply/renote relationships
const originalNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
const replyNote = await createNote({
replyId: originalNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
const renoteNote = await createNote({
renoteId: originalNote.id,
}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 3000);
const result = await service.process(job as any);
// Should delete all three notes as they are all old and remote
expect(result.deletedCount).toBe(3);
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === originalNote.id)).toBe(false);
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(false);
expect(remainingNotes.some(n => n.id === renoteNote.id)).toBe(false);
});
// 古いリモートノートに新しいリプライがある時、どちらも削除されない
test('should not delete both old remote note with new reply', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Create a reply note that is newer than the expiry period
const recentReplyNote = await createNote({
replyId: oldNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
});
// 古いリモートノートに新しいリプライと古いリプライがある時、全て残る
test('should not delete old remote note with new reply and old reply', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const oldNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Create a reply note that is newer than the expiry period
const recentReplyNote = await createNote({
replyId: oldNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) + 1000);
// Create an old reply note that should be deleted
const oldReplyNote = await createNote({
replyId: oldNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === oldNote.id)).toBe(true);
expect(remainingNotes.some(n => n.id === recentReplyNote.id)).toBe(true); // Recent reply note should remain
expect(remainingNotes.some(n => n.id === oldReplyNote.id)).toBe(true); // Old reply note should be deleted
});
// リプライがお気に入りされているとき、どちらも削除されない
test('should not delete reply note that is favorited', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Create a reply note that is newer than the expiry period
const replyNote = await createNote({
replyId: olderRemoteNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
// Favorite the reply note
await noteFavoritesRepository.save({
id: idService.gen(),
userId: alice.id,
noteId: replyNote.id,
});
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Recent reply note should remain
});
// リプライがピン留めされているとき、どちらも削除されない
test('should not delete reply note that is pinned', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Create a reply note that is newer than the expiry period
const replyNote = await createNote({
replyId: olderRemoteNote.id,
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
// Pin the reply note
await userNotePiningsRepository.save({
id: idService.gen(),
userId: carol.id,
noteId: replyNote.id,
});
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0); // Only the old note should be deleted
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true); // Reply note should remain
});
// リプライがクリップされているとき、どちらも削除されない
test('should not delete reply note that is clipped', async () => {
const job = createMockJob();
// Create old remote note that should be deleted
const olderRemoteNote = await createNote({}, bob, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000);
// Create a reply note that is old but clipped
const replyNote = await createNote({
replyId: olderRemoteNote.id,
clippedCount: 1, // Clipped
}, carol, Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 2000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0); // Both notes should be kept because reply is clipped
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.some(n => n.id === olderRemoteNote.id)).toBe(true);
expect(remainingNotes.some(n => n.id === replyNote.id)).toBe(true);
});
test('should handle mixed scenarios with multiple conditions', async () => {
const job = createMockJob();
// Create various types of notes
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
// Should be deleted: old remote note with no special conditions
const deletableNote = await createNote({}, bob, oldTime);
// Should NOT be deleted: old remote note but favorited
const favoritedNote = await createNote({}, carol, oldTime);
await noteFavoritesRepository.save({
id: idService.gen(),
userId: alice.id,
noteId: favoritedNote.id,
});
// Should NOT be deleted: old remote note but pinned
const pinnedNote = await createNote({}, bob, oldTime);
await userNotePiningsRepository.save({
id: idService.gen(),
userId: bob.id,
noteId: pinnedNote.id,
});
// Should NOT be deleted: old remote note but clipped
const clippedNote = await createNote({
clippedCount: 2,
}, carol, oldTime);
// Should NOT be deleted: old local note
const localNote = await createNote({}, alice, oldTime);
// Should NOT be deleted: new remote note
const newerRemoteNote = await createNote({}, bob);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(1); // Only deletableNote should be deleted
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.length).toBe(5);
expect(remainingNotes.some(n => n.id === deletableNote.id)).toBe(false); // Deleted
expect(remainingNotes.some(n => n.id === favoritedNote.id)).toBe(true); // Kept
expect(remainingNotes.some(n => n.id === pinnedNote.id)).toBe(true); // Kept
expect(remainingNotes.some(n => n.id === clippedNote.id)).toBe(true); // Kept
expect(remainingNotes.some(n => n.id === localNote.id)).toBe(true); // Kept
expect(remainingNotes.some(n => n.id === newerRemoteNote.id)).toBe(true); // Kept
});
// 大量のノート
test('should handle large number of notes correctly', async () => {
const AMOUNT = 130;
const job = createMockJob();
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
const noteIds = [];
for (let i = 0; i < AMOUNT; i++) {
const note = await createNote({}, bob, oldTime - i);
noteIds.push(note.id);
}
const result = await service.process(job as any);
// Should delete all notes, but may require multiple batches
expect(result.deletedCount).toBe(AMOUNT);
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.length).toBe(0);
});
// 大量のノート + リプライ or リノート
test('should handle large number of notes with replies correctly', async () => {
const AMOUNT = 130;
const job = createMockJob();
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
const noteIds = [];
for (let i = 0; i < AMOUNT; i++) {
const note = await createNote({}, bob, oldTime - i - AMOUNT);
noteIds.push(note.id);
if (i % 2 === 0) {
// Create a reply for every second note
await createNote({ replyId: note.id }, carol, oldTime - i);
} else {
// Create a renote for every second note
await createNote({ renoteId: note.id }, bob, oldTime - i);
}
}
const result = await service.process(job as any);
// Should delete all notes, but may require multiple batches
expect(result.deletedCount).toBe(AMOUNT * 2);
expect(result.skipped).toBe(false);
});
// 大量の古いノート + 新しいリプライ or リノート
test('should handle large number of old notes with new replies correctly', async () => {
const AMOUNT = 130;
const job = createMockJob();
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
const newTime = Date.now();
const noteIds = [];
for (let i = 0; i < AMOUNT; i++) {
const note = await createNote({}, bob, oldTime - i);
noteIds.push(note.id);
if (i % 2 === 0) {
// Create a reply for every second note
await createNote({ replyId: note.id }, carol, newTime + i);
} else {
// Create a renote for every second note
await createNote({ renoteId: note.id }, bob, newTime + i);
}
}
const result = await service.process(job as any);
expect(result.deletedCount).toBe(0);
expect(result.skipped).toBe(false);
});
// 大量の残す対象(clippedCount: 1)と大量の削除対象
test('should handle large number of notes, mixed conditions with clippedCount', async () => {
const AMOUNT_BASE = 70;
const job = createMockJob();
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
const noteIds = [];
for (let i = 0; i < AMOUNT_BASE; i++) {
const note = await createNote({ clippedCount: 1 }, bob, oldTime - i - AMOUNT_BASE);
noteIds.push(note.id);
}
for (let i = 0; i < AMOUNT_BASE; i++) {
const note = await createNote({}, carol, oldTime - i);
noteIds.push(note.id);
}
const result = await service.process(job as any);
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming half are deletable
expect(result.skipped).toBe(false);
});
// 大量の残す対象(リプライ)と大量の削除対象
test('should handle large number of notes, mixed conditions with replies', async () => {
const AMOUNT_BASE = 70;
const job = createMockJob();
const oldTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 1000;
const newTime = Date.now();
for (let i = 0; i < AMOUNT_BASE; i++) {
// should remain
const note = await createNote({}, carol, oldTime - AMOUNT_BASE - i);
// should remain
await createNote({ replyId: note.id }, bob, newTime + i);
}
const noteIdsExpectedToBeDeleted = [];
for (let i = 0; i < AMOUNT_BASE; i++) {
// should be deleted
const note = await createNote({}, bob, oldTime - i);
noteIdsExpectedToBeDeleted.push(note.id);
}
const result = await service.process(job as any);
expect(result.deletedCount).toBe(AMOUNT_BASE); // Assuming all replies are deletable
expect(result.skipped).toBe(false);
const remainingNotes = await notesRepository.find();
expect(remainingNotes.length).toBe(AMOUNT_BASE * 2); // Only replies should remain
noteIdsExpectedToBeDeleted.forEach(id => {
expect(remainingNotes.some(n => n.id === id)).toBe(false); // All original notes should be deleted
});
});
test('should update cursor correctly during batch processing', async () => {
const job = createMockJob();
// Create notes with specific timing to test cursor behavior
const baseTime = Date.now() - ms(`${meta.remoteNotesCleaningExpiryDaysForEachNotes} days`) - 10000;
const note1 = await createNote({}, bob, baseTime);
const note2 = await createNote({}, carol, baseTime - 1000);
const note3 = await createNote({}, bob, baseTime - 2000);
const result = await service.process(job as any);
expect(result.deletedCount).toBe(3);
expect(result.newest).toBe(idService.parse(note1.id).date.getTime());
expect(result.oldest).toBe(idService.parse(note3.id).date.getTime());
expect(result.skipped).toBe(false);
});
});
});

View file

@ -317,7 +317,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
const formData = new FormData();
formData.append('file', blob ??
new File([await readFile(absPath)], basename(absPath.toString())));
new File([new Uint8Array(await readFile(absPath))], basename(absPath.toString())));
formData.append('force', 'true');
if (name) {
formData.append('name', name);
@ -608,8 +608,8 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
username: config.db.user,
password: config.db.pass,
database: config.db.db,
synchronize: true && !justBorrow,
dropSchema: true && !justBorrow,
synchronize: !justBorrow,
dropSchema: !justBorrow,
entities: initEntities ?? entities,
});
@ -661,7 +661,9 @@ export async function captureWebhook<T = SystemWebhookPayload>(postAction: () =>
let timeoutHandle: NodeJS.Timeout | null = null;
const result = await new Promise<string>(async (resolve, reject) => {
fastify.all('/', async (req, res) => {
timeoutHandle && clearTimeout(timeoutHandle);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
const body = JSON.stringify(req.body);
res.status(200).send('ok');

View file

@ -69,8 +69,10 @@ export class LocaleInliner {
async saveAllLocales(locales: Record<string, Locale>) {
const localeNames = Object.keys(locales);
for (const localeName of localeNames) {
this.logger.info(`Creating bundle for ${localeName}`);
await this.saveLocale(localeName, locales[localeName]);
}
this.logger.info('Done');
}
async saveLocale(localeName: string, localeJson: Locale) {

View file

@ -585,6 +585,14 @@ defineExpose({
grid-template-columns: var(--columns);
font-size: 30px;
> .config {
aspect-ratio: 1 / 1;
width: auto;
height: auto;
min-width: 0;
font-size: 14px;
}
> .item {
aspect-ratio: 1 / 1;
width: auto;

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div v-if="form.modified.value" :class="$style.root">
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
<div style="margin-left: auto;" class="_buttons">
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
@ -16,16 +16,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import MkButton from './MkButton.vue';
import type { useForm } from '@/composables/use-form.js';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
form: {
modifiedCount: {
value: number;
};
discard: () => void;
save: () => void;
};
form: ReturnType<typeof useForm>;
canSaving?: boolean;
}>(), {
canSaving: true,

View file

@ -14,73 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<div :class="$style.root" class="_gaps">
<div v-for="[k, v] in Object.entries(fx.params)" :key="k">
<MkSwitch
v-if="v.type === 'boolean'"
v-model="layer.params[k]"
>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkSwitch>
<MkRange
v-else-if="v.type === 'number'"
v-model="layer.params[k]"
continuousUpdate
:min="v.min"
:max="v.max"
:step="v.step"
:textConverter="fx.params[k].toViewValue"
@thumbDoubleClicked="() => {
if (fx.params[k].default != null) {
layer.params[k] = fx.params[k].default;
} else {
layer.params[k] = v.min;
}
}"
>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkRange>
<MkRadios
v-else-if="v.type === 'number:enum'"
v-model="layer.params[k]"
>
<template #label>{{ fx.params[k].label ?? k }}</template>
<option v-for="item in v.enum" :value="item.value">{{ item.label }}</option>
</MkRadios>
<div v-else-if="v.type === 'seed'">
<MkRange
v-model="layer.params[k]"
continuousUpdate
type="number"
:min="0"
:max="10000"
:step="1"
>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkRange>
</div>
<MkInput
v-else-if="v.type === 'color'"
:modelValue="getHex(layer.params[k])"
type="color"
@update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }"
>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkInput>
</div>
</div>
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
</MkFolder>
</template>
<script setup lang="ts">
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
import { FXS } from '@/utility/image-effector/fxs.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
@ -94,28 +36,4 @@ const emit = defineEmits<{
(e: 'swapUp'): void;
(e: 'swapDown'): void;
}>();
function getHex(c: [number, number, number]) {
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
}
function getRgb(hex: string | number): [number, number, number] | null {
if (
typeof hex === 'number' ||
typeof hex !== 'string' ||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
) {
return null;
}
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
if (m == null) return [0, 0, 0];
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
}
</script>
<style module>
.root {
}
</style>

View file

@ -0,0 +1,95 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<div v-for="v, k in paramDefs" :key="k">
<MkSwitch
v-if="v.type === 'boolean'"
v-model="params[k]">
<template #label>{{ v.label ?? k }}</template>
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
</MkSwitch>
<MkRange
v-else-if="v.type === 'number'"
v-model="params[k]"
continuousUpdate
:min="v.min"
:max="v.max"
:step="v.step"
:textConverter="v.toViewValue"
@thumbDoubleClicked="() => {
params[k] = v.default;
}"
>
<template #label>{{ v.label ?? k }}</template>
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
</MkRange>
<MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]">
<template #label>{{ v.label ?? k }}</template>
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
<option v-for="item in v.enum" :value="item.value">
<i v-if="item.icon" :class="item.icon"></i>
<template v-else>{{ item.label }}</template>
</option>
</MkRadios>
<div v-else-if="v.type === 'seed'">
<MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1">
<template #label>{{ v.label ?? k }}</template>
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
</MkRange>
</div>
<MkInput v-else-if="v.type === 'color'" :modelValue="getHex(params[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params[k] = c; }">
<template #label>{{ v.label ?? k }}</template>
<template v-if="v.caption != null" #caption>{{ v.caption }}</template>
</MkInput>
</div>
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
{{ i18n.ts._imageEffector.nothingToConfigure }}
</div>
</div>
</template>
<script setup lang="ts">
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import { i18n } from '@/i18n.js';
import type { ImageEffectorRGB, ImageEffectorFxParamDefs } from '@/utility/image-effector/ImageEffector.js';
defineProps<{
paramDefs: ImageEffectorFxParamDefs;
}>();
const params = defineModel<Record<string, any>>({ required: true });
function getHex(c: ImageEffectorRGB) {
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
}
function getRgb(hex: string | number): ImageEffectorRGB | null {
if (
typeof hex === 'number' ||
typeof hex !== 'string' ||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
) {
return null;
}
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
if (m == null) return [0, 0, 0];
return m.map(x => parseInt(x, 16) / 255) as ImageEffectorRGB;
}
</script>
<style module>
.nothingToConfigure {
opacity: 0.7;
text-align: center;
font-size: 14px;
padding: 0 10px;
}
</style>

View file

@ -44,6 +44,11 @@ const y = defineModel<string>('y', { default: 'center' });
height: 32px;
background: var(--MI_THEME-panel);
border-radius: 4px;
transition: background 0.1s ease;
&:not(.active):hover {
background: var(--MI_THEME-buttonHoverBg);
}
&.active {
background: var(--MI_THEME-accentedBg);

View file

@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/>
</button>

View file

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</div>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/>
</button>

View file

@ -62,6 +62,8 @@ export type Column = {
withSensitive?: boolean;
onlyFiles?: boolean;
soundSetting?: SoundStore;
// The cache for the name of the antenna, channel, list, or role
timelineNameCache?: string;
};
const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']);

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { throttle } from 'throttle-debounce';
import type { Directive } from 'vue';
export default {
@ -10,12 +11,14 @@ export default {
const fn = binding.value;
if (fn == null) return;
const observer = new IntersectionObserver(entries => {
const check = throttle(1000, (entries) => {
if (entries.some(entry => entry.isIntersecting)) {
fn();
}
});
const observer = new IntersectionObserver(check);
observer.observe(src);
src._observer_ = observer;

View file

@ -111,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
</div>
<div>
<a style="display: inline-block;" class="sads-llc" title="合同会社サッズ" href="https://sads-llc.co.jp/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/sads-llc.png" alt="合同会社サッズ"></a>
</div>
</div>
</FormSection>
<FormSection>
@ -286,6 +289,9 @@ const patronsWithIcon = [{
}, {
name: '井上千二十四',
icon: 'https://assets.misskey-hub.net/patrons/193afa1f039b4c339866039c3dcd74bf.jpg',
}, {
name: 'NigN',
icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg',
}];
const patrons = [
@ -399,6 +405,8 @@ const patrons = [
'みりめい',
'東雲 琥珀',
'ほとラズ',
'スズカケン',
'蒼井よみこ',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template #footer>
<template v-if="botProtectionForm.modified.value" #footer>
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
</template>

View file

@ -346,6 +346,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])">
<template #label>{{ i18n.ts._role._options.canSearchUsers }}</template>
<template #suffix>
<span v-if="role.policies.canSearchUsers.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canSearchUsers.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canSearchUsers)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canSearchUsers.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canSearchUsers.value" :disabled="role.policies.canSearchUsers.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canSearchUsers.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
<template #suffix>

View file

@ -122,6 +122,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchUsers, 'canSearchUsers'])">
<template #label>{{ i18n.ts._role._options.canSearchUsers }}</template>
<template #suffix>{{ policies.canSearchUsers ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canSearchUsers">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canUseTranslator'])">
<template #label>{{ i18n.ts._role._options.canUseTranslator }}</template>
<template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -112,7 +112,7 @@ const favorited = ref(false);
const searchQuery = ref('');
const searchPaginator = shallowRef();
const searchKey = ref('');
const featuredPaginator = markRaw(new Paginator('channels/featured', {
const featuredPaginator = markRaw(new Paginator('notes/featured', {
limit: 10,
computedParams: computed(() => ({
channelId: props.channelId,

View file

@ -15,16 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'user'" class="_spacer" style="--MI_SPACER-w: 800px;">
<XUser v-bind="props"/>
<div v-if="usersSearchAvailable">
<XUser v-bind="props"/>
</div>
<div v-else>
<MkInfo warn>{{ i18n.ts.usersSearchNotAvailable }}</MkInfo>
</div>
</div>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, toRef } from 'vue';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js';
import { notesSearchAvailable, usersSearchAvailable } from '@/utility/check-permissions.js';
import MkInfo from '@/components/MkInfo.vue';
const props = withDefaults(defineProps<{

View file

@ -128,9 +128,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<hr>
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
<hr>
<hr>
</template>
<FormSlot>
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>

View file

@ -3,14 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, defineAsyncComponent } from 'vue';
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { ref } from 'vue';
import { compareVersions } from 'compare-versions';
import { isSafeMode } from '@@/js/config.js';
import * as Misskey from 'misskey-js';
import type { Parser, Interpreter, values } from '@syuilo/aiscript';
import type { FormWithDefault } from '@/utility/form.js';
import { genId } from '@/utility/id.js';
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { store } from '@/store.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -39,7 +38,13 @@ export type AiScriptPluginMeta = {
config?: Record<string, any>;
};
const parser = new Parser();
let _parser: Parser | null = null;
async function getParser(): Promise<Parser> {
const { Parser } = await import('@syuilo/aiscript');
_parser ??= new Parser();
return _parser;
}
export function isSupportedAiScriptVersion(version: string): boolean {
try {
@ -54,6 +59,8 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta>
throw new Error('code is required');
}
const { Interpreter, utils } = await import('@syuilo/aiscript');
const lv = utils.getLangVersion(code);
if (lv == null) {
throw new Error('No language version annotation found');
@ -63,6 +70,7 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta>
let ast;
try {
const parser = await getParser();
ast = parser.parse(code);
} catch (err) {
throw new Error('Aiscript syntax error');
@ -255,7 +263,10 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> {
await authorizePlugin(plugin);
const aiscript = new Interpreter(createPluginEnv({
const { Interpreter, utils } = await import('@syuilo/aiscript');
const { aiScriptReadline } = await import('@/aiscript/api.js');
const aiscript = new Interpreter(await createPluginEnv({
plugin: plugin,
storageKey: 'plugins:' + plugin.installId,
}), {
@ -280,6 +291,7 @@ async function launchPlugin(id: Plugin['installId']): Promise<void> {
pluginContexts.set(plugin.installId, aiscript);
const parser = await getParser();
aiscript.exec(parser.parse(plugin.src)).then(
() => {
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
@ -336,9 +348,12 @@ export function changePluginActive(plugin: Plugin, active: boolean) {
}
}
function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
async function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Promise<Record<string, values.Value>> {
const id = opts.plugin.installId;
const { utils, values } = await import('@syuilo/aiscript');
const { createAiScriptEnv } = await import('@/aiscript/api.js');
const config = new Map<string, values.Value>();
for (const [k, v] of Object.entries(opts.plugin.config ?? {})) {
config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
<template #header>
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span>
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.antenna }}</span>
</template>
<MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
@ -35,18 +35,13 @@ const props = defineProps<{
const timeline = useTemplateRef('timeline');
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
const antennaName = ref<string | null>(null);
onMounted(() => {
if (props.column.antennaId == null) {
setAntenna();
}
});
watch([() => props.column.name, () => props.column.antennaId], () => {
if (!props.column.name && props.column.antennaId) {
} else if (props.column.timelineNameCache == null) {
misskeyApi('antennas/show', { antennaId: props.column.antennaId })
.then(value => antennaName.value = value.name);
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
}
});
@ -77,6 +72,7 @@ async function setAntenna() {
antennasCache.delete();
updateColumn(props.column.id, {
antennaId: newAntenna.id,
timelineNameCache: newAntenna.name,
});
},
closed: () => {
@ -88,6 +84,7 @@ async function setAntenna() {
updateColumn(props.column.id, {
antennaId: antenna.id,
timelineNameCache: antenna.name,
});
}

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
<template #header>
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || channel?.name || i18n.ts._deck._columns.channel }}</span>
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.channel }}</span>
</template>
<template v-if="column.channelId">
@ -46,13 +46,9 @@ const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null,
onMounted(() => {
if (props.column.channelId == null) {
setChannel();
}
});
watch([() => props.column.name, () => props.column.channelId], () => {
if (!props.column.name && props.column.channelId) {
} else if (!props.column.name && props.column.channelId) {
misskeyApi('channels/show', { channelId: props.column.channelId })
.then(value => channel.value = value);
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
}
});
@ -72,7 +68,7 @@ async function setChannel() {
if (canceled || chosenChannel == null) return;
updateColumn(props.column.id, {
channelId: chosenChannel.id,
name: chosenChannel.name,
timelineNameCache: chosenChannel.name,
});
}

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
<template #header>
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span>
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.list }}</span>
</template>
<MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
@ -36,18 +36,13 @@ const props = defineProps<{
const timeline = useTemplateRef('timeline');
const withRenotes = ref(props.column.withRenotes ?? true);
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
const listName = ref<string | null>(null);
onMounted(() => {
if (props.column.listId == null) {
setList();
}
});
watch([() => props.column.name, () => props.column.listId], () => {
if (!props.column.name && props.column.listId) {
} else if (props.column.timelineNameCache == null) {
misskeyApi('users/lists/show', { listId: props.column.listId })
.then(value => listName.value = value.name);
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
}
});
@ -89,10 +84,12 @@ async function setList() {
updateColumn(props.column.id, {
listId: res.id,
timelineNameCache: res.name,
});
} else {
updateColumn(props.column.id, {
listId: list.id,
timelineNameCache: list.name,
});
}
}

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
<template #header>
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span>
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || column.timelineNameCache || i18n.ts._deck._columns.roleTimeline }}</span>
</template>
<MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
@ -33,18 +33,13 @@ const props = defineProps<{
const timeline = useTemplateRef('timeline');
const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
const roleName = ref<string | null>(null);
onMounted(() => {
if (props.column.roleId == null) {
setRole();
}
});
watch([() => props.column.name, () => props.column.roleId], () => {
if (!props.column.name && props.column.roleId) {
} else if (props.column.timelineNameCache == null) {
misskeyApi('roles/show', { roleId: props.column.roleId })
.then(value => roleName.value = value.name);
.then(value => updateColumn(props.column.id, { timelineNameCache: value.name }));
}
});
@ -64,6 +59,7 @@ async function setRole() {
if (canceled || role == null) return;
updateColumn(props.column.id, {
roleId: role.id,
timelineNameCache: role.name,
});
}

View file

@ -78,7 +78,10 @@ export class Autocomplete {
const caretPos = Number(this.textarea.selectionStart);
const text = this.text.substring(0, caretPos).split('\n').pop()!;
const mentionIndex = text.lastIndexOf('@');
// メンションに含められる文字のみで構成された、最も末尾にある文字列を抽出
const mentionCandidate = text.split(/[^a-zA-Z0-9_@.\-]+/).pop()!;
const mentionIndex = mentionCandidate.lastIndexOf('@');
const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':');
const mfmTagIndex = text.lastIndexOf('$');
@ -97,7 +100,7 @@ export class Autocomplete {
const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop();
const isMention = mentionIndex !== -1;
const maybeMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
@ -107,20 +110,27 @@ export class Autocomplete {
let opened = false;
if (isMention && this.onlyType.includes('user')) {
if (maybeMention && this.onlyType.includes('user')) {
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
const username = mentionIndexAlt === -1
? text.substring(mentionIndex + 1)
: text.substring(mentionIndexAlt + 1);
if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
this.open('user', username);
opened = true;
} else if (username === '') {
this.open('user', null);
opened = true;
const mentionIndexAlt = mentionCandidate.lastIndexOf('@', mentionIndex - 1);
// @が連続している場合、1つ目を無視する
const mentionIndexLeft = (mentionIndexAlt !== -1 && mentionIndexAlt !== mentionIndex - 1) ? mentionIndexAlt : mentionIndex;
// メンションを構成する条件を満たしているか確認する
const isMention = mentionIndexLeft === 0 || '_@.-'.includes(mentionCandidate[mentionIndexLeft - 1]);
if (isMention) {
const username = mentionCandidate.substring(mentionIndexLeft + 1);
if (username !== '' && username.match(/^[a-zA-Z0-9_@.\-]+$/)) {
this.open('user', username);
opened = true;
} else if (username === '') {
this.open('user', null);
opened = true;
}
}
}

View file

@ -17,3 +17,11 @@ export const notesSearchAvailable = (
export const canSearchNonLocalNotes = (
instance.noteSearchableScope === 'global'
);
export const usersSearchAvailable = (
// FIXME: instance.policies would be null in Vitest
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
($i == null && instance.policies != null && instance.policies.canSearchUsers) ||
($i != null && $i.policies.canSearchUsers) ||
false
);

View file

@ -6,22 +6,78 @@
import { getProxiedImageUrl } from '../media-proxy.js';
import { initShaderProgram } from '../webgl.js';
export type ImageEffectorRGB = [r: number, g: number, b: number];
type ParamTypeToPrimitive = {
'number': number;
'number:enum': number;
'boolean': boolean;
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
'seed': number;
'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
'color': [r: number, g: number, b: number];
[K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default'];
};
type ImageEffectorFxParamDefs = Record<string, {
type: keyof ParamTypeToPrimitive;
default: any;
interface CommonParamDef {
type: string;
label?: string;
toViewValue?: (v: any) => string;
}>;
caption?: string;
default: any;
}
interface NumberParamDef extends CommonParamDef {
type: 'number';
default: number;
min: number;
max: number;
step?: number;
toViewValue?: (v: number) => string;
};
interface NumberEnumParamDef extends CommonParamDef {
type: 'number:enum';
enum: {
value: number;
label?: string;
icon?: string;
}[];
default: number;
};
interface BooleanParamDef extends CommonParamDef {
type: 'boolean';
default: boolean;
};
interface AlignParamDef extends CommonParamDef {
type: 'align';
default: {
x: 'left' | 'center' | 'right';
y: 'top' | 'center' | 'bottom';
};
};
interface SeedParamDef extends CommonParamDef {
type: 'seed';
default: number;
};
interface TextureParamDef extends CommonParamDef {
type: 'texture';
default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
};
interface ColorParamDef extends CommonParamDef {
type: 'color';
default: ImageEffectorRGB;
};
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef;
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
export type GetParamType<T extends ImageEffectorFxParamDef> =
T extends NumberEnumParamDef
? T['enum'][number]['value']
: ParamTypeToPrimitive[T['type']];
export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = {
[K in keyof PS]: GetParamType<PS[K]>;
};
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
return fx;
@ -36,9 +92,7 @@ export type ImageEffectorFx<ID extends string = string, PS extends ImageEffector
main: (ctx: {
gl: WebGL2RenderingContext;
program: WebGLProgram;
params: {
[key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']];
};
params: ParamsRecordTypeToDefRecord<PS>;
u: Record<US[number], WebGLUniformLocation>;
width: number;
height: number;

View file

@ -48,20 +48,22 @@ void main() {
`;
export const FX_blockNoise = defineImageEffectorFx({
id: 'blockNoise' as const,
id: 'blockNoise',
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
shader,
uniforms: ['amount', 'channelShift'] as const,
params: {
amount: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.amount,
type: 'number',
default: 50,
min: 1,
max: 100,
step: 1,
},
strength: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.strength,
type: 'number',
default: 0.05,
min: -1,
max: 1,
@ -69,7 +71,8 @@ export const FX_blockNoise = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
width: {
type: 'number' as const,
label: i18n.ts.width,
type: 'number',
default: 0.05,
min: 0.01,
max: 1,
@ -77,7 +80,8 @@ export const FX_blockNoise = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
height: {
type: 'number' as const,
label: i18n.ts.height,
type: 'number',
default: 0.01,
min: 0.01,
max: 1,
@ -85,7 +89,8 @@ export const FX_blockNoise = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
channelShift: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.glitchChannelShift,
type: 'number',
default: 0,
min: 0,
max: 10,
@ -93,7 +98,8 @@ export const FX_blockNoise = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
seed: {
type: 'seed' as const,
label: i18n.ts._imageEffector._fxProps.seed,
type: 'seed',
default: 100,
},
},

View file

@ -47,13 +47,14 @@ void main() {
`;
export const FX_checker = defineImageEffectorFx({
id: 'checker' as const,
id: 'checker',
name: i18n.ts._imageEffector._fxs.checker,
shader,
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
params: {
angle: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.angle,
type: 'number',
default: 0,
min: -1.0,
max: 1.0,
@ -61,18 +62,21 @@ export const FX_checker = defineImageEffectorFx({
toViewValue: v => Math.round(v * 90) + '°',
},
scale: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.scale,
type: 'number',
default: 3.0,
min: 1.0,
max: 10.0,
step: 0.1,
},
color: {
type: 'color' as const,
label: i18n.ts._imageEffector._fxProps.color,
type: 'color',
default: [1, 1, 1],
},
opacity: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.opacity,
type: 'number',
default: 0.5,
min: 0.0,
max: 1.0,

View file

@ -52,17 +52,19 @@ void main() {
`;
export const FX_chromaticAberration = defineImageEffectorFx({
id: 'chromaticAberration' as const,
id: 'chromaticAberration',
name: i18n.ts._imageEffector._fxs.chromaticAberration,
shader,
uniforms: ['amount', 'start', 'normalize'] as const,
params: {
normalize: {
type: 'boolean' as const,
label: i18n.ts._imageEffector._fxProps.normalize,
type: 'boolean',
default: false,
},
amount: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.amount,
type: 'number',
default: 0.1,
min: 0.0,
max: 1.0,

View file

@ -85,13 +85,14 @@ void main() {
`;
export const FX_colorAdjust = defineImageEffectorFx({
id: 'colorAdjust' as const,
id: 'colorAdjust',
name: i18n.ts._imageEffector._fxs.colorAdjust,
shader,
uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
params: {
lightness: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.lightness,
type: 'number',
default: 0,
min: -1,
max: 1,
@ -99,7 +100,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
contrast: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.contrast,
type: 'number',
default: 1,
min: 0,
max: 4,
@ -107,7 +109,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
hue: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.hue,
type: 'number',
default: 0,
min: -1,
max: 1,
@ -115,7 +118,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
toViewValue: v => Math.round(v * 180) + '°',
},
brightness: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.brightness,
type: 'number',
default: 1,
min: 0,
max: 4,
@ -123,7 +127,8 @@ export const FX_colorAdjust = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
saturation: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.saturation,
type: 'number',
default: 1,
min: 0,
max: 4,

View file

@ -26,13 +26,14 @@ void main() {
`;
export const FX_colorClamp = defineImageEffectorFx({
id: 'colorClamp' as const,
id: 'colorClamp',
name: i18n.ts._imageEffector._fxs.colorClamp,
shader,
uniforms: ['max', 'min'] as const,
params: {
max: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.max,
type: 'number',
default: 1.0,
min: 0.0,
max: 1.0,
@ -40,7 +41,8 @@ export const FX_colorClamp = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
min: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.min,
type: 'number',
default: -1.0,
min: -1.0,
max: 0.0,

View file

@ -30,13 +30,14 @@ void main() {
`;
export const FX_colorClampAdvanced = defineImageEffectorFx({
id: 'colorClampAdvanced' as const,
id: 'colorClampAdvanced',
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
shader,
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
params: {
rMax: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`,
type: 'number',
default: 1.0,
min: 0.0,
max: 1.0,
@ -44,7 +45,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
rMin: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.redComponent})`,
type: 'number',
default: -1.0,
min: -1.0,
max: 0.0,
@ -52,7 +54,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
gMax: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.greenComponent})`,
type: 'number',
default: 1.0,
min: 0.0,
max: 1.0,
@ -60,7 +63,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
gMin: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.greenComponent})`,
type: 'number',
default: -1.0,
min: -1.0,
max: 0.0,
@ -68,7 +72,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
bMax: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.blueComponent})`,
type: 'number',
default: 1.0,
min: 0.0,
max: 1.0,
@ -76,7 +81,8 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
bMin: {
type: 'number' as const,
label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.blueComponent})`,
type: 'number',
default: -1.0,
min: -1.0,
max: 0.0,

View file

@ -34,18 +34,23 @@ void main() {
`;
export const FX_distort = defineImageEffectorFx({
id: 'distort' as const,
id: 'distort',
name: i18n.ts._imageEffector._fxs.distort,
shader,
uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
params: {
direction: {
type: 'number:enum' as const,
enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }],
label: i18n.ts._imageEffector._fxProps.direction,
type: 'number:enum',
enum: [
{ value: 0 as const, label: i18n.ts.horizontal },
{ value: 1 as const, label: i18n.ts.vertical },
],
default: 1,
},
phase: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.phase,
type: 'number',
default: 0.0,
min: -1.0,
max: 1.0,
@ -53,14 +58,16 @@ export const FX_distort = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
frequency: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.frequency,
type: 'number',
default: 30,
min: 0,
max: 100,
step: 0.1,
},
strength: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.strength,
type: 'number',
default: 0.05,
min: 0,
max: 1,

View file

@ -26,7 +26,7 @@ void main() {
`;
export const FX_grayscale = defineImageEffectorFx({
id: 'grayscale' as const,
id: 'grayscale',
name: i18n.ts._imageEffector._fxs.grayscale,
shader,
uniforms: [] as const,

View file

@ -27,21 +27,24 @@ void main() {
`;
export const FX_invert = defineImageEffectorFx({
id: 'invert' as const,
id: 'invert',
name: i18n.ts._imageEffector._fxs.invert,
shader,
uniforms: ['r', 'g', 'b'] as const,
params: {
r: {
type: 'boolean' as const,
label: i18n.ts._imageEffector._fxProps.redComponent,
type: 'boolean',
default: true,
},
g: {
type: 'boolean' as const,
label: i18n.ts._imageEffector._fxProps.greenComponent,
type: 'boolean',
default: true,
},
b: {
type: 'boolean' as const,
label: i18n.ts._imageEffector._fxProps.blueComponent,
type: 'boolean',
default: true,
},
},

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