Merge branch 'develop' into enh-add-note-policies

This commit is contained in:
かっこかり 2025-08-19 20:16:11 +09:00 committed by GitHub
commit d6b7d24394
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 2924 additions and 2045 deletions

View File

@ -25,7 +25,7 @@ jobs:
cp ./compose_example.yml ./compose.yml cp ./compose_example.yml ./compose.yml
- run: | - run: |
docker compose up -d web 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: | - run: |
cmd="dockle --exit-code 1 misskey-web:latest ${image_name}" cmd="dockle --exit-code 1 misskey-web:latest ${image_name}"
echo "> ${cmd}" echo "> ${cmd}"

View File

@ -11,8 +11,11 @@
- データベースの肥大化を防止することが可能です - データベースの肥大化を防止することが可能です
- 既存のサーバーで当機能を有効化した場合は、処理量が多くなるため、一時的にストレージ使用量が増加する可能性があります。 - 既存のサーバーで当機能を有効化した場合は、処理量が多くなるため、一時的にストレージ使用量が増加する可能性があります。
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。 - 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
- データベースサイズへの効果が見られない場合はautovacuumが有効になっているか確認してください
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました - サーバーの初期設定が完了するまでは連合がオンにならないようになりました
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました - 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
- 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました
- 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました
- mfm.jsをアップデートしました - mfm.jsをアップデートしました
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応 - Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
- Enhance: acctに `.` が入っているユーザーのメンションに対応 - Enhance: acctに `.` が入っているユーザーのメンションに対応
@ -26,9 +29,9 @@
- Enhance: ユーザー検索をロールポリシーで制限できるように - Enhance: ユーザー検索をロールポリシーで制限できるように
### Client ### Client
- Feat: AiScriptが1.0に更新されました - Feat: AiScriptが1.1.0に更新されました
- プラグインは1.0に対応したものが必要です - プラグインは1.xに対応したものが必要です
- Playはそのまま動作しますが、新規に作られるプリセットは1.0になります - Playはそのまま動作しますが、新規に作られるプリセットは1.xになります
- 以前のバージョンから無効化されていた note_view_interruptor が有効になりました - 以前のバージョンから無効化されていた note_view_interruptor が有効になりました
- Feat: セーフモード - Feat: セーフモード
- プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます - プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます
@ -36,18 +39,25 @@
- `g` キーを連打する - `g` キーを連打する
- URLに`?safemode=true`を付ける - URLに`?safemode=true`を付ける
- PWAのショートカットで Safemode を選択して起動する - PWAのショートカットで Safemode を選択して起動する
- Feat: 非ログイン時に表示されるトップページのスタイルを選択できるように
- コントロールパネル→ブランディング→エントランスページのスタイル
- Feat: ページのタブバーを下部に表示できるように - Feat: ページのタブバーを下部に表示できるように
- Feat: (実験的)iOSでの触覚フィードバックを有効にできるように
- Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました
- Enhance: コントロールパネルを検索できるように - Enhance: コントロールパネルを検索できるように
- Enhance: トルコ語 (tr-TR) に対応 - Enhance: トルコ語 (tr-TR) に対応
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました - Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
- Enhance: 画像エフェクトのパラメータ名の多言語対応 - Enhance: 画像エフェクトのパラメータ名の多言語対応
- Enhance: 依存ソフトウェアの更新 - Enhance: 依存ソフトウェアの更新
- Enhance: ートを非表示にする相対期間を1ヶ月単位で自由に指定できるように
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正 - Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正 - Fix: 一部の設定検索結果が存在しないパスになる問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171) (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
- Fix: テーマエディタが動作しない問題を修正 - Fix: テーマエディタが動作しない問題を修正
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正 - Fix: チャンネルのハイライトページにノートが表示されない問題を修正
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正 - Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
- Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正
- Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正
### Server ### Server
- Enhance: ノートの削除処理の効率化 - Enhance: ノートの削除処理の効率化

View File

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

View File

@ -1357,3 +1357,10 @@ _watermarkEditor:
text: "লেখা" text: "লেখা"
image: "ছবি" image: "ছবি"
advanced: "উন্নত" 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" 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." 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." notesSearchNotAvailable: "La cerca de notes no es troba disponible."
usersSearchNotAvailable: "La cerca d'usuaris no està disponible."
license: "Llicència" license: "Llicència"
unfavoriteConfirm: "Esborrar dels favorits?" unfavoriteConfirm: "Esborrar dels favorits?"
myClips: "Els meus retalls" myClips: "Els meus retalls"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius." descriptionOfRateLimitFactor: "Límits baixos són menys restrictius, límits alts són més restrictius."
canHideAds: "Pot amagar la publicitat" canHideAds: "Pot amagar la publicitat"
canSearchNotes: "Pot cercar notes" canSearchNotes: "Pot cercar notes"
canSearchUsers: "Pot cercar usuaris"
canUseTranslator: "Pot fer servir el traductor" canUseTranslator: "Pot fer servir el traductor"
avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars" avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars"
canImportAntennas: "Autoritza la importació d'antenes " canImportAntennas: "Autoritza la importació d'antenes "
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "Tipus" type: "Tipus"
image: "Imatges" image: "Imatges"
advanced: "Avançat" advanced: "Avançat"
angle: "Angle"
stripe: "Bandes" stripe: "Bandes"
stripeWidth: "Amplada de la banda" stripeWidth: "Amplada de la banda"
stripeFrequency: "Freqüència de la banda" stripeFrequency: "Freqüència de la banda"
angle: "Angle"
polkadot: "Lunars" polkadot: "Lunars"
checker: "Escacs" checker: "Escacs"
polkadotMainDotOpacity: "Opacitat del lunar principal" polkadotMainDotOpacity: "Opacitat del lunar principal"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "Efecte" title: "Efecte"
addEffect: "Afegeix un efecte" addEffect: "Afegeix un efecte"
discardChangesConfirm: "Vols descartar els canvis i sortir?" discardChangesConfirm: "Vols descartar els canvis i sortir?"
nothingToConfigure: "No hi ha opcions de configuració disponibles"
_fxs: _fxs:
chromaticAberration: "Aberració cromàtica" chromaticAberration: "Aberració cromàtica"
glitch: "Glitch" glitch: "Glitch"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "Escacs" checker: "Escacs"
blockNoise: "Bloqueig de soroll" blockNoise: "Bloqueig de soroll"
tearing: "Trencament d'imatge " 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: "Esborrany "
_drafts: _drafts:
select: "Seleccionar esborrany" select: "Seleccionar esborrany"

View File

@ -2053,3 +2053,10 @@ _watermarkEditor:
type: "Typ" type: "Typ"
image: "Obrázky" image: "Obrázky"
advanced: "Pokročilé" 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" type: "Art"
image: "Bilder" image: "Bilder"
advanced: "Fortgeschritten" advanced: "Fortgeschritten"
angle: "Winkel"
stripe: "Streifen" stripe: "Streifen"
stripeWidth: "Linienbreite" stripeWidth: "Linienbreite"
stripeFrequency: "Linienanzahl" stripeFrequency: "Linienanzahl"
angle: "Winkel"
polkadot: "Punktmuster" polkadot: "Punktmuster"
polkadotMainDotOpacity: "Deckkraft des Hauptpunktes" polkadotMainDotOpacity: "Deckkraft des Hauptpunktes"
polkadotMainDotRadius: "Größe des Hauptpunktes" polkadotMainDotRadius: "Größe des Hauptpunktes"
@ -3173,6 +3173,13 @@ _imageEffector:
distort: "Verzerrung" distort: "Verzerrung"
stripe: "Streifen" stripe: "Streifen"
polkadot: "Punktmuster" polkadot: "Punktmuster"
_fxProps:
angle: "Winkel"
scale: "Größe"
size: "Größe"
color: "Farbe"
opacity: "Transparenz"
lightness: "Erhellen"
drafts: "Entwurf" drafts: "Entwurf"
_drafts: _drafts:
select: "Entwurf auswählen" select: "Entwurf auswählen"

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Using spaces will create AND expressions and surro
hiddenTags: "Hidden hashtags" hiddenTags: "Hidden hashtags"
hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines." hiddenTagsDescription: "Select tags which will not shown on trend list.\nMultiple tags could be registered by lines."
notesSearchNotAvailable: "Note search is unavailable." notesSearchNotAvailable: "Note search is unavailable."
usersSearchNotAvailable: "User search is not available."
license: "License" license: "License"
unfavoriteConfirm: "Really remove from favorites?" unfavoriteConfirm: "Really remove from favorites?"
myClips: "My clips" 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." contentsUpdateFrequency_description2: "When real-time mode is on, content is updated in real time regardless of this setting."
showUrlPreview: "Show URL preview" showUrlPreview: "Show URL preview"
showAvailableReactionsFirstInNote: "Show available reactions at the top." showAvailableReactionsFirstInNote: "Show available reactions at the top."
showPageTabBarBottom: "Show page tab bar at the bottom"
_chat: _chat:
showSenderName: "Show sender's name" showSenderName: "Show sender's name"
sendOnEnter: "Press Enter to send" sendOnEnter: "Press Enter to send"
@ -1998,19 +2000,20 @@ _role:
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. " descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
canHideAds: "Can hide ads" canHideAds: "Can hide ads"
canSearchNotes: "Usage of note search" canSearchNotes: "Usage of note search"
canSearchUsers: "User search"
canUseTranslator: "Translator usage" canUseTranslator: "Translator usage"
avatarDecorationLimit: "Maximum number of avatar decorations that can be applied" avatarDecorationLimit: "Maximum number of avatar decorations"
canImportAntennas: "Allow importing antennas" canImportAntennas: "Can import antennas"
canImportBlocking: "Allow importing blocking" canImportBlocking: "Can import blocking"
canImportFollowing: "Allow importing following" canImportFollowing: "Can import following"
canImportMuting: "Allow importing muting" canImportMuting: "Can import muting"
canImportUserLists: "Allow importing lists" canImportUserLists: "Can import lists"
chatAvailability: "Allow Chat" chatAvailability: "Chat"
uploadableFileTypes: "Uploadable file types" 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_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." 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" noteDraftLimit: "Number of possible drafts of server notes"
watermarkAvailable: "Availability of watermark function" watermarkAvailable: "Watermark function"
_condition: _condition:
roleAssignedTo: "Assigned to manual roles" roleAssignedTo: "Assigned to manual roles"
isLocal: "Local user" isLocal: "Local user"
@ -3163,10 +3166,10 @@ _watermarkEditor:
type: "Type" type: "Type"
image: "Images" image: "Images"
advanced: "Advanced" advanced: "Advanced"
angle: "Angle"
stripe: "Stripes" stripe: "Stripes"
stripeWidth: "Line width" stripeWidth: "Line width"
stripeFrequency: "Lines count" stripeFrequency: "Lines count"
angle: "Angle"
polkadot: "Polkadot" polkadot: "Polkadot"
checker: "Checker" checker: "Checker"
polkadotMainDotOpacity: "Opacity of the main dot" polkadotMainDotOpacity: "Opacity of the main dot"
@ -3178,6 +3181,7 @@ _imageEffector:
title: "Effects" title: "Effects"
addEffect: "Add Effects" addEffect: "Add Effects"
discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes." discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes."
nothingToConfigure: "No configurable options available"
_fxs: _fxs:
chromaticAberration: "Chromatic Aberration" chromaticAberration: "Chromatic Aberration"
glitch: "Glitch" glitch: "Glitch"
@ -3195,6 +3199,38 @@ _imageEffector:
checker: "Checker" checker: "Checker"
blockNoise: "Block Noise" blockNoise: "Block Noise"
tearing: "Tearing" 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: "Drafts"
_drafts: _drafts:
select: "Select Draft" select: "Select Draft"

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Si se usan espacios se crearán expresiones AND y
hiddenTags: "Hashtags ocultos" hiddenTags: "Hashtags ocultos"
hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea." hiddenTagsDescription: "Selecciona las etiquetas que no se mostrarán en tendencias. Una etiqueta por línea."
notesSearchNotAvailable: "No se puede buscar una nota" notesSearchNotAvailable: "No se puede buscar una nota"
usersSearchNotAvailable: "La búsqueda de usuarios no está disponible."
license: "Licencia" license: "Licencia"
unfavoriteConfirm: "¿Desea quitar de favoritos?" unfavoriteConfirm: "¿Desea quitar de favoritos?"
myClips: "Mis clips" myClips: "Mis clips"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos" descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
canHideAds: "Puede ocultar anuncios" canHideAds: "Puede ocultar anuncios"
canSearchNotes: "Uso de la búsqueda de notas" canSearchNotes: "Uso de la búsqueda de notas"
canSearchUsers: "Uso de la búsqueda de usuarios"
canUseTranslator: "Uso de traductor" canUseTranslator: "Uso de traductor"
avatarDecorationLimit: "Número máximo de decoraciones de avatar" avatarDecorationLimit: "Número máximo de decoraciones de avatar"
canImportAntennas: "Permitir la importación de antenas" canImportAntennas: "Permitir la importación de antenas"
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "Tipo" type: "Tipo"
image: "Imágenes" image: "Imágenes"
advanced: "Avanzado" advanced: "Avanzado"
angle: "Ángulo"
stripe: "Rayas" stripe: "Rayas"
stripeWidth: "Anchura de línea" stripeWidth: "Anchura de línea"
stripeFrequency: "Número de líneas." stripeFrequency: "Número de líneas."
angle: "Ángulo"
polkadot: "Lunares" polkadot: "Lunares"
checker: "verificador" checker: "verificador"
polkadotMainDotOpacity: "Opacidad del círculo principal" polkadotMainDotOpacity: "Opacidad del círculo principal"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "Efecto" title: "Efecto"
addEffect: "Añadir Efecto" addEffect: "Añadir Efecto"
discardChangesConfirm: "¿Ignorar cambios y salir?" discardChangesConfirm: "¿Ignorar cambios y salir?"
nothingToConfigure: "No hay opciones configurables disponibles."
_fxs: _fxs:
chromaticAberration: "Aberración Cromática" chromaticAberration: "Aberración Cromática"
glitch: "Glitch" glitch: "Glitch"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "Corrector" checker: "Corrector"
blockNoise: "Bloquear Ruido" blockNoise: "Bloquear Ruido"
tearing: "Rasgado de Imagen (Tearing)" 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: "Borrador"
_drafts: _drafts:
select: "Seleccionar borradores" select: "Seleccionar borradores"

View File

@ -2372,3 +2372,11 @@ _watermarkEditor:
image: "Images" image: "Images"
advanced: "Avancé" advanced: "Avancé"
angle: "Angle" 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" image: "Gambar"
advanced: "Tingkat lanjut" advanced: "Tingkat lanjut"
angle: "Sudut" angle: "Sudut"
_imageEffector:
_fxProps:
angle: "Sudut"
scale: "Ukuran"
size: "Ukuran"
color: "Warna"
opacity: "Opasitas"
lightness: "Menerangkan"

24
locales/index.d.ts vendored
View File

@ -4234,6 +4234,10 @@ export interface Locale extends ILocale {
* *
*/ */
"selectFromPresets": string; "selectFromPresets": string;
/**
*
*/
"custom": string;
/** /**
* *
*/ */
@ -5521,6 +5525,10 @@ export interface Locale extends ILocale {
* 使 * 使
*/ */
"themeIsDefaultBecauseSafeMode": string; "themeIsDefaultBecauseSafeMode": string;
/**
*
*/
"thankYouForTestingBeta": string;
/** /**
* 使稿 * 使稿
*/ */
@ -6618,6 +6626,18 @@ export interface Locale extends ILocale {
* *
*/ */
"restartServerSetupWizardConfirm_text": string; "restartServerSetupWizardConfirm_text": string;
/**
*
*/
"entrancePageStyle": string;
/**
*
*/
"showTimelineForVisitor": string;
/**
*
*/
"showActivityiesForVisitor": string;
"_userGeneratedContentsVisibilityForVisitor": { "_userGeneratedContentsVisibilityForVisitor": {
/** /**
* *
@ -8872,6 +8892,10 @@ export interface Locale extends ILocale {
* *
*/ */
"day": string; "day": string;
/**
*
*/
"month": string;
}; };
"_2fa": { "_2fa": {
/** /**

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "Gli spazi creano la relazione \"E\" tra parole (qu
hiddenTags: "Hashtag nascosti" hiddenTags: "Hashtag nascosti"
hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga." hiddenTagsDescription: "Impedire la visualizzazione del tag impostato nei trend. Puoi impostare più valori, uno per riga."
notesSearchNotAvailable: "Non è possibile cercare tra le Note." notesSearchNotAvailable: "Non è possibile cercare tra le Note."
usersSearchNotAvailable: "La ricerca profili non è disponibile."
license: "Licenza" license: "Licenza"
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
myClips: "Le mie Clip" myClips: "Le mie Clip"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più." descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
canHideAds: "Nascondere i banner" canHideAds: "Nascondere i banner"
canSearchNotes: "Ricercare nelle Note" canSearchNotes: "Ricercare nelle Note"
canSearchUsers: "Può cercare profili"
canUseTranslator: "Tradurre le Note" canUseTranslator: "Tradurre le Note"
avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili" avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili"
canImportAntennas: "Può importare Antenne" canImportAntennas: "Può importare Antenne"
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "Tipo" type: "Tipo"
image: "Immagini" image: "Immagini"
advanced: "Avanzato" advanced: "Avanzato"
angle: "Angolo"
stripe: "Strisce" stripe: "Strisce"
stripeWidth: "Larghezza della linea" stripeWidth: "Larghezza della linea"
stripeFrequency: "Il numero di linee" stripeFrequency: "Il numero di linee"
angle: "Angolo"
polkadot: "A pallini" polkadot: "A pallini"
checker: "revisore" checker: "revisore"
polkadotMainDotOpacity: "Opacità del punto principale" polkadotMainDotOpacity: "Opacità del punto principale"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "Effetto" title: "Effetto"
addEffect: "Aggiungi effetto" addEffect: "Aggiungi effetto"
discardChangesConfirm: "Scarta le modifiche ed esci?" discardChangesConfirm: "Scarta le modifiche ed esci?"
nothingToConfigure: "Nessuna impostazione configurabile."
_fxs: _fxs:
chromaticAberration: "Aberrazione cromatica" chromaticAberration: "Aberrazione cromatica"
glitch: "Glitch" glitch: "Glitch"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "revisore" checker: "revisore"
blockNoise: "Attenua rumore" blockNoise: "Attenua rumore"
tearing: "Strappa immagine" 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: "Bozza"
_drafts: _drafts:
select: "Selezionare bozza" select: "Selezionare bozza"

View File

@ -1054,6 +1054,7 @@ permissionDeniedError: "操作が拒否されました"
permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。"
preset: "プリセット" preset: "プリセット"
selectFromPresets: "プリセットから選択" selectFromPresets: "プリセットから選択"
custom: "カスタム"
achievements: "実績" achievements: "実績"
gotInvalidResponseError: "サーバーの応答が無効です" gotInvalidResponseError: "サーバーの応答が無効です"
gotInvalidResponseErrorDescription: "サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。" gotInvalidResponseErrorDescription: "サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから再度お試しください。"
@ -1375,6 +1376,7 @@ safeModeEnabled: "セーフモードが有効です"
pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プラグインはすべて無効化されています。" pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プラグインはすべて無効化されています。"
customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。" customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。"
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。" themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。" youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。"
_order: _order:
@ -1682,6 +1684,9 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。" userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。"
restartServerSetupWizardConfirm_title: "サーバーの初期設定ウィザードをやり直しますか?" restartServerSetupWizardConfirm_title: "サーバーの初期設定ウィザードをやり直しますか?"
restartServerSetupWizardConfirm_text: "現在の一部の設定はリセットされます。" restartServerSetupWizardConfirm_text: "現在の一部の設定はリセットされます。"
entrancePageStyle: "エントランスページのスタイル"
showTimelineForVisitor: "タイムラインを表示する"
showActivityiesForVisitor: "アクティビティを表示する"
_userGeneratedContentsVisibilityForVisitor: _userGeneratedContentsVisibilityForVisitor:
all: "全て公開" all: "全て公開"
@ -2330,6 +2335,7 @@ _time:
minute: "分" minute: "分"
hour: "時間" hour: "時間"
day: "日" day: "日"
month: "ヶ月"
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"

View File

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

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며,
hiddenTags: "숨긴 해시태그" hiddenTags: "숨긴 해시태그"
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다." notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
usersSearchNotAvailable: "유저 검색을 이용하실 수 없습니다."
license: "라이선스" license: "라이선스"
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?" unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
myClips: "내 클립" myClips: "내 클립"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
canHideAds: "광고 숨기기" canHideAds: "광고 숨기기"
canSearchNotes: "노트 검색 이용 가능 여부" canSearchNotes: "노트 검색 이용 가능 여부"
canSearchUsers: "유저 검색 이용"
canUseTranslator: "번역 기능의 사용" canUseTranslator: "번역 기능의 사용"
avatarDecorationLimit: "아바타 장식의 최대 붙임 개수" avatarDecorationLimit: "아바타 장식의 최대 붙임 개수"
canImportAntennas: "안테나 가져오기 허용" canImportAntennas: "안테나 가져오기 허용"
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "종류" type: "종류"
image: "이미지" image: "이미지"
advanced: "고급" advanced: "고급"
angle: "각도"
stripe: "줄무늬" stripe: "줄무늬"
stripeWidth: "라인의 폭" stripeWidth: "라인의 폭"
stripeFrequency: "라인의 수" stripeFrequency: "라인의 수"
angle: "각도"
polkadot: "물방울 무늬" polkadot: "물방울 무늬"
checker: "체크 무늬" checker: "체크 무늬"
polkadotMainDotOpacity: "주요 물방울의 불투명도" polkadotMainDotOpacity: "주요 물방울의 불투명도"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "이펙트" title: "이펙트"
addEffect: "이펙트를 추가" addEffect: "이펙트를 추가"
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?" discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
nothingToConfigure: "설정 항목이 없습니다."
_fxs: _fxs:
chromaticAberration: "색수차" chromaticAberration: "색수차"
glitch: "글리치" glitch: "글리치"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "체크 무늬" checker: "체크 무늬"
blockNoise: "노이즈 방지" blockNoise: "노이즈 방지"
tearing: "티어링" 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: "초안"
_drafts: _drafts:
select: "초안 선택" select: "초안 선택"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1459,3 +1459,10 @@ _watermarkEditor:
type: "Typ" type: "Typ"
image: "Obrázky" image: "Obrázky"
advanced: "Rozšírené" 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: _watermarkEditor:
scale: "Storlek" scale: "Storlek"
image: "Bilder" image: "Bilder"
_imageEffector:
_fxProps:
scale: "Storlek"
size: "Storlek"
color: "Färg"

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "ถ้าแยกด้วยเว้นวร
hiddenTags: "แฮชแท็กที่ซ่อนอยู่" hiddenTags: "แฮชแท็กที่ซ่อนอยู่"
hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่" hiddenTagsDescription: "เลือกแท็กที่จะไม่แสดงในรายการเทรนด์ สามารถลงทะเบียนหลายแท็กได้โดยขึ้นบรรทัดใหม่"
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน" notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งาน"
usersSearchNotAvailable: "การค้นหาผู้ใช้ไม่พร้อมใช้งาน"
license: "ใบอนุญาต" license: "ใบอนุญาต"
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?" unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
myClips: "คลิปของฉัน" myClips: "คลิปของฉัน"
@ -1370,6 +1371,10 @@ defaultImageCompressionLevel: "ความละเอียดเริ่ม
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง" defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
inMinutes: "นาที" inMinutes: "นาที"
inDays: "วัน" inDays: "วัน"
safeModeEnabled: "โหมดปลอดภัยถูกเปิดใช้งาน"
pluginsAreDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน ปลั๊กอินทั้งหมดจึงถูกปิดใช้งาน"
customCssIsDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน CSS แบบกำหนดเองจึงไม่ได้ถูกนำมาใช้"
themeIsDefaultBecauseSafeMode: "ในระหว่างที่โหมดปลอดภัยถูกเปิดใช้งาน จะใช้ธีมเริ่มต้น เมื่อปิดโหมดปลอดภัยจะกลับคืนดังเดิม"
_order: _order:
newest: "เรียงจากใหม่ไปเก่า" newest: "เรียงจากใหม่ไปเก่า"
oldest: "เรียงจากเก่าไปใหม่" oldest: "เรียงจากเก่าไปใหม่"
@ -1995,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น" descriptionOfRateLimitFactor: "ยิ่งตัวเลขน้อยก็ยิ่งจำกัดน้อย ยิ่งมากก็ยิ่งเข้มงวดมากขึ้น"
canHideAds: "ซ่อนโฆษณา" canHideAds: "ซ่อนโฆษณา"
canSearchNotes: "การใช้การค้นหาโน้ต" canSearchNotes: "การใช้การค้นหาโน้ต"
canSearchUsers: "ค้นหาผู้ใช้"
canUseTranslator: "การใช้งานแปล" canUseTranslator: "การใช้งานแปล"
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ" canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
@ -3069,6 +3075,7 @@ _bootErrors:
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์" otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย" otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
otherOption3: "เปิดเครื่องมือซ่อมแซม" otherOption3: "เปิดเครื่องมือซ่อมแซม"
otherOption4: "เริ่มทำงาน Misskey ในโหมดปลอดภัย"
_search: _search:
searchScopeAll: "ทั้งหมด" searchScopeAll: "ทั้งหมด"
searchScopeLocal: "ท้องถิ่น" searchScopeLocal: "ท้องถิ่น"
@ -3159,10 +3166,10 @@ _watermarkEditor:
type: "รูปแบบ" type: "รูปแบบ"
image: "รูปภาพ" image: "รูปภาพ"
advanced: "ขั้นสูง" advanced: "ขั้นสูง"
angle: "แองเกิล"
stripe: "ริ้ว" stripe: "ริ้ว"
stripeWidth: "ความกว้างเส้น" stripeWidth: "ความกว้างเส้น"
stripeFrequency: "จำนวนเส้น" stripeFrequency: "จำนวนเส้น"
angle: "แองเกิล"
polkadot: "ลายจุด" polkadot: "ลายจุด"
checker: "ช่องตาราง" checker: "ช่องตาราง"
polkadotMainDotOpacity: "ความทึบของจุดหลัก" polkadotMainDotOpacity: "ความทึบของจุดหลัก"
@ -3174,6 +3181,7 @@ _imageEffector:
title: "เอฟเฟกต์" title: "เอฟเฟกต์"
addEffect: "เพิ่มเอฟเฟกต์" addEffect: "เพิ่มเอฟเฟกต์"
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?" discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า"
_fxs: _fxs:
chromaticAberration: "ความคลาดสี" chromaticAberration: "ความคลาดสี"
glitch: "กลิตช์" glitch: "กลิตช์"
@ -3191,6 +3199,38 @@ _imageEffector:
checker: "ช่องตาราง" checker: "ช่องตาราง"
blockNoise: "บล็อกที่มีการรบกวน" blockNoise: "บล็อกที่มีการรบกวน"
tearing: "ฉีกขาด" 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: "ร่าง"
_drafts: _drafts:
select: "เลือกฉบับร่าง" select: "เลือกฉบับร่าง"

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -2091,3 +2091,11 @@ _watermarkEditor:
image: "Hình ảnh" image: "Hình ảnh"
advanced: "Nâng cao" advanced: "Nâng cao"
angle: "Góc" 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: "隐藏标签" hiddenTags: "隐藏标签"
hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。" hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。"
notesSearchNotAvailable: "帖子检索不可用" notesSearchNotAvailable: "帖子检索不可用"
usersSearchNotAvailable: "用户检索不可用"
license: "许可信息" license: "许可信息"
unfavoriteConfirm: "确定要取消收藏吗?" unfavoriteConfirm: "确定要取消收藏吗?"
myClips: "我的便签" myClips: "我的便签"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "可以隐藏广告" canHideAds: "可以隐藏广告"
canSearchNotes: "是否可以搜索帖子" canSearchNotes: "是否可以搜索帖子"
canSearchUsers: "使用用户检索"
canUseTranslator: "使用翻译功能" canUseTranslator: "使用翻译功能"
avatarDecorationLimit: "可添加头像挂件的最大个数" avatarDecorationLimit: "可添加头像挂件的最大个数"
canImportAntennas: "允许导入天线" canImportAntennas: "允许导入天线"
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "类型" type: "类型"
image: "图片" image: "图片"
advanced: "高级" advanced: "高级"
angle: "角度"
stripe: "条纹" stripe: "条纹"
stripeWidth: "线条宽度" stripeWidth: "线条宽度"
stripeFrequency: "线条数量" stripeFrequency: "线条数量"
angle: "角度"
polkadot: "波点" polkadot: "波点"
checker: "检查" checker: "检查"
polkadotMainDotOpacity: "主波点的不透明度" polkadotMainDotOpacity: "主波点的不透明度"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "效果" title: "效果"
addEffect: "添加效果" addEffect: "添加效果"
discardChangesConfirm: "丢弃当前设置并退出?" discardChangesConfirm: "丢弃当前设置并退出?"
nothingToConfigure: "还没有设置"
_fxs: _fxs:
chromaticAberration: "色差" chromaticAberration: "色差"
glitch: "故障" glitch: "故障"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "检查" checker: "检查"
blockNoise: "块状噪点" blockNoise: "块状噪点"
tearing: "撕裂" 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: "草稿"
_drafts: _drafts:
select: "选择草稿" select: "选择草稿"

View File

@ -1092,6 +1092,7 @@ prohibitedWordsDescription2: "空格代表「以及」AND斜線包圍
hiddenTags: "隱藏標籤" hiddenTags: "隱藏標籤"
hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。" hiddenTagsDescription: "設定的標籤不會在趨勢中顯示,換行可以設定多個標籤。"
notesSearchNotAvailable: "無法使用搜尋貼文功能。" notesSearchNotAvailable: "無法使用搜尋貼文功能。"
usersSearchNotAvailable: "無法使用使用者搜尋功能。"
license: "授權" license: "授權"
unfavoriteConfirm: "要取消收錄我的最愛嗎?" unfavoriteConfirm: "要取消收錄我的最愛嗎?"
myClips: "我的摘錄" myClips: "我的摘錄"
@ -1999,6 +2000,7 @@ _role:
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "不顯示廣告" canHideAds: "不顯示廣告"
canSearchNotes: "可否搜尋貼文" canSearchNotes: "可否搜尋貼文"
canSearchUsers: "可使用使用者搜尋功能"
canUseTranslator: "使用翻譯功能" canUseTranslator: "使用翻譯功能"
avatarDecorationLimit: "頭像可掛上的最大裝飾數量" avatarDecorationLimit: "頭像可掛上的最大裝飾數量"
canImportAntennas: "允許匯入天線" canImportAntennas: "允許匯入天線"
@ -3164,10 +3166,10 @@ _watermarkEditor:
type: "類型" type: "類型"
image: "圖片" image: "圖片"
advanced: "進階" advanced: "進階"
angle: "角度"
stripe: "條紋" stripe: "條紋"
stripeWidth: "線條寬度" stripeWidth: "線條寬度"
stripeFrequency: "線條數量" stripeFrequency: "線條數量"
angle: "角度"
polkadot: "波卡圓點" polkadot: "波卡圓點"
checker: "棋盤格" checker: "棋盤格"
polkadotMainDotOpacity: "主圓點的不透明度" polkadotMainDotOpacity: "主圓點的不透明度"
@ -3179,6 +3181,7 @@ _imageEffector:
title: "特效" title: "特效"
addEffect: "新增特效" addEffect: "新增特效"
discardChangesConfirm: "捨棄更改並退出嗎?" discardChangesConfirm: "捨棄更改並退出嗎?"
nothingToConfigure: "無可設定的項目"
_fxs: _fxs:
chromaticAberration: "色差" chromaticAberration: "色差"
glitch: "異常雜訊效果" glitch: "異常雜訊效果"
@ -3196,6 +3199,38 @@ _imageEffector:
checker: "棋盤格" checker: "棋盤格"
blockNoise: "阻擋雜訊" blockNoise: "阻擋雜訊"
tearing: "撕裂" 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: "草稿\n"
_drafts: _drafts:
select: "選擇草槁" select: "選擇草槁"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.8.0-alpha.7", "version": "2025.8.0-beta.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

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

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EntrancePageStyle1755574887486 {
name = 'EntrancePageStyle1755574887486'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "clientOptions" jsonb NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "clientOptions"`);
}
}

View File

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

View File

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

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

View File

@ -109,6 +109,7 @@ export class MetaEntityService {
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
defaultLightTheme, defaultLightTheme,
defaultDarkTheme, defaultDarkTheme,
clientOptions: instance.clientOptions,
ads: ads.map(ad => ({ ads: ads.map(ad => ({
id: ad.id, id: ad.id,
url: ad.url, url: ad.url,

View File

@ -716,6 +716,11 @@ export class MiMeta {
default: 90, // days default: 90, // days
}) })
public remoteNotesCleaningExpiryDaysForEachNotes: number; public remoteNotesCleaningExpiryDaysForEachNotes: number;
@Column('jsonb', {
default: { },
})
public clientOptions: Record<string, any>;
} }
export type SoftwareSuspension = { export type SoftwareSuspension = {

View File

@ -114,6 +114,13 @@ export class MiNote {
}) })
public clippedCount: number; 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', { @Column('jsonb', {
default: {}, default: {},
}) })

View File

@ -71,6 +71,10 @@ export const packedMetaLiteSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
clientOptions: {
type: 'object',
optional: false, nullable: false,
},
disableRegistration: { disableRegistration: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -5,6 +5,7 @@
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource, IsNull, LessThan, QueryFailedError, Not } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js'; import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
@ -24,18 +25,31 @@ export class CleanRemoteNotesProcessorService {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.db)
private db: DataSource,
private idService: IdService, private idService: IdService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes'); 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 @bindThis
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{ public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
deletedCount: number; deletedCount: number;
oldest: number | null; oldest: number | null;
newest: number | null; newest: number | null;
skipped?: boolean; skipped: boolean;
transientErrors: number;
}> { }> {
if (!this.meta.enableRemoteNotesCleaning) { if (!this.meta.enableRemoteNotesCleaning) {
this.logger.info('Remote notes cleaning is disabled, skipping...'); this.logger.info('Remote notes cleaning is disabled, skipping...');
@ -44,6 +58,7 @@ export class CleanRemoteNotesProcessorService {
oldest: null, oldest: null,
newest: null, newest: null,
skipped: true, skipped: true,
transientErrors: 0,
}; };
} }
@ -52,12 +67,10 @@ export class CleanRemoteNotesProcessorService {
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
const startAt = Date.now(); 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.
//#retion queries // All notes newer than this limit will always be retained.
// We use string literals instead of query builder for several reasons: const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
// - 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
// The condition for removing the notes. // The condition for removing the notes.
// The note must be: // The note must be:
@ -66,56 +79,95 @@ export class CleanRemoteNotesProcessorService {
// - not have clipped // - not have clipped
// - not have pinned on the user profile // - not have pinned on the user profile
// - not has been favorite by any user // - not has been favorite by any user
const removeCondition = 'note.id < :newestLimit' const removalCriteria = [
+ ' AND note."clippedCount" = 0' 'note."id" < :newestLimit',
+ ' AND note."userHost" IS NOT NULL' 'note."clippedCount" = 0',
// using both userId and noteId instead of just noteId to use index on user_note_pining table. 'note."pageCount" = 0',
// This is safe because notes are only pinned by the user who created them. 'note."userHost" IS NOT NULL',
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")' 'NOT EXISTS (SELECT 1 FROM user_note_pining WHERE "noteId" = note."id")',
// We cannot use userId trick because users can favorite notes from other users. 'NOT EXISTS (SELECT 1 FROM note_favorite WHERE "noteId" = note."id")',
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")' 'NOT EXISTS (SELECT 1 FROM note_reaction INNER JOIN "user" ON note_reaction."userId" = "user".id WHERE note_reaction."noteId" = note."id" AND "user"."host" IS NULL)',
; ].join(' AND ');
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes const minId = (await this.notesRepository.createQueryBuilder('note')
const initiatorQuery = 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') .select('note.id', 'id')
.where(removeCondition) .addSelect('note."replyId"', 'replyId')
.andWhere('note.id > :cursor') .addSelect('note."renoteId"', 'renoteId')
.orderBy('note.id', 'ASC') .addSelect('parent."rootId"', 'rootId')
.limit(MAX_NOTE_COUNT_PER_QUERY); .addSelect(removalCriteria, 'isRemovable')
.addSelect('FALSE', 'isBase')
.innerJoin(candidateNotesCteName, 'parent', 'parent."id" = note."replyId" OR parent."id" = note."renoteId"')
.where('parent."isRemovable" = TRUE');
// The union query queries the related notes and replies related to the initiator query // A note tree can be deleted if there are no unremovable rows with the same rootId.
const unionQuery = ` //
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId" // `candidate_notes` will have the following structure after recursive query (some columns omitted):
FROM "note" "note" // After performing a LEFT JOIN with `candidate_notes` as `unremovable`,
INNER JOIN "related_notes" "rn" // the note tree containing unremovable notes will be anti-joined.
ON "note"."replyId" = rn.id // For removable rows, the `unremovable` columns will have `NULL` values.
OR "note"."renoteId" = rn.id // | id | rootId | isRemovable |
OR "note"."id" = rn."replyId" // |-----|--------|-------------|
OR "note"."id" = rn."renoteId" // | aaa | aaa | TRUE |
`; // | bbb | aaa | FALSE |
// | ccc | aaa | FALSE |
const selectRelatedNotesFromInitiatorIdsQuery = ` // | ddd | ddd | TRUE |
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId" // | eee | ddd | TRUE |
FROM "note" "note" WHERE "note"."id" IN (:...initiatorIds) // | fff | fff | TRUE |
`; // | ggg | ggg | FALSE |
//
const recursiveQuery = `(${selectRelatedNotesFromInitiatorIdsQuery}) UNION (${unionQuery})`; const candidateNotesQuery = this.db.createQueryBuilder()
.select(`"${candidateNotesCteName}"."id"`, 'id')
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note') .addSelect('unremovable."id" IS NULL', 'isRemovable')
.select('rn."initiatorId"') .addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
.innerJoin('related_notes', 'rn', 'note.id = rn.id') .addCommonTableExpression(
.groupBy('rn."initiatorId"') `((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
.having(`bool_and(${removeCondition})`); candidateNotesCteName,
{ recursive: true },
const notesQuery = this.notesRepository.createQueryBuilder('note') )
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true }) .from(candidateNotesCteName, candidateNotesCteName)
.select('note.id', 'id') .leftJoin(candidateNotesCteName, 'unremovable', `unremovable."rootId" = "${candidateNotesCteName}"."rootId" AND unremovable."isRemovable" = FALSE`)
.addSelect('rn."initiatorId"') .groupBy(`"${candidateNotesCteName}"."id"`)
.innerJoin('related_notes', 'rn', 'note.id = rn.id') .addGroupBy('unremovable."id" IS NULL');
.where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
.distinctOn(['note.id']);
//#endregion
const stats = { const stats = {
deletedCount: 0, deletedCount: 0,
@ -123,51 +175,71 @@ export class CleanRemoteNotesProcessorService {
newest: null as number | null, newest: null as number | null,
}; };
// The date limit for the newest note to be considered for deletion. let lowThroughputWarned = false;
// All notes newer than this limit will always be retained. let transientErrors = 0;
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)); for (;;) {
let cursor = '0'; // oldest note ID to start from
while (true) {
//#region check time //#region check time
const batchBeginAt = Date.now(); const batchBeginAt = Date.now();
const elapsed = batchBeginAt - startAt; const elapsed = batchBeginAt - startAt;
const progress = this.computeProgress(minId, newestLimit, cursorLeft > minId ? cursorLeft : minId);
if (elapsed >= maxDuration) { if (elapsed >= maxDuration) {
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`); job.log(`Reached maximum duration of ${maxDuration}ms, stopping... (last cursor: ${cursorLeft}, final progress ${progress}%)`);
job.log('Reached maximum duration, stopping cleaning.');
job.updateProgress(100); job.updateProgress(100);
break; 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 //#endregion
// First, we fetch the initiator notes that are older than the newestLimit. const queryBegin = performance.now();
const initiatorNotes: { id: MiNote['id'] }[] = await initiatorQuery.setParameters({ cursor, newestLimit }).getRawMany(); let noteIds = null;
// update the cursor to the newest initiatorId found in the fetched notes. try {
const newCursor = initiatorNotes.reduce((max, note) => note.id > max ? note.id : max, cursor); 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 (initiatorNotes.length === 0 || cursor === newCursor || newCursor >= newestLimit) { if (noteIds.length === 0) {
// If no notes were found or the cursor did not change, we can stop. job.log('No more notes to clean.');
job.log('No more notes to clean. (no initiator notes found or cursor did not change.)');
break; break;
} }
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.setParameters({ const queryDuration = performance.now() - queryBegin;
initiatorIds: initiatorNotes.map(note => note.id), // try to adjust such that each query takes about 1~5 seconds and reasonable NodeJS heap so the task stays responsive
newestLimit, // this should not oscillate..
}).getRawMany(); 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);
cursor = newCursor; const deletableNoteIds = noteIds.filter(result => result.isRemovable).map(result => result.id);
if (deletableNoteIds.length > 0) {
try {
await this.notesRepository.delete(deletableNoteIds);
if (notes.length > 0) { for (const id of deletableNoteIds) {
await this.notesRepository.delete(notes.map(note => note.id));
for (const { id } of notes) {
const t = this.idService.parse(id).date.getTime(); const t = this.idService.parse(id).date.getTime();
if (stats.oldest === null || t < stats.oldest) { if (stats.oldest === null || t < stats.oldest) {
stats.oldest = t; stats.oldest = t;
@ -177,20 +249,33 @@ export class CleanRemoteNotesProcessorService {
} }
} }
stats.deletedCount += notes.length; 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;
}
}
} }
job.log(`Deleted ${notes.length} from ${initiatorNotes.length} initiators; ${Date.now() - batchBeginAt}ms`); cursorLeft = noteIds.filter(result => result.isBase).reduce((max, { id }) => id > max ? id : max, cursorLeft);
if (initiatorNotes.length < MAX_NOTE_COUNT_PER_QUERY) { job.log(`Deleted ${noteIds.length} notes; ${Date.now() - batchBeginAt}ms`);
// If we fetched less than the maximum, it means there are no more notes to process.
job.log(`No more notes to clean. (fewer than MAX_NOTE_COUNT_PER_QUERY =${MAX_NOTE_COUNT_PER_QUERY}.)`); if (process.env.NODE_ENV !== 'test') {
break; await setTimeout(Math.min(1000 * 5, queryDuration)); // Wait a moment to avoid overwhelming the db
} }
};
await setTimeout(1000 * 5); // 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.'); this.logger.succ('cleaning of remote notes completed.');
return { return {
@ -198,6 +283,7 @@ export class CleanRemoteNotesProcessorService {
oldest: stats.oldest, oldest: stats.oldest,
newest: stats.newest, newest: stats.newest,
skipped: false, skipped: false,
transientErrors,
}; };
} }
} }

View File

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm'; import { MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; 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 type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.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 { EmailService } from '@/core/EmailService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { PageService } from '@/core/PageService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js'; import type { DbUserDeleteJobData } from '../types.js';
@ -35,7 +36,11 @@ export class DeleteAccountProcessorService {
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
private driveService: DriveService, private driveService: DriveService,
private pageService: PageService,
private emailService: EmailService, private emailService: EmailService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private searchService: SearchService, private searchService: SearchService,
@ -112,6 +117,28 @@ export class DeleteAccountProcessorService {
this.logger.succ('All of files deleted'); 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 { // Send email notification
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.email && profile.emailVerified) { if (profile.email && profile.emailVerified) {

View File

@ -425,6 +425,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
clientOptions: {
type: 'object',
optional: false, nullable: false,
},
description: { description: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -650,6 +654,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme, defaultDarkTheme: instance.defaultDarkTheme,
clientOptions: instance.clientOptions,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.deeplAuthKey != null,

View File

@ -67,6 +67,7 @@ export const paramDef = {
description: { type: 'string', nullable: true }, description: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true },
clientOptions: { type: 'object', nullable: false },
cacheRemoteFiles: { type: 'boolean' }, cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' },
@ -326,6 +327,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.defaultDarkTheme = ps.defaultDarkTheme; set.defaultDarkTheme = ps.defaultDarkTheme;
} }
if (ps.clientOptions !== undefined) {
set.clientOptions = ps.clientOptions;
}
if (ps.cacheRemoteFiles !== undefined) { if (ps.cacheRemoteFiles !== undefined) {
set.cacheRemoteFiles = ps.cacheRemoteFiles; set.cacheRemoteFiles = ps.cacheRemoteFiles;
} }

View File

@ -5,12 +5,13 @@
import ms from 'ms'; import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; import type { DriveFilesRepository, MiDriveFile, PagesRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { pageNameSchema } from '@/models/Page.js';
import { MiPage, pageNameSchema } from '@/models/Page.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.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'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -77,11 +78,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
private pageEntityService: PageEntityService, private pageEntityService: PageEntityService,
private idService: IdService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let eyeCatchingImage = null; let eyeCatchingImage: MiDriveFile | null = null;
if (ps.eyeCatchingImageId != null) { if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({ eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId, 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({ try {
id: this.idService.gen(), const page = await this.pageService.create(me, {
updatedAt: new Date(), ...ps,
title: ps.title, eyeCatchingImage,
name: ps.name, summary: ps.summary ?? null,
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,
}));
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 { 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 { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { PageService } from '@/core/PageService.js';
export const meta = { export const meta = {
tags: ['pages'], tags: ['pages'],
@ -44,36 +46,17 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.pagesRepository) private pageService: PageService,
private pagesRepository: PagesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); try {
await this.pageService.delete(me, ps.pageId);
if (page == null) { } catch (err) {
throw new ApiError(meta.errors.noSuchPage); 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;
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,
});
} }
}); });
} }

View File

@ -4,13 +4,14 @@
*/ */
import ms from 'ms'; import ms from 'ms';
import { Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; 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 { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
import { pageNameSchema } from '@/models/Page.js'; import { pageNameSchema } from '@/models/Page.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { PageService } from '@/core/PageService.js';
export const meta = { export const meta = {
tags: ['pages'], tags: ['pages'],
@ -75,24 +76,17 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); try {
if (page == null) { let eyeCatchingImage: MiDriveFile | null | undefined | string = ps.eyeCatchingImageId;
throw new ApiError(meta.errors.noSuchPage); if (eyeCatchingImage != null) {
} eyeCatchingImage = await this.driveFilesRepository.findOneBy({
if (page.userId !== me.id) { id: eyeCatchingImage,
throw new ApiError(meta.errors.accessDenied);
}
if (ps.eyeCatchingImageId != null) {
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
userId: me.id, userId: me.id,
}); });
@ -101,31 +95,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
if (ps.name != null) { await this.pageService.update(me, ps.pageId, {
await this.pagesRepository.findBy({ ...ps,
id: Not(ps.pageId), eyeCatchingImage,
userId: me.id,
name: ps.name,
}).then(result => {
if (result.length > 0) {
throw new ApiError(meta.errors.nameAlreadyExists);
}
}); });
} 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;
} }
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,
});
}); });
} }
} }

