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

This commit is contained in:
かっこかり 2025-08-09 15:02:55 +09:00 committed by GitHub
commit 30a1c5dfc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 3399 additions and 1624 deletions

View File

@ -50,6 +50,7 @@ jobs:
"packages/backend/test"
"packages/frontend-shared/@types"
"packages/frontend-shared/js"
"packages/frontend-builder"
"packages/frontend/.storybook"
"packages/frontend/@types"
"packages/frontend/lib"

View File

@ -9,6 +9,7 @@ on:
- packages/backend/**
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/frontend-embed/**
- packages/icons-subsetter/**
- packages/sw/**
@ -22,6 +23,7 @@ on:
- packages/backend/**
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/frontend-embed/**
- packages/icons-subsetter/**
- packages/sw/**
@ -56,6 +58,7 @@ jobs:
- backend
- frontend
- frontend-shared
- frontend-builder
- frontend-embed
- icons-subsetter
- sw

View File

@ -17,12 +17,13 @@
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
- Enhance: acctに `.` が入っているユーザーのメンションに対応
- Fix: Unicode絵文字に隣接する異体字セレクタ`U+FE0F`)が絵文字として認識される問題を修正
- ノートの投稿に関するロールポリシーを強化しました
- Enhance: ノートの投稿に関するロールポリシーを強化しました
- ノートの投稿を一切禁止できるように
- リノート・引用の可否
- 指名ノートの投稿の可否
- 連合するノートの投稿の可否
- ノートに添付できるファイルの最大数
- Enhance: ユーザー検索をロールポリシーで制限できるように
### Client
- Feat: AiScriptが1.0に更新されました
@ -38,14 +39,21 @@
- Feat: ページのタブバーを下部に表示できるように
- Enhance: コントロールパネルを検索できるように
- Enhance: トルコ語 (tr-TR) に対応
- Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました
- Enhance: 画像エフェクトのパラメータ名の多言語対応
- Enhance: 依存ソフトウェアの更新
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
- Fix: テーマエディタが動作しない問題を修正
- Fix: チャンネルのハイライトページにノートが表示されない問題を修正
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
### Server
- Enhance: ノートの削除処理の効率化
- Enhance: 全体的なパフォーマンスの向上
- Enhance: 依存ソフトウェアの更新
- Fix: SystemWebhook設定でsecretを空に出来ない問題を修正
## 2025.7.0

View File

@ -23,6 +23,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"]
COPY --link ["packages/frontend/package.json", "./packages/frontend/"]
COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"]
COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"]
COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"]
COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]

View File

@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
showUrlPreview: "Mostrar vista prèvia d'URL"
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
showPageTabBarBottom: "Mostrar les pestanyes de les línies de temps a la part inferior"
_chat:
showSenderName: "Mostrar el nom del remitent"
sendOnEnter: "Introdueix per enviar"

View File

@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
showUrlPreview: "Mostrar la vista previa de la URL"
showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior."
showPageTabBarBottom: "Mostrar la barra de pestañas de la página en la parte inferior."
_chat:
showSenderName: "Mostrar el nombre del remitente"
sendOnEnter: "Intro para enviar"

View File

@ -73,7 +73,7 @@ export default function generateDTS() {
ts.NodeFlags.Const,
),
),
ts.factory.createInterfaceDeclaration(
ts.factory.createTypeAliasDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('ParameterizedString'),
[
@ -84,20 +84,22 @@ export default function generateDTS() {
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
undefined,
[
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
ts.factory.createIdentifier('kParameters'),
),
undefined,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('T'),
ts.factory.createIntersectionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createTypeLiteralNode([
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
ts.factory.createIdentifier('kParameters'),
),
undefined,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('T'),
undefined,
),
),
),
],
])
]),
),
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],

150
locales/index.d.ts vendored
View File

@ -2,9 +2,9 @@
// This file is generated by locales/generateDTS.js
// Do not edit this file directly.
declare const kParameters: unique symbol;
export interface ParameterizedString<T extends string = string> {
export type ParameterizedString<T extends string = string> = string & {
[kParameters]: T;
}
};
export interface ILocale {
[_: string]: string | ParameterizedString | ILocale;
}
@ -4386,6 +4386,10 @@ export interface Locale extends ILocale {
*
*/
"notesSearchNotAvailable": string;
/**
*
*/
"usersSearchNotAvailable": string;
/**
*
*/
@ -7803,6 +7807,10 @@ export interface Locale extends ILocale {
*
*/
"canSearchNotes": string;
/**
*
*/
"canSearchUsers": string;
/**
*
*/
@ -12239,6 +12247,10 @@ export interface Locale extends ILocale {
*
*/
"advanced": string;
/**
*
*/
"angle": string;
/**
*
*/
@ -12251,10 +12263,6 @@ export interface Locale extends ILocale {
*
*/
"stripeFrequency": string;
/**
*
*/
"angle": string;
/**
*
*/
@ -12297,6 +12305,10 @@ export interface Locale extends ILocale {
*
*/
"discardChangesConfirm": string;
/**
*
*/
"nothingToConfigure": string;
"_fxs": {
/**
*
@ -12363,6 +12375,132 @@ export interface Locale extends ILocale {
*/
"tearing": string;
};
"_fxProps": {
/**
*
*/
"angle": string;
/**
*
*/
"scale": string;
/**
*
*/
"size": string;
/**
*
*/
"color": string;
/**
*
*/
"opacity": string;
/**
*
*/
"normalize": string;
/**
*
*/
"amount": string;
/**
*
*/
"lightness": string;
/**
*
*/
"contrast": string;
/**
*
*/
"hue": string;
/**
*
*/
"brightness": string;
/**
*
*/
"saturation": string;
/**
*
*/
"max": string;
/**
*
*/
"min": string;
/**
*
*/
"direction": string;
/**
*
*/
"phase": string;
/**
*
*/
"frequency": string;
/**
*
*/
"strength": string;
/**
*
*/
"glitchChannelShift": string;
/**
*
*/
"seed": string;
/**
*
*/
"redComponent": string;
/**
*
*/
"greenComponent": string;
/**
*
*/
"blueComponent": string;
/**
*
*/
"threshold": string;
/**
* X
*/
"centerX": string;
/**
* Y
*/
"centerY": string;
/**
*
*/
"zoomLinesSmoothing": string;
/**
*
*/
"zoomLinesSmoothingDescription": string;
/**
*
*/
"zoomLinesThreshold": string;
/**
*
*/
"zoomLinesMaskSize": string;
/**
*
*/
"zoomLinesBlack": string;
};
};
/**
*

View File

@ -1198,7 +1198,7 @@ replies: "Risposte"
renotes: "Rinota"
loadReplies: "Leggi le risposte"
loadConversation: "Leggi la conversazione"
pinnedList: "Elenco in primo piano"
pinnedList: "Lista in primo piano"
keepScreenOn: "Mantenere lo schermo acceso"
verifiedLink: "Abbiamo confermato la validità di questo collegamento"
notifyNotes: "Notifica nuove Note"
@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "Livello predefinito di compressione immagini"
defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine."
inMinutes: "min"
inDays: "giorni"
safeModeEnabled: "La modalità sicura è attiva"
pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la modalità sicura è attiva."
customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva."
themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente."
_order:
newest: "Prima i più recenti"
oldest: "Meno recenti prima"
@ -1461,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
showUrlPreview: "Mostra anteprima dell'URL"
showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto"
showPageTabBarBottom: "Visualizza le schede della pagina nella parte inferiore"
_chat:
showSenderName: "Mostra il nome del mittente"
sendOnEnter: "Invio spedisce"
@ -1634,6 +1639,10 @@ _serverSettings:
fanoutTimelineDbFallback: "Elaborazione dati alternativa"
fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline."
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
remoteNotesCleaning: "Pulizia automatica dei contenuti remoti"
remoteNotesCleaning_description: "Se abilitata, verranno periodicamente rimosse le vecchie Note remote senza relazioni, per ridurre il sovraccarico del sistema."
remoteNotesCleaningMaxProcessingDuration: "Durata massima del processo di pulizia"
remoteNotesCleaningExpiryDaysForEachNotes: "Periodo minimo di conservazione delle note"
inquiryUrl: "URL di contatto"
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
openRegistration: "Registrazioni aperte"
@ -1652,6 +1661,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "Visibilità dei contenuti generati dagli utenti ai non utenti"
userGeneratedContentsVisibilityForVisitor_description: "Questa funzionalità è utile per impedire che contenuti remoti inappropriati e difficili da moderare vengano inavvertitamente resi pubblici su Internet tramite il proprio server."
userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server."
restartServerSetupWizardConfirm_title: "Vuoi ripetere la procedura guidata di configurazione iniziale del server?"
restartServerSetupWizardConfirm_text: "Verranno ripristinate alcune tue impostazioni personalizzate."
_userGeneratedContentsVisibilityForVisitor:
all: "Tutto pubblico"
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
@ -3062,6 +3073,7 @@ _bootErrors:
otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache"
otherOption2: "Avviare il client predefinito"
otherOption3: "Avviare lo strumento di riparazione"
otherOption4: "Avvia Misskey in modalità sicura"
_search:
searchScopeAll: "Tutte"
searchScopeLocal: "Locale"
@ -3098,6 +3110,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "Collegandosi a una rete di server distribuiti, denominata Fediverso, potrai scambiare contenuti con altri server, tramite il protocollo di comunicazione ActivityPub."
doYouConnectToFediverse_description2: "Connettersi al Fediverso è anche detto \"federazione\"."
youCanConfigureMoreFederationSettingsLater: "Puoi svolgere la configurazione avanzata anche dopo. Ad esempio specificando quali server possono federarsi."
remoteContentsCleaning: "Pulizia automatica dei contenuti in arrivo"
remoteContentsCleaning_description: "Con la federazione funzionante, riceverai sempre più contenuti. Abilitando la pulizia automatica, i contenuti non referenziati e obsoleti verranno rimossi automaticamente dai tuoi server, risparmiando spazio di archiviazione."
adminInfo: "Informazioni sull'amministratore"
adminInfo_description: "Imposta le informazioni dell'amministratore utilizzate per accettare le richieste."
adminInfo_mustBeFilled: "Questa operazione è necessaria su un server aperto o se è attiva la federazione."

View File

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

View File

@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
showUrlPreview: "URL 미리보기 표시"
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
showPageTabBarBottom: "페이지의 탭 바를 아래쪽에 표시"
_chat:
showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기"

View File

@ -1461,6 +1461,7 @@ _settings:
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
showUrlPreview: "แสดงตัวอย่าง URL"
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
showPageTabBarBottom: "แสดงแท็บบาร์ของเพจที่ด้านล่าง"
_chat:
showSenderName: "แสดงชื่อผู้ส่ง"
sendOnEnter: "กด Enter เพื่อส่ง"

View File

@ -1,6 +1,6 @@
---
_lang_: "Türkçe"
headlineMisskey: "Notlarla birbirine bağlanan bir ağ"
headlineMisskey: "Notlarla birbirine bağlı bir ağ"
introMisskey: "Hoş geldiniz! Misskey, açık kaynaklı, merkezi olmayan bir mikroblog hizmetidir.\nDüşüncelerinizi çevrenizdeki herkesle paylaşmak için “notlar” oluşturun. 📡\n“Tepkiler” ile herkesin notları hakkındaki duygularınızı hızlıca ifade edebilirsiniz. 👍\nYeni bir dünyayı keşfedelim! 🚀"
poweredByMisskeyDescription: "{name}, açık kaynak platformu <b>Misskey</b> (kısaca “Misskey örneği” olarak anılır) tarafından desteklenen hizmetlerden biridir."
monthAndDay: "{month}/{day}"
@ -23,7 +23,7 @@ renotedBy: "{user} tarafından renot edildi"
noNotes: "Not yok"
noNotifications: "Bildirim yok"
instance: "Sunucu"
settings: "Ayarlarlar"
settings: "Ayarlar"
notificationSettings: "Bildirim Ayarları"
basicSettings: "Temel Ayarlar"
otherSettings: "Diğer Ayarlar"
@ -237,7 +237,7 @@ clearQueueConfirmText: "Kuyrukta kalan teslim edilmemiş notlar birleştirilmeye
clearCachedFiles: "Clear cache"
clearCachedFilesConfirm: "Tüm önbelleğe alınmış uzak dosyaları silmek istediğinizden emin misiniz?"
blockedInstances: "Engellenen Sunucu"
blockedInstancesDescription: "Engellemek istediğiniz örneklerin ana bilgisayar adlarını satır sonlarıyla ayırarak listeleyin. Listelenen örnekler artık bu örnekle iletişim kuramayacaktır."
blockedInstancesDescription: "Engellemek istediğiniz sunucuların ana bilgisayar adlarını satır sonlarıyla ayırarak listeleyin. Listelenen örnekler artık bu örnekle iletişim kuramayacaktır."
silencedInstances: "Susturulmuş sunucular"
silencedInstancesDescription: "Sessize almak istediğiniz sunucuların ana bilgisayar adlarını yeni bir satırla ayırarak listeleyin. Listelenen sunuculara ait tüm hesaplar sessize alınmış olarak kabul edilecek ve yalnızca takip isteklerinde bulunabilecek, takip edilmedikleri takdirde yerel hesapları etiketleyemeyeceklerdir. Bu, engellenen sunucuları etkilemeyecektir."
mediaSilencedInstances: "Medya susturulmuş sunucular"
@ -311,7 +311,7 @@ basicNotesBeforeCreateAccount: "Önemli notlar"
termsOfService: "Hizmet Şartları"
start: "Başla"
home: "Ana sayfa"
remoteUserCaution: "Bu kullanıcı uzak bir örnekten geldiği için, gösterilen bilgiler eksik olabilir."
remoteUserCaution: "Bu kullanıcı uzak bir sunucudan geldiği için, gösterilen bilgiler eksik olabilir."
activity: "Etkinlik"
images: "Görseller"
image: "Görsel"
@ -499,8 +499,8 @@ signinOrContinueOnRemote: "Devam etmek için sunucunuzu taşıyın veya bu sunuc
invitations: "Davetler"
invitationCode: "Davet kodu"
checking: "Kontrol ediliyor..."
available: "Mevcut"
unavailable: "Mevcut değil"
available: "Kullanılabilir"
unavailable: "Kullanılamaz"
usernameInvalidFormat: "Büyük ve küçük harfler, rakamlar ve alt çizgi kullanabilirsiniz. (a~z、A~Z、0~9)"
tooShort: "Çok kısa"
tooLong: "Çok uzun"
@ -619,7 +619,7 @@ unsetUserBannerConfirm: "Banner'ı kaldırmak istediğinizden emin misiniz?"
deleteAllFiles: "Tüm dosyaları sil"
deleteAllFilesConfirm: "Tüm dosyaları silmek istediğinizden emin misiniz?"
removeAllFollowing: "Takip ettiğiniz tüm kullanıcıları takipten çıkarın"
removeAllFollowingDescription: "Bu komutu çalıştırmak, {host} adresindeki tüm hesapları takipten çıkarır. Örneğin, örnek artık mevcut değilse bu komutu çalıştırın."
removeAllFollowingDescription: "Bu komutu çalıştırmak, {host} adresindeki tüm hesapları takipten çıkarır. Örneğin, sunucu artık mevcut değilse bu komutu çalıştırın."
userSuspended: "Bu kullanıcı askıya alınmıştır."
userSilenced: "Bu kullanıcı susturuluyor."
yourAccountSuspendedTitle: "Bu hesap askıya alınmıştır."
@ -1066,19 +1066,19 @@ collapseRenotesDescription: "Daha önce tepki verdiğiniz veya yeniden not aldı
internalServerError: "İç Sunucu Hatası"
internalServerErrorDescription: "Sunucu beklenmedik bir hatayla karşılaştı."
copyErrorInfo: "Hata ayrıntılarını kopyala"
joinThisServer: "Bu örnekte kaydolun"
exploreOtherServers: "Başka bir örnek arayın"
joinThisServer: "Bu sunucuda kaydolun"
exploreOtherServers: "Başka bir sunucu arayın"
letsLookAtTimeline: "Timeline'a bir göz atın"
disableFederationConfirm: "Federasyonu gerçekten devre dışı bırakmak mı?"
disableFederationConfirmWarn: "Federasyondan ayrılsa bile, aksi belirtilmedikçe gönderiler herkese açık olmaya devam edecektir. Genellikle bunu yapmanız gerekmez."
disableFederationOk: "Devre Dışı"
invitationRequiredToRegister: "Bu etkinlik davetle katılımlıdır. Geçerli bir davet kodu girerek kaydolmanız gerekir."
emailNotSupported: "Bu örnek, E-Posta göndermeyi desteklemiyor."
emailNotSupported: "Bu sunucu, E-Posta göndermeyi desteklemiyor."
postToTheChannel: "Kanalına gönder"
cannotBeChangedLater: "Bu daha sonra değiştirilemez."
reactionAcceptance: "Tepki Kabulü"
likeOnly: "Sadece beğeniler"
likeOnlyForRemote: "Tüm (Yalnızca uzak örnekler için beğeniler)"
likeOnlyForRemote: "Tüm (Yalnızca uzak sunucu için beğeniler)"
nonSensitiveOnly: "Hassas olmayanlar için"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Yalnızca hassas olmayanlar (Yalnızca uzaktan beğeniler)"
rolesAssignedToMe: "Bana atanan roller"
@ -1100,7 +1100,7 @@ retryAllQueuesNow: "Tüm kuyrukları yeniden çalıştırmayı deneyin"
retryAllQueuesConfirmTitle: "Gerçekten hepsini tekrar denemek istiyor musunuz?"
retryAllQueuesConfirmText: "Bu, sunucu yükünü geçici olarak artıracaktır."
enableChartsForRemoteUser: "Uzak kullanıcı veri grafikleri oluşturun"
enableChartsForFederatedInstances: "Uzak örnek veri grafikleri oluşturun"
enableChartsForFederatedInstances: "Uzak sunucu veri grafikleri oluşturun"
enableStatsForFederatedInstances: "Uzak sunucu istatistiklerini alın"
showClipButtonInNoteFooter: "Not eylem menüsüne “Klip” ekle"
reactionsDisplaySize: "Tepki ekran boyutu"
@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "Gerçek zamanlı mod açık olduğunda, bu ayardan bağımsız olarak içerik gerçek zamanlı olarak güncellenir."
showUrlPreview: "URL önizlemesini göster"
showAvailableReactionsFirstInNote: "Mevcut tepkileri en üstte göster."
showPageTabBarBottom: "Sayfa sekme çubuğunu aşağıda göster"
_chat:
showSenderName: "Gönderenin adını göster"
sendOnEnter: "Enter tuşuna basarak gönderin"
@ -1911,7 +1912,7 @@ _achievements:
_loggedInOnNewYearsDay:
title: "Yeni yılınız kutlu olsun!"
description: "Yılın ilk gününde oturum açıldı"
flavor: "Bu örnekte bir başka harika yıla"
flavor: "Bu sunucuda bir başka harika yıla"
_cookieClicked:
title: "Çerezleri tıklayarak oynanan bir oyun"
description: "Çerezi tıkladı"
@ -1976,7 +1977,7 @@ _role:
ltlAvailable: "Yerel zaman çizelgesini görüntüleyebilir"
canPublicNote: "Halka açık notlar gönderebilir"
mentionMax: "Bir notta maksimum bahsetme sayısı"
canInvite: "Örnek davet kodları oluşturabilir"
canInvite: "Sunucu davet kodları oluşturabilir"
inviteLimit: "Davet sınırı"
inviteLimitCycle: "Davet sınırı bekleme süresi"
inviteExpirationTime: "Davet süresi dolma aralığı"
@ -2072,7 +2073,7 @@ _ad:
adsTooClose: "Mevcut reklam aralığı çok düşük olduğu için kullanıcı deneyimini önemli ölçüde kötüleştirebilir."
_forgotPassword:
enterEmail: "Kayıt olurken kullandığınız E-Posta adresini girin. Şifrenizi sıfırlayabileceğiniz bir bağlantı bu adrese gönderilecektir."
ifNoEmail: "Kayıt sırasında E-Posta kullanmadıysanız, lütfen bunun yerine örnek yöneticisiyle iletişime geçin."
ifNoEmail: "Kayıt sırasında E-Posta kullanmadıysanız, lütfen bunun yerine sunucu yöneticisiyle iletişime geçin."
contactAdmin: "This instance does not support using email addresses, please contact the instance administrator to reset your password instead."
_gallery:
my: "Benim Galerim"

View File

@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
showUrlPreview: "显示 URL 预览"
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
showPageTabBarBottom: "在下方显示页面标签栏"
_chat:
showSenderName: "显示发送者的名字"
sendOnEnter: "回车键发送"

View File

@ -1373,7 +1373,7 @@ inDays: "日"
safeModeEnabled: "啟用安全模式"
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
themeIsDefaultBecauseSafeMode: "啟用安全模式時將使用預設主題,關閉安全模式時將恢復預設主題。"
themeIsDefaultBecauseSafeMode: "在安全模式啟用期間將使用預設主題。關閉安全模式後會恢復原本的設定。"
_order:
newest: "最新的在前"
oldest: "最舊的在前"
@ -1465,6 +1465,7 @@ _settings:
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
showUrlPreview: "顯示網址預覽"
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
showPageTabBarBottom: "在底部顯示頁面的標籤列"
_chat:
showSenderName: "顯示發送者的名稱"
sendOnEnter: "按下 Enter 發送訊息"
@ -1549,7 +1550,7 @@ _initialAccountSetting:
theseSettingsCanEditLater: "這裡的設定可以在之後變更。"
youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。"
followUsers: "為了構築時間軸,試著追隨您感興趣的使用者吧。"
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自{name}的通知了。"
pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自 {name} 的通知了。"
initialAccountSettingCompleted: "初始設定完成了!"
haveFun: "盡情享受{name}吧!"
youCanContinueTutorial: "您可以繼續學習如何使用{name}(Misskey),也可以就此打住,立即開始使用。"

View File

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

View File

@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json',
'-e', 'ts,js,mjs,cjs,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{

View File

@ -184,9 +184,9 @@ export type Config = {
authUrl: string;
driveUrl: string;
userAgent: string;
frontendEntry: string;
frontendEntry: { file: string | null };
frontendManifestExists: boolean;
frontendEmbedEntry: string;
frontendEmbedEntry: { file: string | null };
frontendEmbedManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@ -235,10 +235,10 @@ export function loadConfig(): Config {
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
const frontendManifest = frontendManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
: { 'src/_boot_.ts': { file: null } };
const frontendEmbedManifest = frontendEmbedManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: 'src/boot.ts' } };
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;

View File

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

View File

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

View File

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

View File

@ -5,9 +5,8 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common';
import { And, Brackets, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js';
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
@ -25,12 +24,6 @@ export class CleanRemoteNotesProcessorService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteFavoritesRepository)
private noteFavoritesRepository: NoteFavoritesRepository,
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
private idService: IdService,
private queueLoggerService: QueueLoggerService,
) {
@ -61,6 +54,69 @@ export class CleanRemoteNotesProcessorService {
const MAX_NOTE_COUNT_PER_QUERY = 50;
//#retion queries
// We use string literals instead of query builder for several reasons:
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
// The condition for removing the notes.
// The note must be:
// - old enough (older than the newestLimit)
// - a remote note (userHost is not null).
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
const removeCondition = 'note.id < :newestLimit'
+ ' AND note."clippedCount" = 0'
+ ' AND note."userHost" IS NOT NULL'
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
// This is safe because notes are only pinned by the user who created them.
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
// We cannot use userId trick because users can favorite notes from other users.
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
;
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
const initiatorQuery = this.notesRepository.createQueryBuilder('note')
.select('note.id', 'id')
.where(removeCondition)
.andWhere('note.id > :cursor')
.orderBy('note.id', 'ASC')
.limit(MAX_NOTE_COUNT_PER_QUERY);
// The union query queries the related notes and replies related to the initiator query
const unionQuery = `
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
FROM "note" "note"
INNER JOIN "related_notes" "rn"
ON "note"."replyId" = rn.id
OR "note"."renoteId" = rn.id
OR "note"."id" = rn."replyId"
OR "note"."id" = rn."renoteId"
`;
const selectRelatedNotesFromInitiatorIdsQuery = `
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
FROM "note" "note" WHERE "note"."id" IN (:...initiatorIds)
`;
const recursiveQuery = `(${selectRelatedNotesFromInitiatorIdsQuery}) UNION (${unionQuery})`;
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
.select('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.groupBy('rn."initiatorId"')
.having(`bool_and(${removeCondition})`);
const notesQuery = this.notesRepository.createQueryBuilder('note')
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
.select('note.id', 'id')
.addSelect('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
.distinctOn(['note.id']);
//#endregion
const stats = {
deletedCount: 0,
oldest: null as number | null,
@ -74,77 +130,45 @@ export class CleanRemoteNotesProcessorService {
let cursor = '0'; // oldest note ID to start from
while (true) {
//#region check time
const batchBeginAt = Date.now();
// We use string literals instead of query builder for several reasons:
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
const elapsed = batchBeginAt - startAt;
// The condition for removing the notes.
// The note must be:
// - old enough (older than the newestLimit)
// - a remote note (userHost is not null).
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
const removeCondition = 'note.id < :newestLimit'
+ ' AND note."clippedCount" = 0'
+ ' AND note."userHost" IS NOT NULL'
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
// This is safe because notes are only pinned by the user who created them.
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
// We cannot use userId trick because users can favorite notes from other users.
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
;
if (elapsed >= maxDuration) {
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
job.log('Reached maximum duration, stopping cleaning.');
job.updateProgress(100);
break;
}
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
const initiatorQuery = `
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
FROM "note" "note" WHERE ${removeCondition} AND "note"."id" > :cursor ORDER BY "note"."id" ASC LIMIT ${MAX_NOTE_COUNT_PER_QUERY}`;
job.updateProgress((elapsed / maxDuration) * 100);
//#endregion
// The union query queries the related notes and replies related to the initiator query
const unionQuery = `
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
FROM "note" "note"
INNER JOIN "related_notes" "rn"
ON "note"."replyId" = rn.id
OR "note"."renoteId" = rn.id
OR "note"."id" = rn."replyId"
OR "note"."id" = rn."renoteId"
`;
const recursiveQuery = `(${initiatorQuery}) UNION (${unionQuery})`;
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
.select('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.groupBy('rn."initiatorId"')
.having(`bool_and(${removeCondition})`);
const notesQuery = this.notesRepository.createQueryBuilder('note')
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
.select('note.id', 'id')
.addSelect('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.where(`rn."initiatorId" IN (${ removableInitiatorNotesQuery.getQuery() })`)
.setParameters({ cursor, newestLimit });
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.getRawMany();
const fetchedCount = notes.length;
// First, we fetch the initiator notes that are older than the newestLimit.
const initiatorNotes: { id: MiNote['id'] }[] = await initiatorQuery.setParameters({ cursor, newestLimit }).getRawMany();
// update the cursor to the newest initiatorId found in the fetched notes.
// We don't use 'id' since the note can be newer than the initiator note.
for (const note of notes) {
if (cursor < note.initiatorId) {
cursor = note.initiatorId;
}
const newCursor = initiatorNotes.reduce((max, note) => note.id > max ? note.id : max, cursor);
if (initiatorNotes.length === 0 || cursor === newCursor || newCursor >= newestLimit) {
// If no notes were found or the cursor did not change, we can stop.
job.log('No more notes to clean. (no initiator notes found or cursor did not change.)');
break;
}
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.setParameters({
initiatorIds: initiatorNotes.map(note => note.id),
newestLimit,
}).getRawMany();
cursor = newCursor;
if (notes.length > 0) {
await this.notesRepository.delete(notes.map(note => note.id));
for (const note of notes) {
const t = this.idService.parse(note.id).date.getTime();
for (const { id } of notes) {
const t = this.idService.parse(id).date.getTime();
if (stats.oldest === null || t < stats.oldest) {
stats.oldest = t;
}
@ -156,19 +180,14 @@ export class CleanRemoteNotesProcessorService {
stats.deletedCount += notes.length;
}
job.log(`Deleted ${notes.length} of ${fetchedCount}; ${Date.now() - batchBeginAt}ms`);
job.log(`Deleted ${notes.length} from ${initiatorNotes.length} initiators; ${Date.now() - batchBeginAt}ms`);
const elapsed = Date.now() - startAt;
if (elapsed >= maxDuration) {
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
job.log('Reached maximum duration, stopping cleaning.');
job.updateProgress(100);
if (initiatorNotes.length < MAX_NOTE_COUNT_PER_QUERY) {
// 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}.)`);
break;
}
job.updateProgress((elapsed / maxDuration) * 100);
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,61 +32,30 @@
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
renderError('LOCALE_FETCH');
return;
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/embed_vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT');
@ -115,10 +84,26 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
const reload = locale?.reload || 'Reload';
const title = messages?.title || 'Failed to initialize Misskey';
const reload = messages?.reload || 'Reload';
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<div class="message">${title}</div>

View File

@ -22,62 +22,31 @@
return;
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
renderError('LOCALE_FETCH');
return;
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
@ -162,9 +131,25 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const messages = Object.assign({
messages = Object.assign({
title: 'Failed to initialize Misskey',
solution: 'The following actions may solve the problem.',
solution1: 'Update your os and browser',
@ -176,8 +161,8 @@
otherOption2: 'Start the simple client',
otherOption3: 'Start the repair tool',
otherOption4: 'Start Misskey in safe mode',
}, locale?._bootErrors || {});
const reload = locale?.reload || 'Reload';
reload: 'Reload',
}, messages);
const safeModeUrl = new URL(window.location.href);
safeModeUrl.searchParams.set('safemode', 'true');
@ -193,7 +178,7 @@
</svg>
<h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${reload}</span>
<span class="button-label-big">${messages?.reload}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>

View File

@ -19,7 +19,6 @@ html(class='embed')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
if !config.frontendEmbedManifestExists
script(type="module" src="/embed_vite/@vite/client")
@ -40,7 +39,7 @@ html(class='embed')
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

@ -37,7 +37,6 @@ html
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
link(rel='modulepreload' href=`/vite/${entry.file}`)
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
@ -69,7 +68,7 @@ html
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

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

View File

@ -0,0 +1 @@
This package contains the common scripts that are used to build the frontend and frontend-embed packages.

View File

@ -0,0 +1,52 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
files: [
'**/*.ts',
],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
...globals.browser,
// Node.js
module: false,
require: false,
__dirname: false,
// Misskey
_DEV_: false,
_LANGS_: false,
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
},
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
},
},
{
ignores: [
],
},
];

