Merge branch 'develop' into removed-note-metadata

This commit is contained in:
anatawa12 2025-08-08 21:37:26 +09:00 committed by GitHub
commit 28a8639f14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2180 additions and 418 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

@ -19,6 +19,10 @@
- Fix: Unicode絵文字に隣接する異体字セレクタ`U+FE0F`)が絵文字として認識される問題を修正
### Client
- Feat: AiScriptが1.0に更新されました
- プラグインは1.0に対応したものが必要です
- Playはそのまま動作しますが、新規に作られるプリセットは1.0になります
- 以前のバージョンから無効化されていた note_view_interruptor が有効になりました
- Feat: セーフモード
- プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます
- 以下の方法でセーフモードを起動できます
@ -28,6 +32,8 @@
- Feat: ページのタブバーを下部に表示できるように
- Enhance: コントロールパネルを検索できるように
- Enhance: トルコ語 (tr-TR) に対応
- Enhance: 言語別のスクリプトバンドルを生成するように
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
- Fix: テーマエディタが動作しない問題を修正
@ -35,6 +41,7 @@
### Server
- Enhance: ノートの削除処理の効率化
- Enhance: 全体的なパフォーマンスの向上
- Fix: SystemWebhook設定でsecretを空に出来ない問題を修正
## 2025.7.0

View File