View File

@ -190,7 +190,8 @@ export async function uploadFile(
path = '../../test/resources/192.jpg', path = '../../test/resources/192.jpg',
): Promise<Misskey.entities.DriveFile> { ): Promise<Misskey.entities.DriveFile> {
const filename = path.split('/').pop() ?? 'untitled'; 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(); const body = new FormData();
body.append('i', user.i); body.append('i', user.i);

View File

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

View File

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

View File

@ -158,6 +158,7 @@ describe('CleanRemoteNotesProcessorService', () => {
oldest: null, oldest: null,
newest: null, newest: null,
skipped: true, skipped: true,
transientErrors: 0,
}); });
}); });
@ -172,6 +173,7 @@ describe('CleanRemoteNotesProcessorService', () => {
oldest: null, oldest: null,
newest: null, newest: null,
skipped: false, skipped: false,
transientErrors: 0,
}); });
}, 3000); }, 3000);
@ -199,6 +201,7 @@ describe('CleanRemoteNotesProcessorService', () => {
oldest: expect.any(Number), oldest: expect.any(Number),
newest: expect.any(Number), newest: expect.any(Number),
skipped: false, skipped: false,
transientErrors: 0,
}); });
// Check side-by-side from all notes // Check side-by-side from all notes
@ -278,6 +281,24 @@ describe('CleanRemoteNotesProcessorService', () => {
expect(remainingNote).not.toBeNull(); 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が含まれている時の挙動 // 古いreply, renoteが含まれている時の挙動
test('should handle reply/renote relationships correctly', async () => { test('should handle reply/renote relationships correctly', async () => {
const job = createMockJob(); const job = createMockJob();

View File

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

View File

@ -25,7 +25,7 @@
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.2.0", "@rollup/pluginutils": "5.2.0",
"@sentry/vue": "10.0.0", "@sentry/vue": "10.0.0",
"@syuilo/aiscript": "1.0.0", "@syuilo/aiscript": "1.1.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0", "@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue": "6.0.1",
@ -52,6 +52,7 @@
"icons-subsetter": "workspace:*", "icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"insert-text-at-cursor": "0.3.0", "insert-text-at-cursor": "0.3.0",
"ios-haptics": "0.1.0",
"is-file-animated": "1.0.2", "is-file-animated": "1.0.2",
"json5": "2.2.3", "json5": "2.2.3",
"magic-string": "0.30.17", "magic-string": "0.30.17",

View File

@ -141,6 +141,7 @@ import { $i } from '@/i.js';
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { haptic } from '@/utility/haptic.js';
const router = useRouter(); const router = useRouter();
@ -431,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef,
const key = getKey(emoji); const key = getKey(emoji);
emit('chosen', key); emit('chosen', key);
haptic();
// 使 // 使
if (!pinned.value?.includes(key)) { if (!pinned.value?.includes(key)) {
let recents = store.s.recentlyUsedEmojis; let recents = store.s.recentlyUsedEmojis;

View File

@ -46,6 +46,7 @@ import { claimAchievement } from '@/utility/achievements.js';
import { pleaseLogin } from '@/utility/please-login.js'; import { pleaseLogin } from '@/utility/please-login.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { haptic } from '@/utility/haptic.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed, user: Misskey.entities.UserDetailed,
@ -84,6 +85,8 @@ async function onClick() {
wait.value = true; wait.value = true;
haptic();
try { try {
if (isFollowing.value) { if (isFollowing.value) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({

View File

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

View File

@ -27,6 +27,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { getScrollContainer } from '@@/js/scroll.js'; import { getScrollContainer } from '@@/js/scroll.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
import { haptic } from '@/utility/haptic.js';
const SCROLL_STOP = 10; const SCROLL_STOP = 10;
const MAX_PULL_DISTANCE = Infinity; const MAX_PULL_DISTANCE = Infinity;
@ -203,6 +204,8 @@ function moving(event: MouseEvent | TouchEvent) {
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD;
if (isPulledEnough.value) haptic();
} }
/** /**

View File

@ -38,6 +38,7 @@ import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { noteEvents } from '@/composables/use-note-capture.js'; import { noteEvents } from '@/composables/use-note-capture.js';
import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js'; import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js';
import { haptic } from '@/utility/haptic.js';
const props = defineProps<{ const props = defineProps<{
noteId: Misskey.entities.Note['id']; noteId: Misskey.entities.Note['id'];
@ -80,6 +81,7 @@ async function toggleReaction() {
if (oldReaction !== props.reaction) { if (oldReaction !== props.reaction) {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
haptic();
} }
if (mock) { if (mock) {
@ -118,6 +120,7 @@ async function toggleReaction() {
} }
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
haptic();
if (mock) { if (mock) {
emit('reactionToggled', props.reaction, (props.count + 1)); emit('reactionToggled', props.reaction, (props.count + 1));

View File

@ -132,6 +132,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>{{ serverSettings.enableReactionsBuffering ? i18n.ts.yes : i18n.ts.no }}</div> <div>{{ serverSettings.enableReactionsBuffering ? i18n.ts.yes : i18n.ts.no }}</div>
</div> </div>
<div>
<div><b>{{ i18n.ts._serverSettings.entrancePageStyle }}:</b></div>
<div>{{ serverSettings.clientOptions.entrancePageStyle }}</div>
</div>
<div> <div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div> <div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div>
<div>{{ defaultPolicies.rateLimitFactor }}</div> <div>{{ defaultPolicies.rateLimitFactor }}</div>
@ -233,6 +238,9 @@ const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => {
enableFanoutTimeline: true, enableFanoutTimeline: true,
enableFanoutTimelineDbFallback: q_use.value === 'single', enableFanoutTimelineDbFallback: q_use.value === 'single',
enableReactionsBuffering, enableReactionsBuffering,
clientOptions: {
entrancePageStyle: q_use.value === 'open' ? 'classic' : 'simple',
},
}; };
}); });

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"/> <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template> </template>
</component> </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> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/> <MkLoading v-else :inline="true"/>
</button> </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"/> <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</div> </div>
</component> </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> <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/> <MkLoading v-else/>
</button> </button>

View File

@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { toRefs } from 'vue'; import { toRefs } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import XButton from '@/components/MkSwitch.button.vue'; import XButton from '@/components/MkSwitch.button.vue';
import { haptic } from '@/utility/haptic.js';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean | Ref<boolean>; modelValue: boolean | Ref<boolean>;
@ -48,6 +49,8 @@ const toggle = () => {
if (props.disabled) return; if (props.disabled) return;
emit('update:modelValue', !checked.value); emit('update:modelValue', !checked.value);
emit('change', !checked.value); emit('change', !checked.value);
haptic();
}; };
</script> </script>

View File

@ -4,10 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkModal ref="modal" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> <MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')">
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div>
<div :class="$style.version">{{ version }}🚀</div> <div :class="$style.version">{{ version }}🚀</div>
<div v-if="isBeta" :class="$style.beta">{{ i18n.ts.thankYouForTestingBeta }}</div>
<MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton> <MkButton full @click="whatIsNew">{{ i18n.ts.whatIsNew }}</MkButton>
<MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton> <MkButton :class="$style.gotIt" primary full @click="modal?.close()">{{ i18n.ts.gotIt }}</MkButton>
</div> </div>
@ -25,6 +26,8 @@ import { confetti } from '@/utility/confetti.js';
const modal = useTemplateRef('modal'); const modal = useTemplateRef('modal');
const isBeta = version.includes('-beta') || version.includes('-alpha') || version.includes('-rc');
function whatIsNew() { function whatIsNew() {
modal.value?.close(); modal.value?.close();
window.open(`https://misskey-hub.net/docs/releases/#_${version.replace(/\./g, '')}`, '_blank'); window.open(`https://misskey-hub.net/docs/releases/#_${version.replace(/\./g, '')}`, '_blank');
@ -58,6 +61,10 @@ onMounted(() => {
margin: 1em 0; margin: 1em 0;
} }
.beta {
margin: 1em 0;
}
.gotIt { .gotIt {
margin: 8px 0 0 0; margin: 8px 0 0 0;
} }

View File

@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<div v-if="stats" :class="$style.stats"> <div v-if="stats && instance.clientOptions.showActivityiesForVisitor !== false" :class="$style.stats">
<div :class="[$style.statsItem, $style.panel]"> <div :class="[$style.statsItem, $style.panel]">
<div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div> <div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div>
<div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div> <div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div>
@ -40,13 +40,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div> <div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div>
</div> </div>
</div> </div>
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> <div v-if="instance.policies.ltlAvailable && instance.clientOptions.showTimelineForVisitor !== false" :class="[$style.tl, $style.panel]">
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
<div :class="$style.tlBody"> <div :class="$style.tlBody">
<MkStreamingNotesTimeline src="local"/> <MkStreamingNotesTimeline src="local"/>
</div> </div>
</div> </div>
<div :class="$style.panel"> <div v-if="instance.clientOptions.showActivityiesForVisitor !== false" :class="$style.panel">
<XActiveUsersChart/> <XActiveUsersChart/>
</div> </div>
</div> </div>
@ -55,12 +55,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { instanceName } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { instanceName } from '@@/js/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -68,13 +69,14 @@ import { instance } from '@/instance.js';
import MkNumber from '@/components/MkNumber.vue'; import MkNumber from '@/components/MkNumber.vue';
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
import { openInstanceMenu } from '@/ui/_common_/common.js'; import { openInstanceMenu } from '@/ui/_common_/common.js';
import type { MenuItem } from '@/types/menu.js';
const stats = ref<Misskey.entities.StatsResponse | null>(null); const stats = ref<Misskey.entities.StatsResponse | null>(null);
misskeyApi('stats', {}).then((res) => { if (instance.clientOptions.showActivityiesForVisitor !== false) {
misskeyApi('stats', {}).then((res) => {
stats.value = res; stats.value = res;
}); });
}
function signin() { function signin() {
const { dispose } = os.popup(XSigninDialog, { const { dispose } = os.popup(XSigninDialog, {

View File

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

View File

@ -111,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div> <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> <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>
<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> </div>
</FormSection> </FormSection>
<FormSection> <FormSection>
@ -289,6 +292,9 @@ const patronsWithIcon = [{
}, { }, {
name: 'NigN', name: 'NigN',
icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg', icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg',
}, {
name: 'しゃどかの',
icon: 'https://assets.misskey-hub.net/patrons/5bec3c6b402942619e03f7a2ae76d69e.jpg',
}]; }];
const patrons = [ const patrons = [
@ -403,6 +409,7 @@ const patrons = [
'東雲 琥珀', '東雲 琥珀',
'ほとラズ', 'ほとラズ',
'スズカケン', 'スズカケン',
'蒼井よみこ',
]; ];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure')); 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 === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template> <template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</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"/> <MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
</template> </template>

View File

@ -8,6 +8,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> <div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<SearchMarker path="/admin/branding" :label="i18n.ts.branding" :keywords="['branding']" icon="ti ti-paint"> <SearchMarker path="/admin/branding" :label="i18n.ts.branding" :keywords="['branding']" icon="ti ti-paint">
<div class="_gaps_m"> <div class="_gaps_m">
<SearchMarker :keywords="['entrance', 'welcome', 'landing', 'front', 'home', 'page', 'style']">
<MkRadios v-model="entrancePageStyle">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.entrancePageStyle }}</SearchLabel></template>
<option value="classic">Classic</option>
<option value="simple">Simple</option>
</MkRadios>
</SearchMarker>
<SearchMarker :keywords="['timeline']">
<MkSwitch v-model="showTimelineForVisitor">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.showTimelineForVisitor }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['activity', 'activities']">
<MkSwitch v-model="showActivityiesForVisitor">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.showActivityiesForVisitor }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['icon', 'image']"> <SearchMarker :keywords="['icon', 'image']">
<MkInput v-model="iconUrl" type="url"> <MkInput v-model="iconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template> <template #prefix><i class="ti ti-link"></i></template>
@ -141,9 +161,14 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue'; import MkColorInput from '@/components/MkColorInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');
const entrancePageStyle = ref(meta.clientOptions.entrancePageStyle ?? 'classic');
const showTimelineForVisitor = ref(meta.clientOptions.showTimelineForVisitor ?? true);
const showActivityiesForVisitor = ref(meta.clientOptions.showActivityiesForVisitor ?? true);
const iconUrl = ref(meta.iconUrl); const iconUrl = ref(meta.iconUrl);
const app192IconUrl = ref(meta.app192IconUrl); const app192IconUrl = ref(meta.app192IconUrl);
const app512IconUrl = ref(meta.app512IconUrl); const app512IconUrl = ref(meta.app512IconUrl);
@ -161,6 +186,11 @@ const manifestJsonOverride = ref(meta.manifestJsonOverride === '' ? '{}' : JSON.
function save() { function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
clientOptions: {
entrancePageStyle: entrancePageStyle.value,
showTimelineForVisitor: showTimelineForVisitor.value,
showActivityiesForVisitor: showActivityiesForVisitor.value,
},
iconUrl: iconUrl.value, iconUrl: iconUrl.value,
app192IconUrl: app192IconUrl.value, app192IconUrl: app192IconUrl.value,
app512IconUrl: app512IconUrl.value, app512IconUrl: app512IconUrl.value,

View File

@ -88,7 +88,7 @@ let choices = [
] ]
// PlayID+ID+ // PlayID+ID+
let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`, { algorithm: 'rc4_legacy' }) let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`)
// //
let chosen = choices[random(0, (choices.len - 1))] let chosen = choices[random(0, (choices.len - 1))]

View File

@ -99,6 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="enableFolderPageView"> <MkSwitch v-model="enableFolderPageView">
<template #label>Enable folder page view</template> <template #label>Enable folder page view</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="enableHapticFeedback">
<template #label>Enable haptic feedback</template>
</MkSwitch>
</div> </div>
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
@ -173,6 +176,7 @@ const skipNoteRender = prefer.model('skipNoteRender');
const devMode = prefer.model('devMode'); const devMode = prefer.model('devMode');
const stackingRouterView = prefer.model('experimental.stackingRouterView'); const stackingRouterView = prefer.model('experimental.stackingRouterView');
const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView');
const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback');
watch(skipNoteRender, () => { watch(skipNoteRender, () => {
suggestReload(); suggestReload();

View File

@ -125,16 +125,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
</MkSelect> </MkSelect>
<MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore"> <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore_selection">
<option :value="-3600">{{ i18n.ts.oneHour }}</option> <option v-for="preset in makeNotesFollowersOnlyBefore_presets" :value="preset.value">{{ preset.label }}</option>
<option :value="-86400">{{ i18n.ts.oneDay }}</option> <option value="custom">{{ i18n.ts.custom }}</option>
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
</MkSelect> </MkSelect>
<MkInput
v-if="makeNotesFollowersOnlyBefore_type === 'relative' && makeNotesFollowersOnlyBefore_isCustomMode"
v-model="makeNotesFollowersOnlyBefore_customMonths"
type="number"
:min="1"
>
<template #suffix>{{ i18n.ts._time.month }}</template>
</MkInput>
<MkInput <MkInput
v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" v-if="makeNotesFollowersOnlyBefore_type === 'absolute'"
:modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')"
@ -162,16 +166,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
</MkSelect> </MkSelect>
<MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore"> <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore_selection">
<option :value="-3600">{{ i18n.ts.oneHour }}</option> <option v-for="preset in makeNotesHiddenBefore_presets" :value="preset.value">{{ preset.label }}</option>
<option :value="-86400">{{ i18n.ts.oneDay }}</option> <option value="custom">{{ i18n.ts.custom }}</option>
<option :value="-259200">{{ i18n.ts.threeDays }}</option>
<option :value="-604800">{{ i18n.ts.oneWeek }}</option>
<option :value="-2592000">{{ i18n.ts.oneMonth }}</option>
<option :value="-7776000">{{ i18n.ts.threeMonths }}</option>
<option :value="-31104000">{{ i18n.ts.oneYear }}</option>
</MkSelect> </MkSelect>
<MkInput
v-if="makeNotesHiddenBefore_type === 'relative' && makeNotesHiddenBefore_isCustomMode"
v-model="makeNotesHiddenBefore_customMonths"
type="number"
:min="1"
>
<template #suffix>{{ i18n.ts._time.month }}</template>
</MkInput>
<MkInput <MkInput
v-if="makeNotesHiddenBefore_type === 'absolute'" v-if="makeNotesHiddenBefore_type === 'absolute'"
:modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')"
@ -241,6 +249,37 @@ const makeNotesFollowersOnlyBefore_type = computed(() => {
} }
}); });
const makeNotesFollowersOnlyBefore_presets = [
{ label: i18n.ts.oneHour, value: -3600 },
{ label: i18n.ts.oneDay, value: -86400 },
{ label: i18n.ts.threeDays, value: -259200 },
{ label: i18n.ts.oneWeek, value: -604800 },
{ label: i18n.ts.oneMonth, value: -2592000 },
{ label: i18n.ts.threeMonths, value: -7776000 },
{ label: i18n.ts.oneYear, value: -31104000 },
];
const makeNotesFollowersOnlyBefore_isCustomMode = ref(
makeNotesFollowersOnlyBefore.value != null &&
makeNotesFollowersOnlyBefore.value < 0 &&
!makeNotesFollowersOnlyBefore_presets.some((preset) => preset.value === makeNotesFollowersOnlyBefore.value)
);
const makeNotesFollowersOnlyBefore_selection = computed({
get: () => makeNotesFollowersOnlyBefore_isCustomMode.value ? 'custom' : makeNotesFollowersOnlyBefore.value,
set(value) {
makeNotesFollowersOnlyBefore_isCustomMode.value = value === 'custom';
if (value !== 'custom') makeNotesFollowersOnlyBefore.value = value;
}
});
const makeNotesFollowersOnlyBefore_customMonths = computed({
get: () => makeNotesFollowersOnlyBefore.value ? Math.abs(makeNotesFollowersOnlyBefore.value) / (30 * 24 * 60 * 60) : null,
set(value) {
if (value != null && value > 0) makeNotesFollowersOnlyBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60;
}
});
const makeNotesHiddenBefore_type = computed(() => { const makeNotesHiddenBefore_type = computed(() => {
if (makeNotesHiddenBefore.value == null) { if (makeNotesHiddenBefore.value == null) {
return null; return null;
@ -251,6 +290,37 @@ const makeNotesHiddenBefore_type = computed(() => {
} }
}); });
const makeNotesHiddenBefore_presets = [
{ label: i18n.ts.oneHour, value: -3600 },
{ label: i18n.ts.oneDay, value: -86400 },
{ label: i18n.ts.threeDays, value: -259200 },
{ label: i18n.ts.oneWeek, value: -604800 },
{ label: i18n.ts.oneMonth, value: -2592000 },
{ label: i18n.ts.threeMonths, value: -7776000 },
{ label: i18n.ts.oneYear, value: -31104000 },
];
const makeNotesHiddenBefore_isCustomMode = ref(
makeNotesHiddenBefore.value != null &&
makeNotesHiddenBefore.value < 0 &&
!makeNotesHiddenBefore_presets.some((preset) => preset.value === makeNotesHiddenBefore.value)
);
const makeNotesHiddenBefore_selection = computed({
get: () => makeNotesHiddenBefore_isCustomMode.value ? 'custom' : makeNotesHiddenBefore.value,
set(value) {
makeNotesHiddenBefore_isCustomMode.value = value === 'custom';
if (value !== 'custom') makeNotesHiddenBefore.value = value;
}
});
const makeNotesHiddenBefore_customMonths = computed({
get: () => makeNotesHiddenBefore.value ? Math.abs(makeNotesHiddenBefore.value) / (30 * 24 * 60 * 60) : null,
set(value) {
if (value != null && value > 0) makeNotesHiddenBefore.value = -Math.abs(Math.floor(Number(value))) * 30 * 24 * 60 * 60;
}
});
watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => { watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => {
save(); save();
}); });

View File

@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="meta" :class="$style.root">
<MkFeaturedPhotos :class="$style.bg"/>
<div :class="$style.logoWrapper">
<div :class="$style.poweredBy">Powered by</div>
<img :src="misskeysvg" :class="$style.misskey"/>
</div>
<div :class="$style.contents">
<MkVisitorDashboard/>
</div>
</div>
</template>
<script lang="ts" setup>
import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
import misskeysvg from '/client-assets/misskey.svg';
import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
import { instance as meta } from '@/instance.js';
</script>
<style lang="scss" module>
.root {
height: 100cqh;
overflow: auto;
overscroll-behavior: contain;
}
.bg {
position: fixed;
top: 0;
right: 0;
width: 80vw; // 100%shape
height: 100vh;
}
.logoWrapper {
position: fixed;
top: 36px;
left: 36px;
flex: auto;
color: #fff;
user-select: none;
pointer-events: none;
}
.poweredBy {
margin-bottom: 2px;
}
.misskey {
width: 120px;
@media (max-width: 450px) {
width: 100px;
}
}
.contents {
position: relative;
width: min(430px, calc(100% - 32px));
margin: auto;
padding: 100px 0 100px 0;
}
</style>

View File

@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div v-if="instance"> <div v-if="instance">
<XSetup v-if="instance.requireSetup"/> <XSetup v-if="instance.requireSetup"/>
<XEntrance v-else/> <XEntranceClassic v-else-if="(instance.clientOptions.entrancePageStyle ?? 'classic') === 'classic'"/>
<XEntranceSimple v-else/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XSetup from './welcome.setup.vue';
import XEntrance from './welcome.entrance.a.vue';
import { instanceName } from '@@/js/config.js'; import { instanceName } from '@@/js/config.js';
import XSetup from './welcome.setup.vue';
import XEntranceClassic from './welcome.entrance.classic.vue';
import XEntranceSimple from './welcome.entrance.simple.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { fetchInstance } from '@/instance.js'; import { fetchInstance } from '@/instance.js';

View File

@ -498,4 +498,7 @@ export const PREF_DEF = definePreferences({
'experimental.enableFolderPageView': { 'experimental.enableFolderPageView': {
default: false, default: false,
}, },
'experimental.enableHapticFeedback': {
default: false,
},
}); });

View File

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

View File

@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { haptic as _haptic } from 'ios-haptics';
import { prefer } from '@/preferences.js';
export function haptic() {
if (prefer.s['experimental.enableHapticFeedback']) {
_haptic();
}
}

View File

@ -320,7 +320,7 @@ describe('AiScript UI API', () => {
const { root, get, outputs } = await exe(` const { root, get, outputs } = await exe(`
let text_input = Ui:C:textInput({ let text_input = Ui:C:textInput({
onInput: print onInput: print
"default": 'a' default: 'a'
label: 'b' label: 'b'
caption: 'c' caption: 'c'
}, 'id') }, 'id')
@ -361,7 +361,7 @@ describe('AiScript UI API', () => {
const { root, get, outputs } = await exe(` const { root, get, outputs } = await exe(`
let textarea = Ui:C:textarea({ let textarea = Ui:C:textarea({
onInput: print onInput: print
"default": 'a' default: 'a'
label: 'b' label: 'b'
caption: 'c' caption: 'c'
}, 'id') }, 'id')
@ -402,7 +402,7 @@ describe('AiScript UI API', () => {
const { root, get, outputs } = await exe(` const { root, get, outputs } = await exe(`
let number_input = Ui:C:numberInput({ let number_input = Ui:C:numberInput({
onInput: print onInput: print
"default": 1 default: 1
label: 'a' label: 'a'
caption: 'b' caption: 'b'
}, 'id') }, 'id')
@ -564,7 +564,7 @@ describe('AiScript UI API', () => {
const { root, get, outputs } = await exe(` const { root, get, outputs } = await exe(`
let switch = Ui:C:switch({ let switch = Ui:C:switch({
onChange: print onChange: print
"default": false default: false
label: 'a' label: 'a'
caption: 'b' caption: 'b'
}, 'id') }, 'id')
@ -609,7 +609,7 @@ describe('AiScript UI API', () => {
{ text: 'B', value: 'b' } { text: 'B', value: 'b' }
] ]
onChange: print onChange: print
"default": 'a' default: 'a'
label: 'c' label: 'c'
caption: 'd' caption: 'd'
}, 'id') }, 'id')

View File

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2025.8.0-alpha.7", "version": "2025.8.0-beta.1",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View File

@ -5336,6 +5336,7 @@ export type components = {
feedbackUrl: string | null; feedbackUrl: string | null;
defaultDarkTheme: string | null; defaultDarkTheme: string | null;
defaultLightTheme: string | null; defaultLightTheme: string | null;
clientOptions: Record<string, never>;
disableRegistration: boolean; disableRegistration: boolean;
emailRequiredForSignup: boolean; emailRequiredForSignup: boolean;
enableHcaptcha: boolean; enableHcaptcha: boolean;
@ -9346,6 +9347,7 @@ export interface operations {
deeplIsPro: boolean; deeplIsPro: boolean;
defaultDarkTheme: string | null; defaultDarkTheme: string | null;
defaultLightTheme: string | null; defaultLightTheme: string | null;
clientOptions: Record<string, never>;
description: string | null; description: string | null;
disableRegistration: boolean; disableRegistration: boolean;
impressumUrl: string | null; impressumUrl: string | null;
@ -12581,6 +12583,7 @@ export interface operations {
description?: string | null; description?: string | null;
defaultLightTheme?: string | null; defaultLightTheme?: string | null;
defaultDarkTheme?: string | null; defaultDarkTheme?: string | null;
clientOptions?: Record<string, never>;
cacheRemoteFiles?: boolean; cacheRemoteFiles?: boolean;
cacheRemoteSensitiveFiles?: boolean; cacheRemoteSensitiveFiles?: boolean;
emailRequiredForSignup?: boolean; emailRequiredForSignup?: boolean;

File diff suppressed because it is too large Load Diff