View File

@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'fs/promises';
import * as path from 'node:path';
import MagicString from 'magic-string';
import { collectModifications } from './locale-inliner/collect-modifications.js';
import { applyWithLocale } from './locale-inliner/apply-with-locale.js';
import { blankLogger } from './logger.js';
import type { Logger } from './logger.js';
import type { Locale } from '../../locales/index.js';
import type { Manifest as ViteManifest } from 'vite';
export class LocaleInliner {
outputDir: string;
scriptsDir: string;
i18nFile: string;
i18nFileName: string;
logger: Logger;
chunks: ScriptChunk[];
static async create(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
logger: Logger,
}): Promise<LocaleInliner> {
const manifest: ViteManifest = JSON.parse(await fs.readFile(`${options.outputDir}/manifest.json`, 'utf-8'));
return new LocaleInliner({ ...options, manifest });
}
constructor(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
manifest: ViteManifest,
logger: Logger,
}) {
this.outputDir = options.outputDir;
this.scriptsDir = options.scriptsDir;
this.i18nFile = options.i18nFile;
this.i18nFileName = this.stripScriptDir(options.manifest[this.i18nFile].file);
this.logger = options.logger;
this.chunks = Object.values(options.manifest).filter(chunk => this.isScriptFile(chunk.file)).map(chunk => ({
fileName: this.stripScriptDir(chunk.file),
chunkName: chunk.name,
}));
}
async loadFiles() {
await Promise.all(this.chunks.map(async chunk => {
const filePath = path.join(this.outputDir, this.scriptsDir, chunk.fileName);
chunk.sourceCode = await fs.readFile(filePath, 'utf-8');
}));
}
collectsModifications() {
for (const chunk of this.chunks) {
if (!chunk.sourceCode) {
throw new Error(`Source code for ${chunk.fileName} is not loaded.`);
}
const fileLogger = this.logger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
chunk.modifications = collectModifications(chunk.sourceCode, chunk.fileName, fileLogger, this);
}
}
async saveAllLocales(locales: Record<string, Locale>) {
const localeNames = Object.keys(locales);
for (const localeName of localeNames) {
this.logger.info(`Creating bundle for ${localeName}`);
await this.saveLocale(localeName, locales[localeName]);
}
this.logger.info('Done');
}
async saveLocale(localeName: string, localeJson: Locale) {
// create directory
await fs.mkdir(path.join(this.outputDir, localeName), { recursive: true });
const localeLogger = localeName === 'ja-JP' ? this.logger : blankLogger; // we want to log for single locale only
for (const chunk of this.chunks) {
if (!chunk.sourceCode || !chunk.modifications) {
throw new Error(`Source code or modifications for ${chunk.fileName} is not available.`);
}
const fileLogger = localeLogger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
const magicString = new MagicString(chunk.sourceCode);
applyWithLocale(magicString, chunk.modifications, localeName, localeJson, fileLogger);
await fs.writeFile(path.join(this.outputDir, localeName, chunk.fileName), magicString.toString());
}
}
isScriptFile(fileName: string) {
return fileName.startsWith(this.scriptsDir + '/') && fileName.endsWith('.js');
}
stripScriptDir(fileName: string) {
if (!fileName.startsWith(this.scriptsDir + '/')) {
throw new Error(`${fileName} does not start with ${this.scriptsDir}/`);
}
return fileName.slice(this.scriptsDir.length + 1);
}
}
interface ScriptChunk {
fileName: string;
chunkName?: string;
sourceCode?: string;
modifications?: TextModification[];
}
export type TextModification = {
type: 'delete';
begin: number;
end: number;
localizedOnly: boolean;
} | {
// can be used later to insert '../scripts' for common files
type: 'insert';
begin: number;
text: string;
localizedOnly: boolean;
} | {
type: 'replace';
begin: number;
end: number;
text: string;
localizedOnly: boolean;
} | {
type: 'localized';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'parameterized-function';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'locale-name';
begin: number;
end: number;
literal: boolean;
localizedOnly: true;
} | {
type: 'locale-json';
begin: number;
end: number;
localizedOnly: true;
};