@ -618,3 +618,23 @@ color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
```
## 考え方
### DRYに囚われるな
必要なのは一般化ではなく抽象化と考えます。
盲信せず、誤った・不必要な共通化は避け、それが自然だと感じる場合は重複させる勇気を持ちましょう。
### Misskeyを複雑にしない実装
それがいくら複雑であっても、Misskey固有のコンテキストと関心が分離されている(もしくは事実上分離されていると見做すことができる)実装であれば、それはMisskeyのコードベースに対する複雑性に影響を与えないと考えます。
例えるなら、VueやAiScriptといったMisskeyが使用しているライブラリの内部実装がいくら複雑だったとしても、「それを使用しているからMisskeyの実装は複雑である」ということにはならないのと同じです。
Misskeyのドメイン知識から関心が分離されているということは、Misskeyの実装について考える時にそれらの内部実装を考慮する必要が無く、認知負荷を増やさないからです。
また重要な点は、その実装が、Misskeyリポジトリの外部にあるか・内部にあるかということや、Misskeyがメンテナンスするものか・第三者がメンテナンスするものかといったことは複雑性を考える上ではほとんど無視できるという点です。
もちろんその実装がMisskeyリポジトリにあり、Misskeyがメンテナンスしなければならないものは、保守のコストはかかります。
しかし、Misskeyの本質的な設計・実装という観点で見たときは、その実装は実質的に外部ライブラリのように振る舞います。
換言すれば「たまたまMisskeyの開発者と同じ人たちがメンテナンスしているし、たまたまMisskeyのリポジトリ内に置いてあるだけの外部ライブラリ」です。
そのため、実装をなるべくMisskeyのドメイン知識から独立したものにすれば、Misskeyのコードベースの複雑性を上げることなく機能実装を行うことができ、お得であると言えます。
もちろんそれにこだわって、些細な実装でもそのように分離してしまうとかえって認知負荷が増えたり、実装量が増えてメリットをデメリットが上回る場合もあるので、ケースバイケースではあります。

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,8 +84,9 @@ export default function generateDTS() {
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
undefined,
[
ts.factory.createIntersectionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createTypeLiteralNode([
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
@ -97,7 +98,8 @@ export default function generateDTS() {
undefined,
),
),
],
])
]),
),
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],

4
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;
}

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

@ -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,6 +1,6 @@
{
"name": "misskey",
"version": "2025.8.0-alpha.4",
"version": "2025.8.0-alpha.6",
"codename": "nasubi",
"repository": {
"type": "git",

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

@ -6,8 +6,7 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { MiNote } from '@/models/Note.js';
import type { MiMeta, NotesRepository } 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';
@ -55,21 +54,7 @@ export class CleanRemoteNotesProcessorService {
const MAX_NOTE_COUNT_PER_QUERY = 50;
const stats = {
deletedCount: 0,
oldest: null as number | null,
newest: null as number | null,
};
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
let cursor = '0'; // oldest note ID to start from
while (true) {
const batchBeginAt = Date.now();
//#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
@ -92,9 +77,12 @@ export class CleanRemoteNotesProcessorService {
;
// 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}`;
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 = `
@ -106,7 +94,13 @@ export class CleanRemoteNotesProcessorService {
OR "note"."id" = rn."replyId"
OR "note"."id" = rn."renoteId"
`;
const recursiveQuery = `(${initiatorQuery}) UNION (${unionQuery})`;
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"')
@ -119,26 +113,62 @@ export class CleanRemoteNotesProcessorService {
.select('note.id', 'id')
.addSelect('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.where(`rn."initiatorId" IN (${ removableInitiatorNotesQuery.getQuery() })`)
.setParameters({ cursor, newestLimit });
.where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
.distinctOn(['note.id']);
//#endregion
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.getRawMany();
const stats = {
deletedCount: 0,
oldest: null as number | null,
newest: null as number | null,
};
const fetchedCount = notes.length;
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
let cursor = '0'; // oldest note ID to start from
while (true) {
//#region check time
const batchBeginAt = Date.now();
const elapsed = batchBeginAt - startAt;
if (elapsed >= maxDuration) {
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
job.log('Reached maximum duration, stopping cleaning.');
job.updateProgress(100);
break;
}
job.updateProgress((elapsed / maxDuration) * 100);
//#endregion
// 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;
}
@ -150,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,8 +32,8 @@
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
@ -46,47 +46,16 @@
}
}
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);
} else {
renderError('LOCALE_FETCH');
return;
}
}
//#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,9 +22,9 @@
return;
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
@ -37,47 +37,16 @@
}
}
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);
} else {
renderError('LOCALE_FETCH');
return;
}
}
//#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,151 @@
/*
* 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) {
await this.saveLocale(localeName, locales[localeName]);
}
}
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",
@ -25,7 +25,8 @@
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.2.0",
"@sentry/vue": "10.0.0",
"@syuilo/aiscript": "0.19.0",
"@syuilo/aiscript": "1.0.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "3.5.18",
@ -46,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",
@ -136,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

@ -275,21 +275,19 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul
let note = deepClone(props.note);
// Transition
// https://github.com/aiscript-dev/aiscript/issues/937
//// plugin
//const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
//if (noteViewInterruptors.length > 0) {
// let result: Misskey.entities.Note | null = deepClone(note);
// for (const interruptor of noteViewInterruptors) {
// try {
// result = await interruptor.handler(result!) as Misskey.entities.Note | null;
// } catch (err) {
// console.error(err);
// }
// }
// note = result as Misskey.entities.Note;
//}
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;

View File

@ -297,20 +297,19 @@ const inChannel = inject('inChannel', null);
let note = deepClone(props.note);
// Transition
//// plugin
//const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
//if (noteViewInterruptors.length > 0) {
// let result: Misskey.entities.Note | null = deepClone(note);
// for (const interruptor of noteViewInterruptors) {
// try {
// result = await interruptor.handler(result!) as Misskey.entities.Note | null;
// } catch (err) {
// console.error(err);
// }
// }
// note = result as Misskey.entities.Note;
//}
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);

View File

@ -907,6 +907,11 @@ async function post(ev?: MouseEvent) {
if (uploader.items.value.some(x => x.uploaded == null)) {
await uploadFiles();
//
if (uploader.items.value.some(x => x.uploaded == null)) {
return;
}
}
let postData = {

View File

@ -52,6 +52,7 @@ export type PageHeaderProps = {
actions?: PageHeaderItem[] | null;
thin?: boolean;
hideTitle?: boolean;
canOmitTitle?: boolean;
displayMyAvatar?: boolean;
};
</script>
@ -77,7 +78,7 @@ const emit = defineEmits<{
const injectedPageMetadata = inject(DI.pageMetadata, ref(null));
const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle || (props.canOmitTitle && props.tabs.length > 0));
const thin_ = props.thin || inject('shouldHeaderThin', false);
const el = useTemplateRef('el');

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

@ -88,7 +88,7 @@ let choices = [
]
// PlayID+ID+
let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`)
let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`, { algorithm: 'rc4_legacy' })
//
let chosen = choices[random(0, (choices.len - 1))]
@ -127,7 +127,7 @@ var results = []
//
var cursor = 0
@do() {
@main() {
if (cursor != 0) {
results = results.slice(0, (cursor + 1))
cursor = 0
@ -175,7 +175,7 @@ var cursor = 0
onClick: forward
}, {
text: "引き直す"
onClick: do
onClick: main
}]
})
Ui:C:postFormButton({
@ -191,7 +191,7 @@ var cursor = 0
])
}
do()
main()
`;
const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION}

View File

@ -63,11 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onDeactivated, onUnmounted, ref, watch, shallowRef, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { Interpreter, Parser, values } from '@syuilo/aiscript';
import { url } from '@@/js/config.js';
import type { Ref } from 'vue';
import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
import type { MenuItem } from '@/types/menu.js';
import type { Interpreter } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -180,8 +180,6 @@ async function unlike() {
watch(() => props.id, fetchFlash, { immediate: true });
const parser = new Parser();
const started = ref(false);
const aiscript = shallowRef<Interpreter | null>(null);
const root = ref<AsUiRoot>();
@ -196,6 +194,12 @@ async function run() {
if (aiscript.value) aiscript.value.abort();
if (!flash.value) return;
const isLegacy = !flash.value.script.replaceAll(' ', '').startsWith('///@1.0.0');
const { Interpreter, Parser, values } = isLegacy ? await import('@syuilo/aiscript-0-19-0') : await import('@syuilo/aiscript');
const parser = new Parser();
components.value = [];
aiscript.value = new Interpreter({

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

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true">
<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true" :canOmitTitle="true">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<MkTip v-if="isBasicTimeline(src)" :k="`tl.${src}`" style="margin-bottom: var(--MI-margin);">
{{ i18n.ts._timelineDescription[src] }}
@ -45,8 +45,6 @@ import { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import { prefer } from '@/preferences.js';
provide('shouldOmitHeaderTitle', true);
const tlComponent = useTemplateRef('tlComponent');
type TimelinePageSrc = BasicTimelineType | `list:${string}`;
@ -105,9 +103,11 @@ const withSensitive = computed<boolean>({
set: (x) => saveTlFilter('withSensitive', x),
});
const showFixedPostForm = prefer.model('showFixedPostForm');
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await userListsCache.fetch();
const items: MenuItem[] = [
const items: (MenuItem | undefined)[] = [
...lists.map(list => ({
type: 'link' as const,
text: list.name,
@ -121,12 +121,12 @@ async function chooseList(ev: MouseEvent): Promise<void> {
to: '/my/lists',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target);
}
async function chooseAntenna(ev: MouseEvent): Promise<void> {
const antennas = await antennasCache.fetch();
const items: MenuItem[] = [
const items: (MenuItem | undefined)[] = [
...antennas.map(antenna => ({
type: 'link' as const,
text: antenna.name,
@ -141,12 +141,12 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
to: '/my/antennas',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target);
}
async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await favoritedChannelsCache.fetch();
const items: MenuItem[] = [
const items: (MenuItem | undefined)[] = [
...channels.map(channel => {
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;
const hasUnreadNote = (lastReadedAt && channel.lastNotedAt) ? Date.parse(channel.lastNotedAt) > lastReadedAt : !!(!lastReadedAt && channel.lastNotedAt);
@ -166,7 +166,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
to: '/channels',
},
];
os.popupMenu(items, ev.currentTarget ?? ev.target);
os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target);
}
function saveSrc(newSrc: TimelinePageSrc): void {
@ -190,19 +190,6 @@ function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) {
}
}
async function timetravel(): Promise<void> {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
});
if (canceled) return;
tlComponent.value.timetravel(date);
}
function focus(): void {
tlComponent.value.focus();
}
function switchTlIfNeeded() {
if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) {
src.value = availableBasicTimelines()[0];
@ -217,8 +204,7 @@ onActivated(() => {
});
const headerActions = computed(() => {
const tmp = [
{
const items = [{
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: (ev) => {
@ -252,14 +238,20 @@ const headerActions = computed(() => {
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false,
}, {
type: 'divider',
}, {
type: 'switch',
text: i18n.ts.showFixedPostForm,
ref: showFixedPostForm,
});
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
},
},
];
}];
if (deviceKind === 'desktop') {
tmp.unshift({
items.unshift({
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: (ev: Event) => {
@ -267,7 +259,8 @@ const headerActions = computed(() => {
},
});
}
return tmp;
return items;
});
const headerTabs = computed(() => [...(prefer.r.pinnedUserLists.value.map(l => ({

View File

@ -7,15 +7,15 @@ import { ref, defineAsyncComponent } from 'vue';
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { compareVersions } from 'compare-versions';
import { isSafeMode } from '@@/js/config.js';
import { genId } from '@/utility/id.js';
import * as Misskey from 'misskey-js';
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';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import type { FormWithDefault } from '@/utility/form.js';
export type Plugin = {
installId: string;
@ -394,8 +394,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => {
utils.assertFunction(handler);
addPluginHandler(id, 'note_view_interruptor', {
handler: withContext(ctx => async (note) => {
return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)]));
handler: withContext(ctx => (note) => {
return utils.valToJs(ctx.execFnSync(handler, [utils.jsToVal(note)]));
}),
});
}),

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

@ -46,6 +46,7 @@
},
"compileOnSave": false,
"include": [
"./build.ts",
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",

View File

@ -14,6 +14,7 @@ import pluginJson5 from './vite.json5.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js';
import pluginWatchLocales from './lib/vite-plugin-watch-locales.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n.js';
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;
@ -111,6 +112,7 @@ export function getConfig(): UserConfig {
pluginWatchLocales(),
...searchIndexes.map(options => pluginCreateSearchIndex(options)),
pluginVue(),
pluginRemoveUnrefI18n(),
pluginUnwindCssModuleClassName(),
pluginJson5(),
...process.env.NODE_ENV === 'production'
@ -174,16 +176,21 @@ 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'],
photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'],
// 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

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

View File

@ -11887,7 +11887,8 @@ export interface operations {
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
/** @default */
secret?: string;
};
};
};
@ -12231,7 +12232,8 @@ export interface operations {
name: string;
on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
/** @default */
secret?: string;
};
};
};