View File

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MagicString from 'magic-string';
import { assertNever } from '../utils.js';
import type { Locale, ILocale } from '../../../locales/index.js';
import type { TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js';
export function applyWithLocale(
sourceCode: MagicString,
modifications: TextModification[],
localeName: string,
localeJson: Locale,
fileLogger: Logger,
) {
for (const modification of modifications) {
switch (modification.type) {
case 'delete':
sourceCode.remove(modification.begin, modification.end);
break;
case 'insert':
sourceCode.appendRight(modification.begin, modification.text);
break;
case 'replace':
sourceCode.update(modification.begin, modification.end, modification.text);
break;
case 'localized': {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
if (accessed == null) {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
}
sourceCode.update(modification.begin, modification.end, JSON.stringify(accessed));
break;
}
case 'parameterized-function': {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
let replacement: string;
if (typeof accessed === 'string') {
replacement = formatFunction(accessed);
} else if (typeof accessed === 'object' && accessed !== null) {
replacement = `({${Object.entries(accessed).map(([key, value]) => `${JSON.stringify(key)}:${formatFunction(value)}`).join(',')}})`;
} else {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
replacement = '(() => "")'; // placeholder for missing locale
}
sourceCode.update(modification.begin, modification.end, replacement);
break;
function formatFunction(format: string): string {
const params = new Set<string>();
const components: string[] = [];
let lastIndex = 0;
for (const match of format.matchAll(/\{(.+?)}/g)) {
const [fullMatch, paramName] = match;
if (lastIndex < match.index) {
components.push(JSON.stringify(format.slice(lastIndex, match.index)));
}
params.add(paramName);
components.push(paramName);
lastIndex = match.index + fullMatch.length;
}
components.push(JSON.stringify(format.slice(lastIndex)));
// we replace with `(({name,count})=>(name+count+"some"))`
const paramList = Array.from(params).join(',');
let body = components.filter(x => x !== '""').join('+');
if (body === '') body = '""'; // if the body is empty, we return empty string
return `(({${paramList}})=>(${body}))`;
}
}
case 'locale-name': {
sourceCode.update(modification.begin, modification.end, modification.literal ? JSON.stringify(localeName) : localeName);
break;
}
case 'locale-json': {
// locale-json is inlined to place where initialize module-level variable which is executed only once.
// In such case we can use JSON.parse to speed up the parsing script.
// https://v8.dev/blog/cost-of-javascript-2019#json
sourceCode.update(modification.begin, modification.end, `JSON.parse(${JSON.stringify(JSON.stringify(localeJson))})`);
break;
}
default: {
assertNever(modification);
}
}
}
}
function getPropertyByPath(localeJson: ILocale, localizationKey: string[]): string | object | null {
if (localizationKey.length === 0) return localeJson;
let current: ILocale | string = localeJson;
for (const key of localizationKey) {
if (typeof current !== 'object' || !(key in current)) {
return null; // Key not found
}
current = current[key];
}
return current;
}

View File

@ -0,0 +1,425 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parseAst } from 'vite';
import * as estreeWalker from 'estree-walker';
import { assertNever, assertType } from '../utils.js';
import type { AstNode, ProgramNode } from 'rollup';
import type * as estree from 'estree';
import type { LocaleInliner, TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js';
// WalkerContext is not exported from estree-walker, so we define it here
interface WalkerContext {
skip: () => void;
}
export function collectModifications(sourceCode: string, fileName: string, fileLogger: Logger, inliner: LocaleInliner): TextModification[] {
let programNode: ProgramNode;
try {
programNode = parseAst(sourceCode);
} catch (err) {
fileLogger.error(`Failed to parse source code: ${err}`);
return [];
}
if (programNode.sourceType !== 'module') {
fileLogger.error('Source code is not a module.');
return [];
}
const modifications: TextModification[] = [];
// first
// 1) replace all `scripts/` path literals with locale code
// 2) replace all `localStorage.getItem("lang")` with `localeName` variable
// 3) replace all `await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json())` with `localeJson` variable
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node: Node) {
assertType<AstNode>(node);
if (node.type === 'Literal' && typeof node.value === 'string' && node.raw) {
if (node.raw.substring(1).startsWith(inliner.scriptsDir)) {
// we find `scripts/\w+\.js` literal and replace 'scripts' part with locale code
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.scriptsDir}/ path literal ${node.raw}`);
modifications.push({
type: 'locale-name',
begin: node.start + 1,
end: node.start + 1 + inliner.scriptsDir.length,
literal: false,
localizedOnly: true,
});
}
if (node.raw.substring(1, node.raw.length - 1) === `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
// we find `scripts/i18n.ts` literal.
// This is tipically in depmap and replace with this file name to avoid unnecessary loading i18n script
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.i18nFileName} path literal ${node.raw}`);
modifications.push({
type: 'replace',
begin: node.end - 1 - inliner.i18nFileName.length,
end: node.end - 1,
text: fileName,
localizedOnly: true,
});
}
}
if (isLocalStorageGetItemLang(node)) {
fileLogger.debug(`${lineCol(sourceCode, node)}: found localStorage.getItem("lang") call`);
modifications.push({
type: 'locale-name',
begin: node.start,
end: node.end,
literal: true,
localizedOnly: true,
});
}
if (isAwaitFetchLocaleThenJson(node)) {
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json(), () => null)
fileLogger.debug(`${lineCol(sourceCode, node)}: found await window.fetch(\`/assets/locales/\${d}.\${x}.json\`).then(u=>u.json()) call`);
modifications.push({
type: 'locale-json',
begin: node.start,
end: node.end,
localizedOnly: true,
});
}
},
});
const importSpecifierResult = findImportSpecifier(programNode, inliner.i18nFileName, 'i18n');
switch (importSpecifierResult.type) {
case 'no-import':
fileLogger.debug('No import of i18n found, skipping inlining.');
return modifications;
case 'no-specifiers':
fileLogger.debug('Importing i18n without specifiers, removing the import.');
modifications.push({
type: 'delete',
begin: importSpecifierResult.importNode.start,
end: importSpecifierResult.importNode.end,
localizedOnly: false,
});
return modifications;
case 'unexpected-specifiers':
fileLogger.info(`Importing ${inliner.i18nFileName} found but with unexpected specifiers. Skipping inlining.`);
return modifications;
case 'specifier':
fileLogger.debug(`Found import i18n as ${importSpecifierResult.localI18nIdentifier}`);
break;
}
const i18nImport = importSpecifierResult.importNode;
const localI18nIdentifier = importSpecifierResult.localI18nIdentifier;
// Check if the identifier is already declared in the file.
// If it is, we may overwrite it and cause issues so we skip inlining
let isSupported = true;
estreeWalker.walk(programNode, {
enter(node) {
if (node.type === 'VariableDeclaration') {
assertType<estree.VariableDeclaration>(node);
for (const id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
if (id === localI18nIdentifier) {
isSupported = false;
}
}
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!isSupported) {
fileLogger.error(`Duplicated identifier "${localI18nIdentifier}" in variable declaration. Skipping inlining.`);
return modifications;
}
fileLogger.debug(`imports i18n as ${localI18nIdentifier}`);
// In case of substitution failure, we will preserve the import statement
// otherwise we will remove it.
let preserveI18nImport = false;
const toSkip = new Set();
toSkip.add(i18nImport);
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node, parent, property) {
assertType<AstNode>(node);
assertType<AstNode>(parent);
if (toSkip.has(node)) {
// This is the import specifier, skip processing it
this.skip();
return;
}
// We don't care original name part of the import declaration
if (node.type === 'ImportDeclaration') this.skip();
if (node.type === 'Identifier') {
assertType<estree.Identifier>(node);
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent);
if (parent.type === 'Property' && !parent.computed && property === 'key') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'MemberExpression' && !parent.computed && property === 'property') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'ExportSpecifier' && property === 'exported') return; // we don't care 'id' part of { id: expr }
if (node.name === localI18nIdentifier) {
fileLogger.error(`${lineCol(sourceCode, node)}: Using i18n identifier "${localI18nIdentifier}" directly. Skipping inlining.`);
preserveI18nImport = true;
}
} else if (node.type === 'MemberExpression') {
assertType<estree.MemberExpression>(node);
const i18nPath = parseI18nPropertyAccess(node);
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'ts') {
if (parent.type === 'CallExpression' && property === 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
if (i18nPath.at(-1)?.startsWith('_')) fileLogger.debug(`found i18n grouped property access ${i18nPath.join('.')}`);
else fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n property access ${i18nPath.join('.')}`);
// it's i18n.ts.propertyAccess
// i18n.ts.* will always be resolved to string or object containing strings
modifications.push({
type: 'localized',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'ts' prefix
localizedOnly: true,
});
this.skip();
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'tsx') {
// it's parameterized locale substitution (`i18n.tsx.property(parameters)`)
// we expect the parameter to be an object literal
fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n function access (object) ${i18nPath.join('.')}`);
modifications.push({
type: 'parameterized-function',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'tsx' prefix
localizedOnly: true,
});
this.skip();
}
} else if (node.type === 'ArrowFunctionExpression') {
assertType<estree.ArrowFunctionExpression>(node);
// If there is 'i18n' in the parameters, we care interior of the function
if (node.params.flatMap(param => declsOfPattern(param)).includes(localI18nIdentifier)) this.skip();
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!preserveI18nImport) {
fileLogger.debug('removing i18n import statement');
modifications.push({
type: 'delete',
begin: i18nImport.start,
end: i18nImport.end,
localizedOnly: true,
});
}
function parseI18nPropertyAccess(node: estree.Expression | estree.Super): string[] | null {
if (node.type === 'Identifier' && node.name === localI18nIdentifier) return []; // i18n itself
if (node.type !== 'MemberExpression') return null;
// super.*
if (node.object.type === 'Super') return null;
// i18n?.property is not supported
if (node.optional) return null;
let id: string | null = null;
if (node.computed) {
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
id = node.property.value;
}
} else {
if (node.property.type === 'Identifier') {
id = node.property.name;
}
}
// non-constant property access
if (id == null) return null;
const parentAccess = parseI18nPropertyAccess(node.object);
if (parentAccess == null) return null;
return [...parentAccess, id];
}
return modifications;
}
function declsOfPattern(pattern: estree.Pattern | null): string[] {
if (pattern == null) return [];
switch (pattern.type) {
case 'Identifier':
return [pattern.name];
case 'ObjectPattern':
return pattern.properties.flatMap(prop => {
switch (prop.type) {
case 'Property':
return declsOfPattern(prop.value);
case 'RestElement':
return declsOfPattern(prop.argument);
default:
assertNever(prop);
}
});
case 'ArrayPattern':
return pattern.elements.flatMap(p => declsOfPattern(p));
case 'RestElement':
return declsOfPattern(pattern.argument);
case 'AssignmentPattern':
return declsOfPattern(pattern.left);
case 'MemberExpression':
// assignment pattern so no new variable is declared
return [];
default:
assertNever(pattern);
}
}
function lineCol(sourceCode: string, node: estree.Node): string {
assertType<AstNode>(node);
const leading = sourceCode.slice(0, node.start);
const lines = leading.split('\n');
const line = lines.length;
const col = lines[lines.length - 1].length + 1; // +1 for 1-based index
return `(${line}:${col})`;
}
//region checker functions
type Node =
| estree.AssignmentProperty
| estree.CatchClause
| estree.Class
| estree.ClassBody
| estree.Expression
| estree.Function
| estree.Identifier
| estree.Literal
| estree.MethodDefinition
| estree.ModuleDeclaration
| estree.ModuleSpecifier
| estree.Pattern
| estree.PrivateIdentifier
| estree.Program
| estree.Property
| estree.PropertyDefinition
| estree.SpreadElement
| estree.Statement
| estree.Super
| estree.SwitchCase
| estree.TemplateElement
| estree.VariableDeclarator
;
// localStorage.getItem("lang")
function isLocalStorageGetItemLang(getItemCall: Node): boolean {
if (getItemCall.type !== 'CallExpression') return false;
if (getItemCall.arguments.length !== 1) return false;
const langLiteral = getItemCall.arguments[0];
if (!isStringLiteral(langLiteral, 'lang')) return false;
const getItemFunction = getItemCall.callee;
if (!isMemberExpression(getItemFunction, 'getItem')) return false;
const localStorageObject = getItemFunction.object;
if (!isIdentifier(localStorageObject, 'localStorage')) return false;
return true;
}
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u => u.json(), ....)
function isAwaitFetchLocaleThenJson(awaitNode: Node): boolean {
if (awaitNode.type !== 'AwaitExpression') return false;
const thenCall = awaitNode.argument;
if (thenCall.type !== 'CallExpression') return false;
if (thenCall.arguments.length < 1) return false;
const arrowFunction = thenCall.arguments[0];
if (arrowFunction.type !== 'ArrowFunctionExpression') return false;
if (arrowFunction.params.length !== 1) return false;
const arrowBodyCall = arrowFunction.body;
if (arrowBodyCall.type !== 'CallExpression') return false;
const jsonFunction = arrowBodyCall.callee;
if (!isMemberExpression(jsonFunction, 'json')) return false;
const thenFunction = thenCall.callee;
if (!isMemberExpression(thenFunction, 'then')) return false;
const fetchCall = thenFunction.object;
if (fetchCall.type !== 'CallExpression') return false;
if (fetchCall.arguments.length !== 1) return false;
// `/assets/locales/${d}.${x}.json`
const assetLocaleTemplate = fetchCall.arguments[0];
if (assetLocaleTemplate.type !== 'TemplateLiteral') return false;
if (assetLocaleTemplate.quasis.length !== 3) return false;
if (assetLocaleTemplate.expressions.length !== 2) return false;
if (assetLocaleTemplate.quasis[0].value.cooked !== '/assets/locales/') return false;
if (assetLocaleTemplate.quasis[1].value.cooked !== '.') return false;
if (assetLocaleTemplate.quasis[2].value.cooked !== '.json') return false;
const fetchFunction = fetchCall.callee;
if (!isMemberExpression(fetchFunction, 'fetch')) return false;
const windowObject = fetchFunction.object;
if (!isIdentifier(windowObject, 'window')) return false;
return true;
}
type SpecifierResult =
| { type: 'no-import' }
| { type: 'no-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'unexpected-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'specifier', localI18nIdentifier: string, importNode: estree.ImportDeclaration & AstNode }
;
function findImportSpecifier(programNode: ProgramNode, i18nFileName: string, i18nSymbol: string): SpecifierResult {
const imports = programNode.body.filter(x => x.type === 'ImportDeclaration');
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration | undefined;
if (!importNode) return { type: 'no-import' };
assertType<AstNode>(importNode);
if (importNode.specifiers.length === 0) {
return { type: 'no-specifiers', importNode };
}
if (importNode.specifiers.length !== 1) {
return { type: 'unexpected-specifiers', importNode };
}
const i18nImportSpecifier = importNode.specifiers[0];
if (i18nImportSpecifier.type !== 'ImportSpecifier') {
return { type: 'unexpected-specifiers', importNode };
}
if (i18nImportSpecifier.imported.type !== 'Identifier') {
return { type: 'unexpected-specifiers', importNode };
}
const importingIdentifier = i18nImportSpecifier.imported.name;
if (importingIdentifier !== i18nSymbol) {
return { type: 'unexpected-specifiers', importNode };
}
const localI18nIdentifier = i18nImportSpecifier.local.name;
return { type: 'specifier', localI18nIdentifier, importNode };
}
// checker helpers
function isMemberExpression(node: Node, property: string): node is estree.MemberExpression {
return node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier' && node.property.name === property;
}
function isStringLiteral(node: Node, value: string): node is estree.Literal {
return node.type === 'Literal' && typeof node.value === 'string' && node.value === value;
}
function isIdentifier(node: Node, name: string): node is estree.Identifier {
return node.type === 'Identifier' && node.name === name;
}
//endregion

View File

@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as process from 'node:process';
const debug = process.env.BUILDER_DEBUG !== undefined && process.env.BUILDER_DEBUG !== '0';
export interface Logger {
debug(message: string): void;
warn(message: string): void;
error(message: string): void;
info(message: string): void;
prefixed(newPrefix: string): Logger;
}
interface RootLogger extends Logger {
warningCount: number;
errorCount: number;
}
export function createLogger(): RootLogger {
return loggerFactory('', {
warningCount: 0,
errorCount: 0,
});
}
type LogContext = {
warningCount: number;
errorCount: number;
};
function loggerFactory(prefix: string, context: LogContext): RootLogger {
return {
debug: (message: string) => {
if (debug) console.log(`[DBG] ${prefix}${message}`);
},
warn: (message: string) => {
context.warningCount++;
console.log(`${debug ? '[WRN]' : 'w:'} ${prefix}${message}`);
},
error: (message: string) => {
context.errorCount++;
console.error(`${debug ? '[ERR]' : 'e:'} ${prefix}${message}`);
},
info: (message: string) => {
console.error(`${debug ? '[INF]' : 'i:'} ${prefix}${message}`);
},
prefixed: (newPrefix: string) => {
return loggerFactory(`${prefix}${newPrefix}`, context);
},
get warningCount() {
return context.warningCount;
},
get errorCount() {
return context.errorCount;
},
};
}
export const blankLogger: Logger = {
debug: () => void 0,
warn: () => void 0,
error: () => void 0,
info: () => void 0,
prefixed: () => blankLogger,
};