View File

@ -731,8 +731,11 @@ importers:
specifier: 10.0.0
version: 10.0.0(vue@3.5.18(typescript@5.9.2))
'@syuilo/aiscript':
specifier: 0.19.0
version: 0.19.0
specifier: 1.0.0
version: 1.0.0
'@syuilo/aiscript-0-19-0':
specifier: npm:@syuilo/aiscript@^0.19.0
version: '@syuilo/aiscript@0.19.0'
'@twemoji/parser':
specifier: 16.0.0
version: 16.0.0
@ -793,6 +796,9 @@ importers:
eventemitter3:
specifier: 5.0.1
version: 5.0.1
execa:
specifier: 9.6.0
version: 9.6.0
frontend-shared:
specifier: workspace:*
version: link:../frontend-shared
@ -1058,6 +1064,9 @@ importers:
storybook-addon-misskey-theme:
specifier: github:misskey-dev/storybook-addon-misskey-theme
version: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640(1169897c5e200f76aeb2e7696f1450e0)
tsx:
specifier: 4.20.3
version: 4.20.3
vite-plugin-turbosnap:
specifier: 1.0.3
version: 1.0.3
@ -1077,6 +1086,37 @@ importers:
specifier: 3.0.5
version: 3.0.5(typescript@5.9.2)
packages/frontend-builder:
dependencies:
estree-walker:
specifier: 3.0.3
version: 3.0.3
magic-string:
specifier: 0.30.17
version: 0.30.17
vite:
specifier: 7.0.6
version: 7.0.6(@types/node@22.17.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)
devDependencies:
'@types/estree':
specifier: 1.0.8
version: 1.0.8
'@types/node':
specifier: 22.17.0
version: 22.17.0
'@typescript-eslint/eslint-plugin':
specifier: 8.38.0
version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.9.2))(eslint@9.31.0)(typescript@5.9.2)
'@typescript-eslint/parser':
specifier: 8.38.0
version: 8.38.0(eslint@9.31.0)(typescript@5.9.2)
rollup:
specifier: 4.46.2
version: 4.46.2
typescript:
specifier: 5.9.2
version: 5.9.2
packages/frontend-embed:
dependencies:
'@discordapp/twemoji':
@ -1233,6 +1273,9 @@ importers:
start-server-and-test:
specifier: 2.0.12
version: 2.0.12
tsx:
specifier: 4.20.3
version: 4.20.3
vite-plugin-turbosnap:
specifier: 1.0.3
version: 1.0.3
@ -4302,6 +4345,9 @@ packages:
'@syuilo/aiscript@0.19.0':
resolution: {integrity: sha512-ZWG4s1m6RrFjE7NeIMaxFz769YO1jW5ReTrOROrEO4IHheOrjxxJ/Ffe2TUNqX9/XxDloMwfWplKhfSzx8LGMA==}
'@syuilo/aiscript@1.0.0':
resolution: {integrity: sha512-m+Dxx0g2pDI198OCj/OJgiJnE4ajlbOFAMyh84FmbY1S8ss/MHytxY82dCnMZj5WVt7VE7a1rtW7biuRRfuyaA==}
'@szmarczak/http-timer@5.0.1':
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
@ -6765,14 +6811,6 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.4.4:
resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fdir@6.4.6:
resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==}
peerDependencies:
@ -14637,7 +14675,7 @@ snapshots:
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.2
picomatch: 4.0.3
transitivePeerDependencies:
- supports-color
- typescript
@ -14735,6 +14773,12 @@ snapshots:
stringz: 2.1.0
uuid: 9.0.1
'@syuilo/aiscript@1.0.0':
dependencies:
seedrandom: 3.0.5
stringz: 2.1.0
uuid: 11.1.0
'@szmarczak/http-timer@5.0.1':
dependencies:
defer-to-connect: 2.0.1
@ -15353,7 +15397,7 @@ snapshots:
'@typescript-eslint/project-service@8.34.0(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0
'@typescript-eslint/types': 8.38.0
debug: 4.4.1(supports-color@10.0.0)
typescript: 5.8.3
transitivePeerDependencies:
@ -15361,8 +15405,8 @@ snapshots:
'@typescript-eslint/project-service@8.37.0(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
'@typescript-eslint/types': 8.38.0
debug: 4.4.1(supports-color@10.0.0)
typescript: 5.8.3
transitivePeerDependencies:
@ -15792,7 +15836,7 @@ snapshots:
alien-signals: 2.0.6
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.2
picomatch: 4.0.3
optionalDependencies:
typescript: 5.9.2
@ -18060,10 +18104,6 @@ snapshots:
dependencies:
pend: 1.2.0
fdir@6.4.4(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
fdir@6.4.6(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -22352,8 +22392,8 @@ snapshots:
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
fdir: 6.4.6(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}

View File

@ -2,6 +2,7 @@ packages:
- packages/backend
- packages/frontend-shared
- packages/frontend
- packages/frontend-builder
- packages/frontend-embed
- packages/icons-subsetter
- packages/sw

View File

@ -13,6 +13,8 @@ const fs = require('fs');
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-builder/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true });