View File

@ -0,0 +1,25 @@
{
"name": "frontend-builder",
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"exports": {
"./*": "./js/*"
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "22.17.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"rollup": "4.46.2",
"typescript": "5.9.2"
},
"dependencies": {
"estree-walker": "3.0.3",
"magic-string": "0.30.17",
"vite": "7.0.6"
}
}

View File

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as estreeWalker from 'estree-walker';
import MagicString from 'magic-string';
import { assertType } from './utils.js';
import type { Plugin } from 'vite';
import type { CallExpression, Expression, Program } from 'estree';
import type { AstNode } from 'rollup';
// This plugin transforms `unref(i18n)` to `i18n` in the code, which is useful for removing unnecessary unref calls
// and helps locale inliner runs after vite build to inline the locale data into the final build.
//
// locale inliner cannot know minifiedSymbol(i18n) is 'unref(i18n)' or 'otherFunctionsWithEffect(i18n)' so
// it is necessary to remove unref calls before minification.
export function pluginRemoveUnrefI18n(
{
i18nSymbolName = 'i18n',
}: {
i18nSymbolName?: string
} = {}): Plugin {
return {
name: 'UnwindCssModuleClassName',
renderChunk(code) {
if (!code.includes('unref(i18n)')) return null;
const ast = this.parse(code) as Program;
const magicString = new MagicString(code);
estreeWalker.walk(ast, {
enter(node) {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'unref'
&& node.arguments.length === 1) {
// calls to unref with single argument
const arg = node.arguments[0];
if (arg.type === 'Identifier' && arg.name === i18nSymbolName) {
// this is unref(i18n) so replace it with i18n
// to replace, remove the 'unref(' and the trailing ')'
assertType<CallExpression & AstNode>(node);
assertType<Expression & AstNode>(arg);
magicString.remove(node.start, arg.start);
magicString.remove(arg.end, node.end);
}
}
},
});
return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true }),
};
},
};
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"typeRoots": [
"./@types",
"./node_modules/@types"
],
"lib": [
"esnext"
]
}
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function assertNever(x: never): never {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
}
export function assertType<T>(node: unknown): asserts node is T {
}

View File

@ -0,0 +1,51 @@
import * as fs from 'fs/promises';
import url from 'node:url';
import path from 'node:path';
import { execa } from 'execa';
import locales from '../../locales/index.js';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger';
// requires node 21 or later
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = __dirname + '/../../built/_frontend_embed_vite_';
/**
* @return {Promise<void>}
*/
async function viteBuild() {
await execa('vite', ['build'], {
cwd: __dirname,
stdout: process.stdout,
stderr: process.stderr,
});
}
async function buildAllLocale() {
const logger = createLogger()
const inliner = await LocaleInliner.create({
outputDir,
logger,
scriptsDir: 'scripts',
i18nFile: 'src/i18n.ts',
})
await inliner.loadFiles();
inliner.collectsModifications();
await inliner.saveAllLocales(locales);
if (logger.errorCount > 0) {
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
}
}
async function build() {
await fs.rm(outputDir, { recursive: true, force: true });
await viteBuild();
await buildAllLocale();
}
await build();

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@ -20,8 +20,8 @@
"astring": "1.9.0",
"buraha": "0.0.1",
"estree-walker": "3.0.3",
"icons-subsetter": "workspace:*",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"json5": "2.2.3",
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
@ -63,6 +63,7 @@
"nodemon": "3.1.10",
"prettier": "3.6.2",
"start-server-and-test": "2.0.12",
"tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.0.5",
"vue-eslint-parser": "10.2.0",

View File

@ -17,15 +17,16 @@ import { createApp, defineAsyncComponent } from 'vue';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-dark.json5';
import { MediaProxy } from '@@/js/media-proxy.js';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import { applyTheme, assertIsTheme } from '@/theme.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { DI } from '@/di.js';
import { serverMetadata } from '@/server-metadata.js';
import { url, version, locale, lang, updateLocale } from '@@/js/config.js';
import { url, version, lang } from '@@/js/config.js';
import { parseEmbedParams } from '@@/js/embed-page.js';
import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
import { serverContext } from '@/server-context.js';
import { i18n, updateI18n } from '@/i18n.js';
import { i18n } from '@/i18n.js';
import type { Theme } from '@/theme.js';
@ -76,19 +77,7 @@ if (embedParams.colorMode === 'dark') {
//#endregion
//#region Detect language & fetch translations
const localeVersion = localStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
localStorage.setItem('locale', newLocale);
localStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
//#endregion
// サイズの制限

View File

@ -5,11 +5,12 @@
import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js';
import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
// test 以外では使わないこと。インライン化されてるのでだいたい意味がない
export function updateI18n(newLocale: Locale) {
i18n.locale = newLocale;
}

View File

@ -8,6 +8,7 @@ import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' };
import pluginJson5 from './vite.json5.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n';
const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null;
const host = url ? (new URL(url)).hostname : undefined;
@ -85,6 +86,7 @@ export function getConfig(): UserConfig {
plugins: [
pluginVue(),
pluginRemoveUnrefI18n(),
pluginJson5(),
],
@ -135,15 +137,20 @@ export function getConfig(): UserConfig {
manifest: 'manifest.json',
rollupOptions: {
input: {
app: './src/boot.ts',
i18n: './src/i18n.ts',
entry: './src/boot.ts',
},
external: externalPackages.map(p => p.match),
preserveEntrySignatures: 'allow-extension',
output: {
manualChunks: {
vue: ['vue'],
// dependencies of i18n.ts
'config': ['@@/js/config.js'],
},
chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
entryFileNames: 'scripts/[hash:8].js',
chunkFileNames: 'scripts/[hash:8].js',
assetFileNames: 'assets/[hash:8][extname]',
paths(id) {
for (const p of externalPackages) {
if (p.match.test(id)) {

View File

@ -3,8 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
@ -17,14 +15,8 @@ export const apiUrl = location.origin + '/api';
export const wsOrigin = location.origin;
export const lang = localStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_;
const preParseLocale = localStorage.getItem('locale');
export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null;
export const version = _VERSION_;
export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName;
export const ui = localStorage.getItem('ui');
export const debug = localStorage.getItem('debug') === 'true';
export const isSafeMode = localStorage.getItem('isSafeMode') === 'true';
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -28,7 +28,8 @@ type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedSt
: never;
type Tsx<T extends ILocale> = {
readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P>
// `string extends T[K] ? never : K` part removes non-parameterized string keys from Tsx type.
readonly [K in keyof T as string extends T[K] ? never : K]: T[K] extends ParameterizedString<infer P>
? (arg: { readonly [_ in P]: string | number }) => string
// @ts-expect-error -- 証明省略
: Tsx<T[K]>;
@ -39,11 +40,7 @@ export class I18n<T extends ILocale> {
private devMode: boolean;
constructor(public locale: T, devMode = false) {
// 場合によってはバージョンアップ前の翻訳データを参照した結果存在しないプロパティにアクセスしてクライアントが起動できなくなることがある問題の応急処置として非devモードでもプロキシする
// TODO: https://github.com/misskey-dev/misskey/issues/14453 が実装されたらそのようなことは発生し得なくなるため消す
const oukyuusyoti = true;
this.devMode = devMode || oukyuusyoti;
this.devMode = devMode;
//#region BIND
this.t = this.t.bind(this);

View File

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { lang, version } from '@@/js/config.js';
import type { Locale } from '../../../locales/index.js';
// ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える
export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null);
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] };
export function storeBootloaderErrors(locale: BootLoaderLocaleBody) {
localStorage.setItem('bootloaderLocales', JSON.stringify(locale));
}

View File

@ -1 +1,2 @@
/storybook-static
/build/

View File

@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
import themes from './themes.js';
import '../src/style.scss';
@ -55,7 +54,6 @@ function initLocalStorage() {
...userDetailed(),
policies: {},
}));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({

View File

@ -0,0 +1,51 @@
import * as fs from 'fs/promises';
import url from 'node:url';
import path from 'node:path';
import { execa } from 'execa';
import locales from '../../locales/index.js';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger';
// requires node 21 or later
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = __dirname + '/../../built/_frontend_vite_';
/**
* @return {Promise<void>}
*/
async function viteBuild() {
await execa('vite', ['build'], {
cwd: __dirname,
stdout: process.stdout,
stderr: process.stderr,
});
}
async function buildAllLocale() {
const logger = createLogger()
const inliner = await LocaleInliner.create({
outputDir,
logger,
scriptsDir: 'scripts',
i18nFile: 'src/i18n.ts',
})
await inliner.loadFiles();
inliner.collectsModifications();
await inliner.saveAllLocales(locales);
if (logger.errorCount > 0) {
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
}
}
async function build() {
await fs.rm(outputDir, { recursive: true, force: true });
await viteBuild();
await buildAllLocale();
}
await build();

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
@ -47,6 +47,7 @@
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"execa": "9.6.0",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
@ -137,6 +138,7 @@
"start-server-and-test": "2.0.12",
"storybook": "9.1.0",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4",
"vitest-fetch-mock": "0.4.5",

View File

@ -5,9 +5,10 @@
import { computed, watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale, apiUrl, isSafeMode } from '@@/js/config.js';
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import type { App } from 'vue';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
@ -79,25 +80,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
//#endregion
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
async function fetchAndUpdateLocale({ useCache } = { useCache: true }) {
const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' };
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
if (localeOutdated) {
fetchAndUpdateLocale();
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
if (import.meta.hot) {
import.meta.hot.on('locale-update', async (updatedLang: string) => {
@ -106,7 +89,8 @@ export async function common(createVue: () => Promise<App<Element>>) {
await new Promise(resolve => {
window.setTimeout(resolve, 500);
});
await fetchAndUpdateLocale({ useCache: false });
// fetch with cache: 'no-store' to ensure the latest locale is fetched
await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text());
window.location.reload();
}
});

View File

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

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo>
<div v-if="isPlugin" class="_gaps_s">
<div v-if="extension.type === 'plugin'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCode :code="extension.raw"/>
</MkFolder>
</div>
<div v-else-if="isTheme" class="_gaps_s">
<div v-else-if="extension.type === 'theme'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
<template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template>
<template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template>
</MkKeyValue>
</div>
</MkFolder>

View File

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

View File

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

View File

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

View File

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

View File

@ -5,11 +5,12 @@
import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js';
import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
// test 以外では使わないこと。インライン化されてるのでだいたい意味がない
export function updateI18n(newLocale: Locale) {
i18n.locale = newLocale;
}

View File

@ -22,8 +22,7 @@ export type Keys = (
'fontSize' |
'ui' |
'ui_temp' |
'locale' |
'localeVersion' |
'bootloaderLocales' |
'theme' |
'themeId' |
'customCss' |

View File

@ -286,6 +286,9 @@ const patronsWithIcon = [{
}, {
name: '井上千二十四',
icon: 'https://assets.misskey-hub.net/patrons/193afa1f039b4c339866039c3dcd74bf.jpg',
}, {
name: 'NigN',
icon: 'https://assets.misskey-hub.net/patrons/1ccaef8e73ec4a50b59ff7cd688ceb84.jpg',
}];
const patrons = [
@ -399,6 +402,7 @@ const patrons = [
'みりめい',
'東雲 琥珀',
'ほとラズ',
'スズカケン',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -886,8 +886,6 @@ const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
});
watch(fontSize, () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,8 +13,6 @@ export async function clearCache() {
os.waiting();
miLocalStorage.removeItem('instance');
miLocalStorage.removeItem('instanceCachedAt');
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,19 +35,29 @@ void main() {
`;
export const FX_mirror = defineImageEffectorFx({
id: 'mirror' as const,
id: 'mirror',
name: i18n.ts._imageEffector._fxs.mirror,
shader,
uniforms: ['h', 'v'] as const,
params: {
h: {
type: 'number:enum' as const,
enum: [{ value: -1, label: '<-' }, { value: 0, label: '|' }, { value: 1, label: '->' }],
label: i18n.ts.horizontal,
type: 'number:enum',
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-right' },
{ value: 0 as const, icon: 'ti ti-minus-vertical' },
{ value: 1 as const, icon: 'ti ti-arrow-bar-left' }
],
default: -1,
},
v: {
type: 'number:enum' as const,
enum: [{ value: -1, label: '^' }, { value: 0, label: '-' }, { value: 1, label: 'v' }],
label: i18n.ts.vertical,
type: 'number:enum',
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-down' },
{ value: 0 as const, icon: 'ti ti-minus' },
{ value: 1 as const, icon: 'ti ti-arrow-bar-up' }
],
default: 0,
},
},

View File

@ -78,14 +78,16 @@ void main() {
}
`;
// Primarily used for watermark
export const FX_polkadot = defineImageEffectorFx({
id: 'polkadot' as const,
id: 'polkadot',
name: i18n.ts._imageEffector._fxs.polkadot,
shader,
uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const,
params: {
angle: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.angle,
type: 'number',
default: 0,
min: -1.0,
max: 1.0,
@ -93,21 +95,24 @@ export const FX_polkadot = defineImageEffectorFx({
toViewValue: v => Math.round(v * 90) + '°',
},
scale: {
type: 'number' as const,
label: i18n.ts._imageEffector._fxProps.scale,
type: 'number',
default: 3.0,
min: 1.0,
max: 10.0,
step: 0.1,
},
majorRadius: {
type: 'number' as const,
label: i18n.ts._watermarkEditor.polkadotMainDotRadius,
type: 'number',
default: 0.1,
min: 0.0,
max: 1.0,
step: 0.01,
},
majorOpacity: {
type: 'number' as const,
label: i18n.ts._watermarkEditor.polkadotMainDotOpacity,
type: 'number',
default: 0.75,
min: 0.0,
max: 1.0,
@ -115,21 +120,24 @@ export const FX_polkadot = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
minorDivisions: {
type: 'number' as const,
label: i18n.ts._watermarkEditor.polkadotSubDotDivisions,
type: 'number',
default: 4,
min: 0,
max: 16,
step: 1,
},
minorRadius: {
type: 'number' as const,
label: i18n.ts._watermarkEditor.polkadotSubDotRadius,
type: 'number',
default: 0.25,
min: 0.0,
max: 1.0,
step: 0.01,
},
minorOpacity: {
type: 'number' as const,
label: i18n.ts._watermarkEditor.polkadotSubDotOpacity,
type: 'number',
default: 0.5,
min: 0.0,
max: 1.0,
@ -137,7 +145,8 @@ export const FX_polkadot = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
color: {
type: 'color' as const,
label: i18n.ts._imageEffector._fxProps.color,
type: 'color',
default: [1, 1, 1],
},
},

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