Compare commits
40 Commits
b88698d1f9
...
9a9a5431b1
| Author | SHA1 | Date |
|---|---|---|
|
|
9a9a5431b1 | |
|
|
c1ff6feeda | |
|
|
1214d1d8fc | |
|
|
367dac4edd | |
|
|
e4b7a1f4e1 | |
|
|
e786ff4bf1 | |
|
|
e4a2d1658b | |
|
|
ed8a95f5bc | |
|
|
825c337cf4 | |
|
|
9bbc2028ad | |
|
|
97e916c912 | |
|
|
e954060f3b | |
|
|
e078cd9296 | |
|
|
1276e65049 | |
|
|
07c2de3749 | |
|
|
47f4f11e3e | |
|
|
d27075c5f5 | |
|
|
ed3a844f5d | |
|
|
0504d4399c | |
|
|
fab9db405c | |
|
|
02b37b7adf | |
|
|
02041344bd | |
|
|
fe1b2b00f5 | |
|
|
4fcb80bcf2 | |
|
|
836ed98c54 | |
|
|
64791a7160 | |
|
|
90e39d22d2 | |
|
|
9c98c13743 | |
|
|
bbbc68a772 | |
|
|
554623e5a8 | |
|
|
6c4b055921 | |
|
|
227ca69704 | |
|
|
a1b5786939 | |
|
|
2a6bc2154c | |
|
|
4ad8b59b49 | |
|
|
b6ade8315a | |
|
|
2352d50e99 | |
|
|
2bfbbbf16a | |
|
|
9d36d36fc4 | |
|
|
479d7a58a4 |
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -14,6 +14,9 @@
|
|||
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
|
||||
- Feat: ロールでアップロード可能なファイル種別を設定可能になりました
|
||||
- デフォルトは**テキスト、JSON、画像、動画、音声ファイル**になっています。zipなど、その他の種別のファイルは含まれていないため、必要に応じて設定を変更してください。
|
||||
- 場合によってはファイル種別を正しく検出できないことがあります(特にテキストフォーマット)。その場合、ファイル種別は application/octet-stream と見做されます。
|
||||
- したがって、それらの種別不明ファイルを許可したい場合は application/octet-stream を指定に追加してください。
|
||||
- Feat: プレビュー先がリダイレクトを伴う場合、リダイレクト先のコンテンツを取得しに行くか否かを設定できるように(#16043)
|
||||
- Enhance: UIのアイコンデータの読み込みを軽量化
|
||||
|
||||
### Client
|
||||
|
|
@ -43,13 +46,18 @@
|
|||
- Enhance: ノートのサーバー情報のデザインを改善・パフォーマンス向上
|
||||
(Based on https://github.com/taiyme/misskey/pull/198, https://github.com/taiyme/misskey/pull/211, https://github.com/taiyme/misskey/pull/283)
|
||||
- Enhance: ユーザー設定でURLプレビューを無効化できるように
|
||||
- Enhance: ヒントとコツを追加
|
||||
- Enhance: ヒントとコツを再表示できるように
|
||||
- Enhance: AiScriptからtoastを表示する関数 `Mk:toast` を追加
|
||||
- Enhance: シンタックスハイライトのエンジンをJavaScriptベースのものに変更
|
||||
- フロントエンドの読み込みサイズを軽量化しました
|
||||
- ほとんどの言語のハイライトは問題なく行えますが、互換性の問題により一部の言語が正常にハイライトできなくなる可能性があります。詳しくは https://shiki.style/references/engine-js-compat をご覧ください。
|
||||
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
|
||||
- Fix: 一定時間操作がなかったら動画プレイヤーのコントロールを隠すように
|
||||
- Fix: Twitchのクリップがプレイヤーで再生できない問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: リストやフォローをエクスポートする際にリプライを含むかどうかの情報を含むように
|
||||
- Enhance: チャットルームの最大メンバー数を30人から50人に調整
|
||||
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
|
||||
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
|
||||
|
|
@ -57,7 +65,9 @@
|
|||
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
|
||||
- Fix: ユーザ除外アンテナをインポートできない問題を修正
|
||||
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
|
||||
- Fix: ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題を修正 #16009
|
||||
- Fix: 連合モードが「なし」の場合に、生成されるHTML内のactivity jsonへのリンクタグを省略するように
|
||||
- Fix: コントロールパネルから招待コードを作成すると作成者の情報が記録されない問題を修正
|
||||
|
||||
|
||||
## 2025.5.0
|
||||
|
|
|
|||
|
|
@ -581,27 +581,6 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
|
|||
- 生成後、ファイルをmigration下に移してください
|
||||
- 作成されたスクリプトは不必要な変更を含むため除去してください
|
||||
|
||||
### JSON SchemaのobjectでanyOfを使うとき
|
||||
JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。
|
||||
バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます)
|
||||
https://github.com/misskey-dev/misskey/pull/10082
|
||||
|
||||
テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合:
|
||||
|
||||
```ts
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hoge: { type: 'string', minLength: 1 },
|
||||
fuga: { type: 'string', minLength: 1 },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['hoge'] },
|
||||
{ required: ['fuga'] },
|
||||
],
|
||||
} as const;
|
||||
```
|
||||
|
||||
### コネクションには`markRaw`せよ
|
||||
**Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。
|
||||
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,7 @@ failedToUpload: "Ha fallat la pujada"
|
|||
cannotUploadBecauseInappropriate: "Aquest fitxer no es pot pujar perquè s'ha trobat que algunes parts són inapropiades."
|
||||
cannotUploadBecauseNoFreeSpace: "Ha fallat la pujada del fitxer perquè no hi ha capacitat al Disc."
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "Aquest fitxer no es pot pujar perquè supera la mida permesa."
|
||||
cannotUploadBecauseUnallowedFileType: "Impossible pujar l'arxiu no és un tipus de fitxer autoritzat."
|
||||
beta: "Proves"
|
||||
enableAutoSensitive: "Marcar com a sensible automàticament "
|
||||
enableAutoSensitiveDescription: "Permet la detecció i el marcat automàtic dels mitjans sensibles fent servir aprenentatge automàtic quan sigui possible. Si aquesta opció es troba desactivada potser que estigui activada per a tota la instància. "
|
||||
|
|
@ -1359,6 +1360,9 @@ emojiUnmute: "Deixar de silenciar emojis"
|
|||
muteX: "Silenciar {x}"
|
||||
unmuteX: "Deixar de silenciar {x}"
|
||||
abort: "Cancel·lar"
|
||||
tip: "Trucs i consells"
|
||||
redisplayAllTips: "Torna ha mostrat tots els trucs i consells"
|
||||
hideAllTips: "Amagar tots els trucs i consells"
|
||||
_chat:
|
||||
noMessagesYet: "Encara no tens missatges "
|
||||
newMessage: "Missatge nou"
|
||||
|
|
@ -1981,6 +1985,9 @@ _role:
|
|||
canImportMuting: "Autoritza la importació de silenciats"
|
||||
canImportUserLists: "Autoritza la importació de llistes d'usuaris "
|
||||
chatAvailability: "Es permet xatejar"
|
||||
uploadableFileTypes: "Tipus de fitxers que en podeu pujar"
|
||||
uploadableFileTypes_caption: "Especifica el tipus MIME. Es poden especificar diferents tipus MIME separats amb una nova línia, i es poden especificar comodins amb asteriscs (*). (Per exemple: image/*)"
|
||||
uploadableFileTypes_caption2: "Pot que no sigui possible determinar el tipus MIME d'alguns arxius. Per permetre aquests tipus d'arxius afegeix {x} a les especificacions."
|
||||
_condition:
|
||||
roleAssignedTo: "Assignat a rols manuals"
|
||||
isLocal: "Usuari local"
|
||||
|
|
@ -2896,6 +2903,8 @@ _offlineScreen:
|
|||
_urlPreviewSetting:
|
||||
title: "Configuració per a la previsualització de l'URL"
|
||||
enable: "Activa la previsualització de l'URL"
|
||||
allowRedirect: "Permet la redirecció de la visualització prèvia "
|
||||
allowRedirectDescription: "Estableix si es mostra o no la redirecció a la vista prèvia quan l'adreça URL introduïda té una redirecció. Si es desactiva s'estalvien recursos del servidor, però no es mostrarà el contingut de la redirecció."
|
||||
timeout: "Temps màxim per carregar la previsualització de l'URL (ms)"
|
||||
timeoutDescription: "Si l'obtenció de la previsualització triga més que el temps establert, no es generarà la vista prèvia."
|
||||
maximumContentLength: "Longitud màxima del contingut (bytes)"
|
||||
|
|
@ -3092,6 +3101,8 @@ _uploader:
|
|||
abortConfirm: "Hi ha un arxiu que no s'ha pujat, vols cancel·lar?"
|
||||
doneConfirm: "Hi han fitxers no pujats, vols completar-los?"
|
||||
maxFileSizeIsX: "La mida màxima d'arxiu que es pot pujar és {x}."
|
||||
allowedTypes: "Tipus de fitxers que en podeu pujar"
|
||||
tip: "L'arxiu encara no s'ha carregat. En aquest quadre de diàleg, pots comprovar, canviar el nom, comprimir i retallar l'arxiu abans de pujar-lo. Quan estigui llest pots iniciar la càrrega polsant el boto \"Pujar\""
|
||||
_clientPerformanceIssueTip:
|
||||
title: "Si creus que el consum de bateria és molt alt"
|
||||
makeSureDisabledAdBlocker: "Desactiva els bloquejadors de publicitat"
|
||||
|
|
@ -3100,3 +3111,7 @@ _clientPerformanceIssueTip:
|
|||
makeSureDisabledCustomCss_description: "L'anul·lació dels estils pot afectar el rendiment. Comprova que el CSS personalitzat o les extensions que reescriuen estils no estiguin activats."
|
||||
makeSureDisabledAddons: "Desactiva extensions"
|
||||
makeSureDisabledAddons_description: "Algunes extensions poden interferir en el comportament del client i afectar el rendiment. Desactiva les extensions del navegador i comprovar-ho."
|
||||
_clip:
|
||||
tip: "Clip és una funció que permet organitzar les teves notes."
|
||||
_userLists:
|
||||
tip: "Es poden crear llistes amb qualsevol usuari. La llista creada es pot mostrar com una línia de temps."
|
||||
|
|
|
|||
|
|
@ -327,6 +327,7 @@ dark: "Dark"
|
|||
lightThemes: "Light themes"
|
||||
darkThemes: "Dark themes"
|
||||
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
|
||||
switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on, Would you like to turn off synchronization and switch modes manually?"
|
||||
drive: "Drive"
|
||||
fileName: "Filename"
|
||||
selectFile: "Select a file"
|
||||
|
|
@ -579,6 +580,7 @@ newNoteRecived: "There are new notes"
|
|||
newNote: "New Note"
|
||||
sounds: "Sounds"
|
||||
sound: "Sounds"
|
||||
notificationSoundSettings: "Notification sound settings"
|
||||
listen: "Listen"
|
||||
none: "None"
|
||||
showInPage: "Show in page"
|
||||
|
|
@ -1000,6 +1002,7 @@ failedToUpload: "Upload failed"
|
|||
cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially inappropriate."
|
||||
cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity."
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "This file cannot be uploaded as it exceeds the file size limit."
|
||||
cannotUploadBecauseUnallowedFileType: "Unable to upload due to unauthorized file type."
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "Automatic marking as sensitive"
|
||||
enableAutoSensitiveDescription: "Allows automatic detection and marking of sensitive media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide."
|
||||
|
|
@ -1357,6 +1360,10 @@ emojiMute: "Mute emoji"
|
|||
emojiUnmute: "Unmute emoji"
|
||||
muteX: "Mute {x}"
|
||||
unmuteX: "Unmute {x}"
|
||||
abort: "Abort"
|
||||
tip: "Tips & Tricks"
|
||||
redisplayAllTips: "Show all “Tips & Tricks” again"
|
||||
hideAllTips: "Hide all \"Tips & Tricks\""
|
||||
_chat:
|
||||
noMessagesYet: "No messages yet"
|
||||
newMessage: "New message"
|
||||
|
|
@ -1979,6 +1986,9 @@ _role:
|
|||
canImportMuting: "Allow importing muting"
|
||||
canImportUserLists: "Allow importing lists"
|
||||
chatAvailability: "Allow Chat"
|
||||
uploadableFileTypes: "Uploadable file types"
|
||||
uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)"
|
||||
uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification."
|
||||
_condition:
|
||||
roleAssignedTo: "Assigned to manual roles"
|
||||
isLocal: "Local user"
|
||||
|
|
@ -2894,6 +2904,8 @@ _offlineScreen:
|
|||
_urlPreviewSetting:
|
||||
title: "URL preview settings"
|
||||
enable: "Enable URL preview"
|
||||
allowRedirect: "Allow URL preview redirection"
|
||||
allowRedirectDescription: "If a URL has a redirection set, you can enable this feature to follow the redirection and display a preview of the redirected content. Disabling this will save server resources, but redirected content will not be displayed."
|
||||
timeout: "Time out when getting preview (ms)"
|
||||
timeoutDescription: "If it takes longer than this value to get the preview, the preview won’t be generated."
|
||||
maximumContentLength: "Maximum Content-Length (bytes)"
|
||||
|
|
@ -3090,6 +3102,8 @@ _uploader:
|
|||
abortConfirm: "Some files have not been uploaded, do you want to abort?"
|
||||
doneConfirm: "Some files have not been uploaded, do you want to continue anyway?"
|
||||
maxFileSizeIsX: "The maximum file size that can be uploaded is {x}"
|
||||
allowedTypes: "Uploadable file types"
|
||||
tip: "The file has not yet been uploaded so this dialog allows you to confirm, rename, compress, and crop the file before uploading. When ready, you can start uploading by pressing the “Upload” button."
|
||||
_clientPerformanceIssueTip:
|
||||
title: "Performance tips"
|
||||
makeSureDisabledAdBlocker: "Disable your adblocker"
|
||||
|
|
@ -3098,3 +3112,7 @@ _clientPerformanceIssueTip:
|
|||
makeSureDisabledCustomCss_description: "Overriding styles can affect performance. Please make sure that custom CSS or extensions that override styles are not enabled."
|
||||
makeSureDisabledAddons: "Disable extensions"
|
||||
makeSureDisabledAddons_description: "Some extensions may interfere with client behavior and affect performance. Please disable your browser extensions and see if this improves the situation."
|
||||
_clip:
|
||||
tip: "Clip is a feature that allows you to organize your notes."
|
||||
_userLists:
|
||||
tip: "Lists can contain any user you specify when creating, the created list can then be displayed as a timeline showing only the specified users."
|
||||
|
|
|
|||
|
|
@ -5461,6 +5461,18 @@ export interface Locale extends ILocale {
|
|||
* 中止
|
||||
*/
|
||||
"abort": string;
|
||||
/**
|
||||
* ヒントとコツ
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
* 全ての「ヒントとコツ」を再表示
|
||||
*/
|
||||
"redisplayAllTips": string;
|
||||
/**
|
||||
* 全ての「ヒントとコツ」を非表示
|
||||
*/
|
||||
"hideAllTips": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* まだメッセージはありません
|
||||
|
|
@ -7741,6 +7753,10 @@ export interface Locale extends ILocale {
|
|||
* MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)
|
||||
*/
|
||||
"uploadableFileTypes_caption": string;
|
||||
/**
|
||||
* ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。
|
||||
*/
|
||||
"uploadableFileTypes_caption2": ParameterizedString<"x">;
|
||||
};
|
||||
"_condition": {
|
||||
/**
|
||||
|
|
@ -9691,7 +9707,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"excludeInactiveUsers": string;
|
||||
/**
|
||||
* インポートした人による返信をTLに含むようにする
|
||||
* 返信をTLに含むかの情報がファイルにない場合に、インポートした人による返信をTLに含むようにする
|
||||
*/
|
||||
"withReplies": string;
|
||||
};
|
||||
|
|
@ -11216,6 +11232,14 @@ export interface Locale extends ILocale {
|
|||
* URLプレビューを有効にする
|
||||
*/
|
||||
"enable": string;
|
||||
/**
|
||||
* プレビュー先のリダイレクトを許可
|
||||
*/
|
||||
"allowRedirect": string;
|
||||
/**
|
||||
* 入力されたURLがリダイレクトされる場合に、そのリダイレクト先をたどってプレビューを表示するかどうかを設定します。無効にするとサーバーリソースの節約になりますが、リダイレクト先の内容は表示されなくなります。
|
||||
*/
|
||||
"allowRedirectDescription": string;
|
||||
/**
|
||||
* プレビュー取得時のタイムアウト(ms)
|
||||
*/
|
||||
|
|
@ -11941,6 +11965,10 @@ export interface Locale extends ILocale {
|
|||
* アップロード可能なファイル種別
|
||||
*/
|
||||
"allowedTypes": string;
|
||||
/**
|
||||
* ファイルはまだアップロードされていません。このダイアログで、アップロード前の確認・リネーム・圧縮・クロッピングなどが行えます。準備が出来たら、「アップロード」ボタンを押してアップロードを開始できます。
|
||||
*/
|
||||
"tip": string;
|
||||
};
|
||||
"_clientPerformanceIssueTip": {
|
||||
/**
|
||||
|
|
@ -11972,6 +12000,18 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"makeSureDisabledAddons_description": string;
|
||||
};
|
||||
"_clip": {
|
||||
/**
|
||||
* クリップは、ノートをまとめることができる機能です。
|
||||
*/
|
||||
"tip": string;
|
||||
};
|
||||
"_userLists": {
|
||||
/**
|
||||
* 任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。
|
||||
*/
|
||||
"tip": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ silenceThisInstance: "Silenziare l'istanza"
|
|||
mediaSilenceThisInstance: "Silenzia i media dell'istanza"
|
||||
operations: "Operazioni"
|
||||
software: "Software"
|
||||
softwareName: "Nome del software"
|
||||
version: "Versione"
|
||||
metadata: "Metadato"
|
||||
withNFiles: "{n} file in allegato"
|
||||
|
|
@ -297,6 +298,7 @@ uploadFromUrl: "Incolla URL immagine"
|
|||
uploadFromUrlDescription: "URL del file che vuoi caricare"
|
||||
uploadFromUrlRequested: "Caricamento richiesto"
|
||||
uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo."
|
||||
uploadNFiles: "Caricare {n} file singolarmente"
|
||||
explore: "Esplora"
|
||||
messageRead: "Visualizzato"
|
||||
noMoreHistory: "Non c'è più cronologia da visualizzare"
|
||||
|
|
@ -574,8 +576,10 @@ showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timel
|
|||
showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline"
|
||||
withRepliesByDefaultForNewlyFollowed: "Quando segui nuovi profili, includi le risposte in TL come impostazione predefinita"
|
||||
newNoteRecived: "Nuove Note da leggere"
|
||||
newNote: "Nuova Nota"
|
||||
sounds: "Impostazioni suoni"
|
||||
sound: "Suono"
|
||||
notificationSoundSettings: "Preferenze di notifica"
|
||||
listen: "Ascolta"
|
||||
none: "Nessuno"
|
||||
showInPage: "Visualizza in pagina"
|
||||
|
|
@ -790,6 +794,7 @@ wide: "Largo"
|
|||
narrow: "Stretto"
|
||||
reloadToApplySetting: "Le tue preferenze verranno impostate dopo il ricaricamento della pagina. Vuoi ricaricare adesso?"
|
||||
needReloadToApply: "È necessario riavviare per rendere effettive le modifiche."
|
||||
needToRestartServerToApply: "Per attivare le modifiche, occorre riavviare il server."
|
||||
showTitlebar: "Visualizza la barra del titolo"
|
||||
clearCache: "Svuota la cache"
|
||||
onlineUsersCount: "{n} persone attive adesso"
|
||||
|
|
@ -996,6 +1001,7 @@ failedToUpload: "errore di caricamento"
|
|||
cannotUploadBecauseInappropriate: "Non è possibile caricarlo perché è stato stabilito che potrebbe contenere contenuti inappropriati."
|
||||
cannotUploadBecauseNoFreeSpace: "Impossibile caricare a causa della mancanza di spazio libero sul drive."
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "Il file non può essere caricato perché eccede le dimensioni consentite."
|
||||
cannotUploadBecauseUnallowedFileType: "Impossibile caricare a causa di un tipo file non autorizzato."
|
||||
beta: "Versione beta"
|
||||
enableAutoSensitive: "Determinazione automatica del NSFW"
|
||||
enableAutoSensitiveDescription: "Se disponibile, il flag NSFW viene impostato automaticamente sui media utilizzando l'apprendimento automatico. Anche se questa funzione è disattivata, in alcuni casi può essere impostata automaticamente."
|
||||
|
|
@ -1342,6 +1348,20 @@ embed: "Incorporare"
|
|||
settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)"
|
||||
readonly: "Sola lettura"
|
||||
goToDeck: "Torna al Deck"
|
||||
federationJobs: "Coda di federazione"
|
||||
scrollToClose: "Scorri per chiudere"
|
||||
advice: "Consiglio"
|
||||
realtimeMode: "Modalità in tempo reale"
|
||||
turnItOn: "Attivare"
|
||||
turnItOff: "Disattivare"
|
||||
emojiMute: "Silenzia emoji"
|
||||
emojiUnmute: "De silenzia emoji"
|
||||
muteX: "Silenzia {x}"
|
||||
unmuteX: "De silenzia {x}"
|
||||
abort: "Annulla"
|
||||
tip: "Suggerimento"
|
||||
redisplayAllTips: "Mostra tutti i suggerimenti"
|
||||
hideAllTips: "Nascondi tutti i suggerimenti"
|
||||
_chat:
|
||||
noMessagesYet: "Ancora nessun messaggio"
|
||||
newMessage: "Nuovo messaggio"
|
||||
|
|
@ -1375,6 +1395,8 @@ _chat:
|
|||
chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona."
|
||||
cannotChatWithTheUser: "Impossibile chattare con questa persona"
|
||||
cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo."
|
||||
youAreNotAMemberOfThisRoomButInvited: "Non partecipi a questa stanza di chat, ma hai ricevuto un invito. Per partecipare, accetta l'invito."
|
||||
doYouAcceptInvitation: "Intendi accettare l'invito?"
|
||||
chatWithThisUser: "Chatta con questa persona"
|
||||
thisUserAllowsChatOnlyFromFollowers: "Questa persona permette di chattare soltanto i propri Follower."
|
||||
thisUserAllowsChatOnlyFromFollowing: "Questa persona permette di chattare soltanto ai suoi Follow."
|
||||
|
|
@ -1414,10 +1436,19 @@ _settings:
|
|||
makeEveryTextElementsSelectable: "Imposta ogni elemento come selezionabile"
|
||||
makeEveryTextElementsSelectable_description: "Potrebbe ridurre l'usabilità in alcune situazioni."
|
||||
useStickyIcons: "Fissa le icone durante lo scorrimento"
|
||||
enableHighQualityImagePlaceholders: "Mostra un segnaposto per immagini in alta qualità"
|
||||
uiAnimations: "Animazione dell'interfaccia"
|
||||
showNavbarSubButtons: "Mostra i pulsanti secondari nella barra di navigazione"
|
||||
ifOn: "Quando attivato"
|
||||
ifOff: "Quando disattivato"
|
||||
enableSyncThemesBetweenDevices: "Sincronizzare il tema tra i dispositivi"
|
||||
enablePullToRefresh: "Scorri e aggiorna"
|
||||
enablePullToRefresh_description: "Clicca col mouse e gira la rotella."
|
||||
realtimeMode_description: "Connette al server e aggiorna il contenuto in tempo reale. Potrebbe aumentare l'uso dei dati e il consumo della batteria."
|
||||
contentsUpdateFrequency: "Frequenza di ricezione contenuti"
|
||||
contentsUpdateFrequency_description: "Se l'impostazione è alta, verranno aggiornati più frequentemente, consumando più dati e più batteria."
|
||||
contentsUpdateFrequency_description2: "Quando la modalità è in tempo reale, arriveranno a prescindere."
|
||||
showUrlPreview: "Mostra anteprima dell'URL"
|
||||
_chat:
|
||||
showSenderName: "Mostra il nome del mittente"
|
||||
sendOnEnter: "Invio spedisce"
|
||||
|
|
@ -1425,6 +1456,7 @@ _preferencesProfile:
|
|||
profileName: "Nome del profilo"
|
||||
profileNameDescription: "Impostare il nome che indentifica questo dispositivo."
|
||||
profileNameDescription2: "Es: \"PC principale\" o \"Cellulare\""
|
||||
manageProfiles: "Gestione profili"
|
||||
_preferencesBackup:
|
||||
autoBackup: "Backup automatico"
|
||||
restoreFromBackup: "Ripristinare da backup"
|
||||
|
|
@ -1463,6 +1495,7 @@ _delivery:
|
|||
manuallySuspended: "Sospesa manualmente"
|
||||
goneSuspended: "Sospensione server a causa dell'eliminazione"
|
||||
autoSuspendedForNotResponding: "Sospensione del server a causa di mancata risposta"
|
||||
softwareSuspended: "Attualmente non disponibile perché il software non è più distribuito"
|
||||
_bubbleGame:
|
||||
howToPlay: "Come giocare"
|
||||
hold: "Tieni"
|
||||
|
|
@ -1594,6 +1627,23 @@ _serverSettings:
|
|||
openRegistration: "Registrazioni aperte"
|
||||
openRegistrationWarning: "L’apertura della registrazione comporta dei rischi. Ti consigliamo di attivarla solo se hai predisposto il monitoraggio continuo del tuo server e puoi rispondere immediatamente se si verifica un problema."
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
|
||||
deliverSuspendedSoftware: "Software fuori produzione"
|
||||
deliverSuspendedSoftwareDescription: "A causa di vulnerabilità o altri motivi, puoi interrompere la distribuzione di un software da un server specificandone il nome e la versione. Le informazioni sono fornite dall'altro server e l'autenticità non è garantita. Puoi indicare un intervallo di versione semantica, ma specificando >= 2024.3.1 non verranno incluse le versioni personalizzate come ad esempio 2024.3.1-custom.0, pertanto ti consigliamo di specificare una versione come >= 2024.3.1-0."
|
||||
singleUserMode: "Modalità utente singolo"
|
||||
singleUserMode_description: "Se sei l'unica persona a utilizzare questo server, l'abilitazione di questa modalità ottimizzerà le prestazioni."
|
||||
signToActivityPubGet: "Firma delle richieste GET"
|
||||
signToActivityPubGet_description: "Normalmente questa opzione dovrebbe essere abilitata. Se si verificano problemi con la comunicazione federata, disabilitarla potrebbe migliorare la situazione, ma d'altro canto potrebbe rendere impossibile la comunicazione, a seconda del server."
|
||||
proxyRemoteFiles: "Proxy di file remoti"
|
||||
proxyRemoteFiles_description: "Se abilitato, i file remoti verranno serviti tramite proxy. Utile per generare miniature delle immagini e proteggere la privacy degli utenti."
|
||||
allowExternalApRedirect: "Consenti reindirizzamenti per le query tramite ActivityPub"
|
||||
allowExternalApRedirect_description: "Se abilitata, consente ad altri server di interrogare contenuti di terze parti tramite il tuo server, con conseguente potenziale falsificazione dei contenuti."
|
||||
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."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Tutto pubblico"
|
||||
localOnly: "Pubblica solo contenuti locali, mantieni privati i contenuti remoti"
|
||||
none: "Tutto privato"
|
||||
_accountMigration:
|
||||
moveFrom: "Migra un altro profilo dentro a questo"
|
||||
moveFromSub: "Crea un alias verso un altro profilo remoto"
|
||||
|
|
@ -1911,6 +1961,7 @@ _role:
|
|||
canManageCustomEmojis: "Gestire le emoji personalizzate"
|
||||
canManageAvatarDecorations: "Gestisce le decorazioni di immagini del profilo"
|
||||
driveCapacity: "Capienza del Drive"
|
||||
maxFileSize: "Dimensione massima del file caricabile"
|
||||
alwaysMarkNsfw: "Impostare sempre come esplicito (NSFW)"
|
||||
canUpdateBioMedia: "Può aggiornare foto profilo e di testata"
|
||||
pinMax: "Quantità massima di Note in primo piano"
|
||||
|
|
@ -1933,6 +1984,9 @@ _role:
|
|||
canImportMuting: "Può importare Silenziati"
|
||||
canImportUserLists: "Può importare liste di Profili"
|
||||
chatAvailability: "Chat consentita"
|
||||
uploadableFileTypes: "Tipi di file caricabili"
|
||||
uploadableFileTypes_caption: "Specifica il tipo MIME. Puoi specificare più valori separandoli andando a capo, oppure indicare caratteri jolly con un asterisco (*). Ad esempio: image/*"
|
||||
uploadableFileTypes_caption2: "A seconda del file, il tipo potrebbe non essere determinato. Se si desidera consentire tali file, aggiungere {x} alla specifica."
|
||||
_condition:
|
||||
roleAssignedTo: "Assegnato a ruoli manualmente"
|
||||
isLocal: "Profilo locale"
|
||||
|
|
@ -2785,6 +2839,12 @@ _dataSaver:
|
|||
_avatar:
|
||||
title: "Immagine del profilo"
|
||||
description: "Impedire l'animazione per l'immagine del profilo. Le immagini animate possono avere dimensioni file maggiori rispetto a quelle normali, puoi ridurre ulteriormente l'utilizzo dei dati."
|
||||
_urlPreviewThumbnail:
|
||||
title: "Nascondi le miniature nell'anteprima URL"
|
||||
description: "Le immagini in miniatura nell'anteprima URL non verranno più caricate."
|
||||
_disableUrlPreview:
|
||||
title: "Disabilita l'anteprima URL"
|
||||
description: "Disabilita la funzione di anteprima URL. A differenza di una semplice immagine in miniatura, questo riduce il tempo necessario per caricare le informazioni collegate."
|
||||
_code:
|
||||
title: "Codice evidenziato"
|
||||
description: "Impedire che il codice sorgente sia automaticamente evidenziato. Evidenziare il codice richiede il caricamento di un file per ogni linguaggio. Puoi evidenziare soltanto il codice che intendi leggere e ridurre il traffico inutilizzato."
|
||||
|
|
@ -2990,3 +3050,65 @@ _search:
|
|||
pleaseEnterServerHost: "Inserire il nome host"
|
||||
pleaseSelectUser: "Per favore, seleziona un profilo"
|
||||
serverHostPlaceholder: "Es: misskey.example.com"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "L'installazione di Misskey è completata!"
|
||||
firstCreateAccount: "Per prima cosa, crea un account amministratore."
|
||||
accountCreated: "Il tuo account amministratore è stato creato!"
|
||||
serverSetting: "Configurazione del server"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "Questa procedura guidata ti aiuterà a configurare facilmente il tuo server in modo ottimale."
|
||||
settingsYouMakeHereCanBeChangedLater: "Potrai anche modificare le impostazioni in seguito."
|
||||
howWillYouUseMisskey: "Come si usa Misskey?"
|
||||
_use:
|
||||
single: "Modalità utenza singola"
|
||||
single_description: "Se intendi usarlo come tuo server personale"
|
||||
single_youCanCreateMultipleAccounts: "Anche se lo utilizzi come server per una sola persona, puoi creare più account in base alle tue esigenze."
|
||||
group: "Modalità multi utentza"
|
||||
group_description: "Invita altre persone fidate ad usare il server insieme a te"
|
||||
open: "Server aperto"
|
||||
open_description: "Per ospitare un numero imprecisato di persone"
|
||||
openServerAdvice: "Ospitare un numero imprecisato di persone comporta dei rischi. Ti consigliamo di adottare un solido sistema di moderazione, in modo da poter gestire eventuali problemi che potrebbero presentarsi pubblicando contenuti proposti da altre persone, che potrebbero essere sconosciute."
|
||||
openServerAntiSpamAdvice: "Presta molta attenzione alla sicurezza, ad esempio attivando funzionalità anti-bot (iscrizioni automatiche) come reCAPTCHA. Questo può evitare che il tuo server diventi un trampolino di lancio per lo spam di altri."
|
||||
howManyUsersDoYouExpect: "Quante persone pensi che parteciperanno?"
|
||||
_scale:
|
||||
small: "100 persone o meno (piccolo)"
|
||||
medium: "Da 100 a 1000 persone (medio)"
|
||||
large: "Oltre 1000 persone (grande)"
|
||||
largeScaleServerAdvice: "Configurare grandi server potrebbe richiedere conoscenze infrastrutturali avanzate, ad esempio, il bilanciamento del carico e la replicazione del database."
|
||||
doYouConnectToFediverse: "Vuoi connetterti al Fediverso?"
|
||||
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."
|
||||
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."
|
||||
followingSettingsAreRecommended: "Si consigliano le seguenti impostazioni:"
|
||||
applyTheseSettings: "Applica questa impostazione"
|
||||
skipSettings: "Salta l'installazione"
|
||||
settingsCompleted: "Installazione completata!"
|
||||
settingsCompleted_description: "Grazie per il tuo impegno. Adesso che hai completato la configurazione, puoi iniziare a utilizzare il tuo server."
|
||||
settingsCompleted_description2: "Le impostazioni dettagliate del server possono essere effettuate tramite il Pannello di controllo."
|
||||
donationRequest: "Per favore Fai una donazione"
|
||||
_donationRequest:
|
||||
text1: "Misskey è un software libero sviluppato da volontari."
|
||||
text2: "Se puoi, ti preghiamo di prendere in considerazione l'idea di fare una donazione, così potremo continuare a sviluppare."
|
||||
text3: "Sono previsti anche dei vantaggi speciali per i sostenitori!"
|
||||
_uploader:
|
||||
compressedToX: "Compresso in {x}"
|
||||
savedXPercent: "{x}% risparmiati"
|
||||
abortConfirm: "Alcuni file non sono stati caricati. Vuoi annullare l'operazione?"
|
||||
doneConfirm: "Alcuni file non sono stati caricati. Vuoi completarli?"
|
||||
maxFileSizeIsX: "La dimensione massima del file che puoi caricare è {x}."
|
||||
allowedTypes: "Tipi di file caricabili"
|
||||
tip: "Il file non è ancora stato caricato. Puoi controllare, rinominare, comprimere, ritagliare, prima del caricamento. Quando hai finito, premi il bottone \"Carica\" per completare."
|
||||
_clientPerformanceIssueTip:
|
||||
title: "Se ritieni che la batteria si stia scaricando troppo"
|
||||
makeSureDisabledAdBlocker: "Disattiva il tuo AdBlocker"
|
||||
makeSureDisabledAdBlocker_description: "Gli AdBlocker possono influire sulle prestazioni. Controlla se nel tuo sistema operativo, nel browser o nei componenti aggiuntivi è abilitato un AdBlocker."
|
||||
makeSureDisabledCustomCss: "Disabilita CSS personalizzato"
|
||||
makeSureDisabledCustomCss_description: "La riscrittura degli stili CSS può influire sulle prestazioni. Assicurati di non avere CSS personalizzati o estensioni abilitate che sovrascrivano i tuoi stili."
|
||||
makeSureDisabledAddons: "Disabilitare le estensioni"
|
||||
makeSureDisabledAddons_description: "Alcune estensioni potrebbero interferire con il funzionamento del client e comprometterne le prestazioni. Prova a disattivare le estensioni del browser e vedi se il problema persiste."
|
||||
_clip:
|
||||
tip: "Le clip sono una funzionalità che consente di raggruppare le Note."
|
||||
_userLists:
|
||||
tip: "Puoi creare un elenco di Note create da qualsiasi profilo. L'elenco è visualizzato come una sequenza temporale."
|
||||
|
|
|
|||
|
|
@ -1360,6 +1360,9 @@ emojiUnmute: "絵文字ミュート解除"
|
|||
muteX: "{x}をミュート"
|
||||
unmuteX: "{x}のミュートを解除"
|
||||
abort: "中止"
|
||||
tip: "ヒントとコツ"
|
||||
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
||||
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
||||
|
||||
_chat:
|
||||
noMessagesYet: "まだメッセージはありません"
|
||||
|
|
@ -2004,6 +2007,7 @@ _role:
|
|||
chatAvailability: "チャットを許可"
|
||||
uploadableFileTypes: "アップロード可能なファイル種別"
|
||||
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
|
||||
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
isLocal: "ローカルユーザー"
|
||||
|
|
@ -2552,7 +2556,7 @@ _exportOrImport:
|
|||
userLists: "リスト"
|
||||
excludeMutingUsers: "ミュートしているユーザーを除外"
|
||||
excludeInactiveUsers: "使われていないアカウントを除外"
|
||||
withReplies: "インポートした人による返信をTLに含むようにする"
|
||||
withReplies: "返信をTLに含むかの情報がファイルにない場合に、インポートした人による返信をTLに含むようにする"
|
||||
|
||||
_charts:
|
||||
federation: "連合"
|
||||
|
|
@ -2983,6 +2987,8 @@ _offlineScreen:
|
|||
_urlPreviewSetting:
|
||||
title: "URLプレビューの設定"
|
||||
enable: "URLプレビューを有効にする"
|
||||
allowRedirect: "プレビュー先のリダイレクトを許可"
|
||||
allowRedirectDescription: "入力されたURLがリダイレクトされる場合に、そのリダイレクト先をたどってプレビューを表示するかどうかを設定します。無効にするとサーバーリソースの節約になりますが、リダイレクト先の内容は表示されなくなります。"
|
||||
timeout: "プレビュー取得時のタイムアウト(ms)"
|
||||
timeoutDescription: "プレビュー取得の所要時間がこの値を超えた場合、プレビューは生成されません。"
|
||||
maximumContentLength: "Content-Lengthの最大値(byte)"
|
||||
|
|
@ -3194,6 +3200,7 @@ _uploader:
|
|||
doneConfirm: "アップロードされていないファイルがありますが、完了しますか?"
|
||||
maxFileSizeIsX: "アップロード可能な最大ファイルサイズは{x}です。"
|
||||
allowedTypes: "アップロード可能なファイル種別"
|
||||
tip: "ファイルはまだアップロードされていません。このダイアログで、アップロード前の確認・リネーム・圧縮・クロッピングなどが行えます。準備が出来たら、「アップロード」ボタンを押してアップロードを開始できます。"
|
||||
|
||||
_clientPerformanceIssueTip:
|
||||
title: "バッテリー消費が多いと感じたら"
|
||||
|
|
@ -3203,3 +3210,9 @@ _clientPerformanceIssueTip:
|
|||
makeSureDisabledCustomCss_description: "スタイルを上書きするとパフォーマンスに影響を及ぼすことがあります。カスタムCSSや、スタイルを上書きする拡張機能が有効になっていないか確認してください。"
|
||||
makeSureDisabledAddons: "拡張機能を無効にしてください"
|
||||
makeSureDisabledAddons_description: "一部の拡張機能はクライアントの動作に干渉しパフォーマンスに影響を及ぼすことがあります。ブラウザの拡張機能を無効にして改善するか確認してください。"
|
||||
|
||||
_clip:
|
||||
tip: "クリップは、ノートをまとめることができる機能です。"
|
||||
|
||||
_userLists:
|
||||
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ uploadFromUrl: "Tải lên bằng một URL"
|
|||
uploadFromUrlDescription: "URL của tập tin bạn muốn tải lên"
|
||||
uploadFromUrlRequested: "Đã yêu cầu tải lên"
|
||||
uploadFromUrlMayTakeTime: "Sẽ mất một khoảng thời gian để tải lên xong."
|
||||
uploadNFiles: "Tải lên {n} tập tin"
|
||||
explore: "Khám phá"
|
||||
messageRead: "Đã đọc"
|
||||
noMoreHistory: "Không còn gì để đọc"
|
||||
|
|
@ -576,6 +577,7 @@ withRepliesByDefaultForNewlyFollowed: "Mặc định hiển thị trả lời t
|
|||
newNoteRecived: "Đã nhận tút mới"
|
||||
sounds: "Âm thanh"
|
||||
sound: "Âm thanh"
|
||||
notificationSoundSettings: "Cài đặt âm thanh thông báo"
|
||||
listen: "Nghe"
|
||||
none: "Không"
|
||||
showInPage: "Hiện trong trang"
|
||||
|
|
@ -1174,9 +1176,12 @@ mutualFollow: "Theo dõi lẫn nhau"
|
|||
followingOrFollower: "Đang theo dõi hoặc người theo dõi"
|
||||
externalServices: "Các dịch vụ bên ngoài"
|
||||
sourceCode: "Mã nguồn"
|
||||
sourceCodeIsNotYetProvided: "Mã nguồn hiện chưa có sẵn, vui lòng liên hệ với quản trị viên để khắc phục sự cố này."
|
||||
repositoryUrlDescription: "Nếu bạn có kho lưu trữ mã nguồn có thể truy cập công khai, hãy nhập URL. Nếu bạn đang sử dụng Misskey theo mặc định (không thực hiện bất kỳ thay đổi nào đối với mã nguồn), hãy nhập https://github.com/misskey-dev/misskey."
|
||||
feedback: "Phản hồi"
|
||||
feedbackUrl: "URL phản hồi"
|
||||
impressum: "Thông tin nhà điều hành"
|
||||
impressumUrl: "URL thông tin nhà điều hành"
|
||||
privacyPolicy: "Chính sách bảo mật"
|
||||
privacyPolicyUrl: "URL Chính sách bảo mật"
|
||||
tosAndPrivacyPolicy: "Điều khoản sử dụng và Chính sách bảo mật"
|
||||
|
|
@ -1190,6 +1195,7 @@ showAvatarDecorations: "Hiển thị trang trí ảnh đại diện"
|
|||
releaseToRefresh: "Thả để làm mới"
|
||||
refreshing: "Đang làm mới"
|
||||
pullDownToRefresh: "Kéo xuống để làm mới"
|
||||
signupPendingError: "Đã xảy ra sự cố khi xác minh địa chỉ email của bạn. Liên kết có thể đã hết hạn."
|
||||
cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải có chú thích."
|
||||
decorate: "Trang trí"
|
||||
lastNDays: "{n} ngày trước"
|
||||
|
|
@ -1200,11 +1206,17 @@ passkeyVerificationFailed: "Xác minh mật khẩu không thành công."
|
|||
messageToFollower: "Tin nhắn cho người theo dõi"
|
||||
yourNameContainsProhibitedWords: "Tên bạn đang cố gắng đổi có chứa chuỗi ký tự bị cấm."
|
||||
yourNameContainsProhibitedWordsDescription: "Tên có chứa chuỗi ký tự bị cấm. Nếu bạn muốn sử dụng tên này, hãy liên hệ với quản trị viên máy chủ của bạn."
|
||||
pleaseSelectAccount: "Chọn tài khoản của bạn"
|
||||
federationDisabled: "Liên kết bị vô hiệu hóa trên máy chủ này. Bạn không thể tương tác với người dùng trên các máy chủ khác."
|
||||
reactAreYouSure: "Bạn có muốn phản hồi với \" {emoji} \" không?"
|
||||
preferences: "Thiết lập môi trường"
|
||||
accessibility: "Khả năng tiếp cận"
|
||||
paste: "dán"
|
||||
postForm: "Mẫu đăng"
|
||||
information: "Giới thiệu"
|
||||
chat: "Trò chuyện"
|
||||
migrateOldSettings: "Di chuyển cài đặt cũ"
|
||||
migrateOldSettings_description: "Thông thường, quá trình này diễn ra tự động, nhưng nếu vì lý do nào đó mà quá trình di chuyển không thành công, bạn có thể kích hoạt thủ công quy trình di chuyển, quá trình này sẽ ghi đè lên thông tin cấu hình hiện tại của bạn."
|
||||
_chat:
|
||||
invitations: "Mời"
|
||||
noHistory: "Không có dữ liệu"
|
||||
|
|
@ -1797,6 +1809,7 @@ _widgets:
|
|||
_userList:
|
||||
chooseList: "Chọn danh sách"
|
||||
clicker: "clicker"
|
||||
chat: "Trò chuyện"
|
||||
_cw:
|
||||
hide: "Ẩn"
|
||||
show: "Tải thêm"
|
||||
|
|
@ -2022,6 +2035,7 @@ _deck:
|
|||
channel: "Kênh"
|
||||
mentions: "Lượt nhắc"
|
||||
direct: "Nhắn riêng"
|
||||
chat: "Trò chuyện"
|
||||
_dialog:
|
||||
charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}"
|
||||
charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}"
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,7 @@ failedToUpload: "上传失败"
|
|||
cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法上传。"
|
||||
cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。"
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "无法上传文件,超过文件大小限制。"
|
||||
cannotUploadBecauseUnallowedFileType: "因文件类型被禁止而无法上传。"
|
||||
beta: "测试"
|
||||
enableAutoSensitive: "自动 NSFW 识别"
|
||||
enableAutoSensitiveDescription: "使用机器学习在可用时自动使用 NSFW 标记来标记媒体。即使您关闭此功能,根据服务器的不同,它仍然可能会自动设置。"
|
||||
|
|
@ -1981,6 +1982,9 @@ _role:
|
|||
canImportMuting: "允许导入隐藏列表"
|
||||
canImportUserLists: "允许导入用户列表"
|
||||
chatAvailability: "允许聊天"
|
||||
uploadableFileTypes: "可上传的文件类型"
|
||||
uploadableFileTypes_caption: "指定 MIME 类型。可用换行指定多个类型,也可以用星号(*)作为通配符。(如 image/*)"
|
||||
uploadableFileTypes_caption2: "文件根据文件的不同,可能无法判断其类型。若要允许此类文件,请在指定中添加 {x}。"
|
||||
_condition:
|
||||
roleAssignedTo: "已分配给手动角色"
|
||||
isLocal: "是本地用户"
|
||||
|
|
@ -3092,6 +3096,7 @@ _uploader:
|
|||
abortConfirm: "还有未上传的文件,要中止吗?"
|
||||
doneConfirm: "还有未上传的文件,要完成吗?"
|
||||
maxFileSizeIsX: "可上传最大 {x} 的文件。"
|
||||
allowedTypes: "可上传的文件类型"
|
||||
_clientPerformanceIssueTip:
|
||||
title: "如果觉得电池耗电过高"
|
||||
makeSureDisabledAdBlocker: "请关闭广告拦截器"
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,7 @@ failedToUpload: "上傳失敗"
|
|||
cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。"
|
||||
cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。"
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "由於超過了檔案大小的限制,無法上傳。"
|
||||
cannotUploadBecauseUnallowedFileType: "由於檔案類型不被允許,無法上傳。\n"
|
||||
beta: "測試版"
|
||||
enableAutoSensitive: "自動 NSFW 判定"
|
||||
enableAutoSensitiveDescription: "如果可行,它將使用機器學習技術判斷檔案是否需要標記為敏感。即使關閉此功能,也可能會依伺服器規則而自動啟用。"
|
||||
|
|
@ -1359,6 +1360,9 @@ emojiUnmute: "表情符號解除靜音"
|
|||
muteX: "將 {x} 靜音"
|
||||
unmuteX: "將 {x} 解除靜音"
|
||||
abort: "取消"
|
||||
tip: "提示與技巧"
|
||||
redisplayAllTips: "重新顯示所有「提示與技巧」"
|
||||
hideAllTips: "隱藏所有「提示與技巧」"
|
||||
_chat:
|
||||
noMessagesYet: "尚無訊息"
|
||||
newMessage: "新訊息"
|
||||
|
|
@ -1981,6 +1985,9 @@ _role:
|
|||
canImportMuting: "允許匯入靜音名單"
|
||||
canImportUserLists: "允許匯入清單"
|
||||
chatAvailability: "允許聊天"
|
||||
uploadableFileTypes: "可上傳的檔案類型"
|
||||
uploadableFileTypes_caption: "請指定 MIME 類型。可以用換行區隔多個類型,也可以使用星號(*)作為萬用字元進行指定。(例如:image/*)\n"
|
||||
uploadableFileTypes_caption2: "有些檔案可能無法判斷其類型。若要允許這類檔案,請在指定中加入 {x}。"
|
||||
_condition:
|
||||
roleAssignedTo: "手動指派角色完成"
|
||||
isLocal: "本地使用者"
|
||||
|
|
@ -2896,6 +2903,8 @@ _offlineScreen:
|
|||
_urlPreviewSetting:
|
||||
title: "URL 預覽設定"
|
||||
enable: "啟用 URL 預覽"
|
||||
allowRedirect: "允許預覽目標的重新導向"
|
||||
allowRedirectDescription: "設定當輸入的 URL 發生重新導向時,是否追蹤該重新導向並顯示預覽。若停用此功能,雖可節省伺服器資源,但將無法顯示重新導向後的內容。\n"
|
||||
timeout: "取得預覽的逾時時間 (ms)"
|
||||
timeoutDescription: "若取得預覽所需的時間超過這個值,則不會產生預覽。"
|
||||
maximumContentLength: "Content-Length 的最大値 (byte)"
|
||||
|
|
@ -3092,6 +3101,8 @@ _uploader:
|
|||
abortConfirm: "有些檔案尚未上傳,您要中止嗎?"
|
||||
doneConfirm: "有些檔案尚未上傳,是否要完成上傳?"
|
||||
maxFileSizeIsX: "可上傳的最大檔案大小為 {x}。"
|
||||
allowedTypes: "可上傳的檔案類型"
|
||||
tip: "檔案尚未上傳。您可以在此對話框中進行上傳前的確認、重新命名、壓縮、裁切等操作。準備完成後,請點選「上傳」按鈕開始上傳。\n"
|
||||
_clientPerformanceIssueTip:
|
||||
title: "如果覺得電池消耗過快的話"
|
||||
makeSureDisabledAdBlocker: "請將廣告阻擋器停用"
|
||||
|
|
@ -3100,3 +3111,7 @@ _clientPerformanceIssueTip:
|
|||
makeSureDisabledCustomCss_description: "覆蓋樣式可能會影響效能。請確認是否啟用了自訂 CSS 或其他會覆蓋樣式的擴充功能。\n"
|
||||
makeSureDisabledAddons: "請停用擴充功能"
|
||||
makeSureDisabledAddons_description: "部分擴充功能可能會干擾用戶端的運作並影響效能。請嘗試停用瀏覽器的擴充功能,以確認是否能改善情況"
|
||||
_clip:
|
||||
tip: "摘錄是一項可以用來整理貼文的功能。"
|
||||
_userLists:
|
||||
tip: "您可以建立包含任意使用者的清單。建立後的清單可以作為時間軸顯示。\n"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.5.1-alpha.4",
|
||||
"version": "2025.5.1-beta.4",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -17,4 +17,15 @@ args.push(...[
|
|||
...process.argv.slice(2),
|
||||
]);
|
||||
|
||||
child_process.spawn(process.execPath, args, { stdio: 'inherit' });
|
||||
const child = child_process.spawn(process.execPath, args, { stdio: 'inherit' });
|
||||
child.on('error', (err) => {
|
||||
console.error('Failed to start Jest:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code === null) {
|
||||
process.exit(128 + signal);
|
||||
} else {
|
||||
process.exit(code);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddUrlPreviewAllowRedirect1748310233000 {
|
||||
name = 'AddUrlPreviewAllowRedirect1748310233000'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewAllowRedirect" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewAllowRedirect"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,8 +67,8 @@
|
|||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.815.0",
|
||||
"@aws-sdk/lib-storage": "3.815.0",
|
||||
"@aws-sdk/client-s3": "3.817.0",
|
||||
"@aws-sdk/lib-storage": "3.817.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@fastify/accepts": "5.0.2",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
|
|
@ -81,9 +81,9 @@
|
|||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@napi-rs/canvas": "0.1.70",
|
||||
"@nestjs/common": "11.1.1",
|
||||
"@nestjs/core": "11.1.1",
|
||||
"@nestjs/testing": "11.1.1",
|
||||
"@nestjs/common": "11.1.2",
|
||||
"@nestjs/core": "11.1.2",
|
||||
"@nestjs/testing": "11.1.2",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.55.0",
|
||||
"@sentry/profiling-node": "8.55.0",
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.21.5",
|
||||
"re2": "1.22.1",
|
||||
"redis-info": "3.1.0",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.2.2",
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.26.1",
|
||||
"systeminformation": "5.27.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.3",
|
||||
"tsc-alias": "1.8.16",
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.17",
|
||||
"@nestjs/platform-express": "10.4.18",
|
||||
"@sentry/vue": "9.22.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.38",
|
||||
|
|
|
|||
|
|
@ -469,13 +469,14 @@ export class DriveService {
|
|||
if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
|
||||
|
||||
const info = await this.fileInfoService.getFileInfo(path, {
|
||||
fileName: name,
|
||||
skipSensitiveDetection: skipNsfwCheck,
|
||||
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
0.5,
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
0.5,
|
||||
sensitiveThresholdForPorn: 0.75,
|
||||
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
|
||||
});
|
||||
|
|
@ -527,7 +528,7 @@ export class DriveService {
|
|||
return info.type.mime === mimeType;
|
||||
});
|
||||
if (!isAllowed) {
|
||||
throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', 'Unallowed file type.');
|
||||
throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`);
|
||||
}
|
||||
|
||||
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ export class FanoutTimelineEndpointService {
|
|||
filter = (note) => {
|
||||
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
|
||||
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
||||
if (isUserRelated(note.renote, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
|
||||
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
||||
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
|
||||
if (isInstanceMuted(note, userMutedInstances)) return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export class FileInfoService {
|
|||
*/
|
||||
@bindThis
|
||||
public async getFileInfo(path: string, opts: {
|
||||
fileName?: string | null;
|
||||
skipSensitiveDetection: boolean;
|
||||
sensitiveThreshold?: number;
|
||||
sensitiveThresholdForPorn?: number;
|
||||
|
|
@ -76,6 +77,26 @@ export class FileInfoService {
|
|||
|
||||
let type = await this.detectType(path);
|
||||
|
||||
if (type.mime === TYPE_OCTET_STREAM.mime && opts.fileName != null) {
|
||||
const ext = opts.fileName.split('.').pop();
|
||||
if (ext === 'txt') {
|
||||
type = {
|
||||
mime: 'text/plain',
|
||||
ext: 'txt',
|
||||
};
|
||||
} else if (ext === 'csv') {
|
||||
type = {
|
||||
mime: 'text/csv',
|
||||
ext: 'csv',
|
||||
};
|
||||
} else if (ext === 'json') {
|
||||
type = {
|
||||
mime: 'application/json',
|
||||
ext: 'json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// image dimensions
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
|
|
@ -438,12 +459,12 @@ export class FileInfoService {
|
|||
*/
|
||||
@bindThis
|
||||
private async detectImageSize(path: string): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
wUnits: string;
|
||||
hUnits: string;
|
||||
orientation?: number;
|
||||
}> {
|
||||
width: number;
|
||||
height: number;
|
||||
wUnits: string;
|
||||
hUnits: string;
|
||||
orientation?: number;
|
||||
}> {
|
||||
const readable = fs.createReadStream(path);
|
||||
const imageSize = await probeImageSize(readable);
|
||||
readable.destroy();
|
||||
|
|
|
|||
|
|
@ -77,9 +77,51 @@ export class QueryService {
|
|||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* ミュートやブロックのようにすべてのタイムラインで共通に使用するフィルターを定義します。
|
||||
*
|
||||
* 特別な事情がない限り、各タイムラインはこの関数を呼び出してフィルターを適用してください。
|
||||
*
|
||||
* Notes for future maintainers:
|
||||
* 1) この関数で生成するクエリと同等の処理が FanoutTimelineEndpointService にあります。
|
||||
* この関数を変更した場合、FanoutTimelineEndpointService の方も変更する必要があります。
|
||||
* 2) 以下のエンドポイントでは特別な事情があるため queryService のそれぞれの関数を呼び出しています。
|
||||
* この関数を変更した場合、以下のエンドポイントの方も変更する必要があることがあります。
|
||||
* - packages/backend/src/server/api/endpoints/clips/notes.ts
|
||||
*/
|
||||
@bindThis
|
||||
public generateBaseNoteFilteringQuery(
|
||||
query: SelectQueryBuilder<any>,
|
||||
me: { id: MiUser['id'] } | null,
|
||||
{
|
||||
excludeUserFromMute,
|
||||
excludeAuthor,
|
||||
}: {
|
||||
excludeUserFromMute?: MiUser['id'],
|
||||
excludeAuthor?: boolean,
|
||||
} = {},
|
||||
): void {
|
||||
this.generateBlockedHostQueryForNote(query, excludeAuthor);
|
||||
this.generateSuspendedUserQueryForNote(query, excludeAuthor);
|
||||
if (me) {
|
||||
this.generateMutedUserQueryForNotes(query, me, { excludeUserFromMute });
|
||||
this.generateBlockedUserQueryForNotes(query, me);
|
||||
this.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote', excludeUserFromMute });
|
||||
this.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
|
||||
}
|
||||
}
|
||||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
@bindThis
|
||||
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
public generateBlockedUserQueryForNotes(
|
||||
q: SelectQueryBuilder<any>,
|
||||
me: { id: MiUser['id'] },
|
||||
{
|
||||
noteColumn = 'note',
|
||||
}: {
|
||||
noteColumn?: string,
|
||||
} = {},
|
||||
): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
|
@ -88,16 +130,20 @@ export class QueryService {
|
|||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.where(`${noteColumn}.userId IS NULL`)
|
||||
.orWhere(`${noteColumn}.userId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
.where(`${noteColumn}.replyUserId IS NULL`)
|
||||
.orWhere(`${noteColumn}.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(`${noteColumn}.renoteUserId IS NULL`)
|
||||
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
|
|
@ -137,13 +183,23 @@ export class QueryService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
public generateMutedUserQueryForNotes(
|
||||
q: SelectQueryBuilder<any>,
|
||||
me: { id: MiUser['id'] },
|
||||
{
|
||||
excludeUserFromMute,
|
||||
noteColumn = 'note',
|
||||
}: {
|
||||
excludeUserFromMute?: MiUser['id'],
|
||||
noteColumn?: string,
|
||||
} = {},
|
||||
): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
||||
if (excludeUserFromMute) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: excludeUserFromMute });
|
||||
}
|
||||
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
|
|
@ -154,32 +210,36 @@ export class QueryService {
|
|||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.where(`${noteColumn}.userId IS NULL`)
|
||||
.orWhere(`${noteColumn}.userId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
.where(`${noteColumn}.replyUserId IS NULL`)
|
||||
.orWhere(`${noteColumn}.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(`${noteColumn}.renoteUserId IS NULL`)
|
||||
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
.andWhere(`${noteColumn}.userHost IS NULL`)
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
.where(`${noteColumn}.replyUserHost IS NULL`)
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
.where(`${noteColumn}.renoteUserHost IS NULL`)
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
|
|
|
|||
|
|
@ -234,10 +234,7 @@ export class SearchService {
|
|||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
return query.limit(pagination.limit).getMany();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
|
||||
public async addMember(target: MiUser, list: MiUserList, me: MiUser, options: { withReplies?: boolean } = {}) {
|
||||
const currentCount = await this.userListMembershipsRepository.countBy({
|
||||
userListId: list.id,
|
||||
});
|
||||
|
|
@ -104,6 +104,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
userId: target.id,
|
||||
userListId: list.id,
|
||||
userListUserId: list.userId,
|
||||
withReplies: options.withReplies ?? false,
|
||||
} as MiUserListMembership);
|
||||
|
||||
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,17 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean {
|
||||
import type { MiUser } from '@/models/_.js';
|
||||
|
||||
interface NoteLike {
|
||||
userId: MiUser['id'];
|
||||
reply?: NoteLike | null;
|
||||
renote?: NoteLike | null;
|
||||
replyUserId?: MiUser['id'] | null;
|
||||
renoteUserId?: MiUser['id'] | null;
|
||||
}
|
||||
|
||||
export function isUserRelated(note: NoteLike | null | undefined, userIds: Set<string>, ignoreAuthor = false): boolean {
|
||||
if (!note) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -12,13 +22,16 @@ export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = fa
|
|||
return true;
|
||||
}
|
||||
|
||||
if (note.reply != null && note.reply.userId !== note.userId && userIds.has(note.reply.userId)) {
|
||||
const replyUserId = note.replyUserId ?? note.reply?.userId;
|
||||
if (replyUserId != null && replyUserId !== note.userId && userIds.has(replyUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.renote != null && note.renote.userId !== note.userId && userIds.has(note.renote.userId)) {
|
||||
const renoteUserId = note.renoteUserId ?? note.renote?.userId;
|
||||
if (renoteUserId != null && renoteUserId !== note.userId && userIds.has(renoteUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,17 @@ type NullOrUndefined<p extends Schema, T> =
|
|||
// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
|
||||
// Get intersection from union
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
||||
type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
|
||||
|
||||
type ArrayToIntersection<T extends ReadonlyArray<Schema>> =
|
||||
T extends readonly [infer Head, ...infer Tail]
|
||||
? Head extends Schema
|
||||
? Tail extends ReadonlyArray<Schema>
|
||||
? Tail extends []
|
||||
? SchemaType<Head>
|
||||
: SchemaType<Head> & ArrayToIntersection<Tail>
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
|
||||
// To get union, we use `Foo extends any ? Hoge<Foo> : never`
|
||||
|
|
@ -236,8 +246,8 @@ type ObjectSchemaTypeDef<p extends Schema> =
|
|||
: never
|
||||
: ObjType<p['properties'], NonNullable<p['required']>>
|
||||
:
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
|
||||
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> :
|
||||
p['allOf'] extends ReadonlyArray<Schema> ? ArrayToIntersection<p['allOf']> :
|
||||
p['additionalProperties'] extends true ? Record<string, any> :
|
||||
p['additionalProperties'] extends Schema ?
|
||||
p['additionalProperties'] extends infer AdditionalProperties ?
|
||||
|
|
@ -277,7 +287,8 @@ export type SchemaTypeDef<p extends Schema> =
|
|||
p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
|
||||
any[]
|
||||
) :
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> :
|
||||
p['allOf'] extends ReadonlyArray<Schema> ? ArrayToIntersection<p['allOf']> :
|
||||
p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
|
||||
any;
|
||||
|
||||
|
|
|
|||
|
|
@ -619,6 +619,11 @@ export class MiMeta {
|
|||
})
|
||||
public urlPreviewEnabled: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public urlPreviewAllowRedirect: boolean;
|
||||
|
||||
@Column('integer', {
|
||||
default: 10000,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ export class ExportFollowingProcessorService {
|
|||
continue;
|
||||
}
|
||||
|
||||
const content = this.utilityService.getFullApAccount(u.username, u.host);
|
||||
const userAcct = this.utilityService.getFullApAccount(u.username, u.host);
|
||||
const content = `${userAcct},withReplies=${following.withReplies}`;
|
||||
await new Promise<void>((res, rej) => {
|
||||
stream.write(content + '\n', err => {
|
||||
if (err) {
|
||||
|
|
|
|||
|
|
@ -67,10 +67,12 @@ export class ExportUserListsProcessorService {
|
|||
const users = await this.usersRepository.findBy({
|
||||
id: In(memberships.map(j => j.userId)),
|
||||
});
|
||||
const usersWithReplies = new Set(memberships.filter(m => m.withReplies).map(m => m.userId));
|
||||
|
||||
for (const u of users) {
|
||||
const acct = this.utilityService.getFullApAccount(u.username, u.host);
|
||||
const content = `${list.name},${acct}`;
|
||||
// 3rd column and later will be key=value pairs
|
||||
const content = `${list.name},${acct},withReplies=${usersWithReplies.has(u.id)}`;
|
||||
await new Promise<void>((res, rej) => {
|
||||
stream.write(content + '\n', err => {
|
||||
if (err) {
|
||||
|
|
|
|||
|
|
@ -67,8 +67,19 @@ export class ImportFollowingProcessorService {
|
|||
const user = job.data.user;
|
||||
|
||||
try {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const parts = line.split(',');
|
||||
const acct = parts[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
let withReplies: boolean | null = null;
|
||||
|
||||
for (const keyValue of parts.slice(2)) {
|
||||
const [key, value] = keyValue.split('=');
|
||||
switch (key) {
|
||||
case 'withReplies':
|
||||
withReplies = value === 'true';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host) return;
|
||||
|
||||
|
|
@ -95,7 +106,7 @@ export class ImportFollowingProcessorService {
|
|||
|
||||
this.logger.info(`Follow ${target.id} ${job.data.withReplies ? 'with replies' : 'without replies'} ...`);
|
||||
|
||||
this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: job.data.withReplies }]);
|
||||
await this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: withReplies ?? job.data.withReplies }]);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error: ${e}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,19 @@ export class ImportUserListsProcessorService {
|
|||
linenum++;
|
||||
|
||||
try {
|
||||
const listName = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(line.split(',')[1].trim());
|
||||
const parts = line.split(',');
|
||||
const listName = parts[0].trim();
|
||||
const { username, host } = Acct.parse(parts[1].trim());
|
||||
let withReplies = false;
|
||||
|
||||
for (const keyValue of parts.slice(2)) {
|
||||
const [key, value] = keyValue.split('=');
|
||||
switch (key) {
|
||||
case 'withReplies':
|
||||
withReplies = value === 'true';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let list = await this.userListsRepository.findOneBy({
|
||||
userId: user.id,
|
||||
|
|
@ -100,7 +111,9 @@ export class ImportUserListsProcessorService {
|
|||
|
||||
if (await this.userListMembershipsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
|
||||
|
||||
this.userListService.addMember(target, list!, user);
|
||||
await this.userListService.addMember(target, list, user, {
|
||||
withReplies: withReplies,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error in line:${linenum} ${e}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,14 +162,21 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['fileId'] },
|
||||
{ required: ['url'] },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['fileId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -186,15 +193,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({
|
||||
where: [{
|
||||
url: ps.url,
|
||||
}, {
|
||||
thumbnailUrl: ps.url,
|
||||
}, {
|
||||
webpublicUrl: ps.url,
|
||||
}],
|
||||
});
|
||||
const file = await this.driveFilesRepository.findOneBy(
|
||||
'fileId' in ps
|
||||
? { id: ps.fileId }
|
||||
: [{ url: ps.url }, { thumbnailUrl: ps.url }, { webpublicUrl: ps.url }],
|
||||
);
|
||||
|
||||
if (file == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
|
|
|
|||
|
|
@ -37,29 +37,45 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
category: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Use `null` to reset the category.',
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
category: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Use `null` to reset the category.',
|
||||
},
|
||||
aliases: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
},
|
||||
aliases: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['id'] },
|
||||
{ required: ['name'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -78,10 +94,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
|
||||
// JSON schemeのanyOfの型変換がうまくいっていないらしい
|
||||
const required = { id: ps.id, name: ps.name } as
|
||||
| { id: MiEmoji['id']; name?: string }
|
||||
| { id?: MiEmoji['id']; name: string };
|
||||
const required = 'id' in ps
|
||||
? { id: ps.id, name: 'name' in ps ? ps.name as string : undefined }
|
||||
: { name: ps.name };
|
||||
|
||||
const error = await this.customEmojiService.update({
|
||||
...required,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
for (let i = 0; i < ps.count; i++) {
|
||||
ticketsPromises.push(this.registrationTicketsRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
createdBy: me,
|
||||
createdById: me.id,
|
||||
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
||||
code: generateInviteCode(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -495,6 +495,10 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
urlPreviewAllowRedirect: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
urlPreviewTimeout: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -704,6 +708,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
notesPerOneAd: instance.notesPerOneAd,
|
||||
summalyProxy: instance.urlPreviewSummaryProxyUrl,
|
||||
urlPreviewEnabled: instance.urlPreviewEnabled,
|
||||
urlPreviewAllowRedirect: instance.urlPreviewAllowRedirect,
|
||||
urlPreviewTimeout: instance.urlPreviewTimeout,
|
||||
urlPreviewMaximumContentLength: instance.urlPreviewMaximumContentLength,
|
||||
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export const paramDef = {
|
|||
description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
|
||||
},
|
||||
urlPreviewEnabled: { type: 'boolean' },
|
||||
urlPreviewAllowRedirect: { type: 'boolean' },
|
||||
urlPreviewTimeout: { type: 'integer' },
|
||||
urlPreviewMaximumContentLength: { type: 'integer' },
|
||||
urlPreviewRequireContentLength: { type: 'boolean' },
|
||||
|
|
@ -664,6 +665,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.urlPreviewEnabled = ps.urlPreviewEnabled;
|
||||
}
|
||||
|
||||
if (ps.urlPreviewAllowRedirect !== undefined) {
|
||||
set.urlPreviewAllowRedirect = ps.urlPreviewAllowRedirect;
|
||||
}
|
||||
|
||||
if (ps.urlPreviewTimeout !== undefined) {
|
||||
set.urlPreviewTimeout = ps.urlPreviewTimeout;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,11 +111,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
||||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
const notes = await query.getMany();
|
||||
if (sinceId != null && untilId == null) {
|
||||
|
|
|
|||
|
|
@ -121,12 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote' });
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
|
||||
}
|
||||
|
||||
const notes = await query
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export const meta = {
|
|||
id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a',
|
||||
httpStatusCode: 413,
|
||||
},
|
||||
|
||||
unallowedFileType: {
|
||||
message: 'Cannot upload the file because it is an unallowed file type.',
|
||||
code: 'UNALLOWED_FILE_TYPE',
|
||||
id: '4becd248-7f2c-48c4-a9f0-75edc4f9a1ea',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -123,6 +129,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate);
|
||||
if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace);
|
||||
if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded);
|
||||
if (err.id === 'bd71c601-f9b0-4808-9137-a330647ced9b') throw new ApiError(meta.errors.unallowedFileType);
|
||||
}
|
||||
throw new ApiError();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -43,14 +43,21 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['fileId'] },
|
||||
{ required: ['url'] },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['fileId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -64,21 +71,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let file: MiDriveFile | null = null;
|
||||
|
||||
if (ps.fileId) {
|
||||
file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
} else if (ps.url) {
|
||||
file = await this.driveFilesRepository.findOne({
|
||||
where: [{
|
||||
url: ps.url,
|
||||
}, {
|
||||
webpublicUrl: ps.url,
|
||||
}, {
|
||||
thumbnailUrl: ps.url,
|
||||
}],
|
||||
});
|
||||
}
|
||||
const file = await this.driveFilesRepository.findOneBy(
|
||||
'fileId' in ps
|
||||
? { id: ps.fileId }
|
||||
: [{ url: ps.url }, { webpublicUrl: ps.url }, { thumbnailUrl: ps.url }],
|
||||
);
|
||||
|
||||
if (file == null) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
|
|
|
|||
|
|
@ -15,14 +15,21 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tokenId: { type: 'string', format: 'misskey:id' },
|
||||
token: { type: 'string', nullable: true },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['tokenId'] },
|
||||
{ required: ['token'] },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
tokenId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['tokenId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['token'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -33,7 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private accessTokensRepository: AccessTokensRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.tokenId) {
|
||||
if ('tokenId' in ps) {
|
||||
const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
|
||||
|
||||
if (tokenExist) {
|
||||
|
|
|
|||
|
|
@ -70,12 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
const notes = await query.limit(ps.limit).getMany();
|
||||
|
||||
|
|
|
|||
|
|
@ -78,11 +78,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
|
|
|||
|
|
@ -243,10 +243,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
|
|
|
|||
|
|
@ -156,10 +156,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -72,11 +72,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
if (ps.visibility) {
|
||||
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
||||
|
|
|
|||
|
|
@ -72,10 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
const renotes = await query.limit(ps.limit).getMany();
|
||||
|
||||
|
|
|
|||
|
|
@ -56,10 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
const timeline = await query.limit(ps.limit).getMany();
|
||||
|
||||
|
|
|
|||
|
|
@ -28,38 +28,53 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'boolean', nullable: true, default: null },
|
||||
renote: { type: 'boolean', nullable: true, default: null },
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
poll: { type: 'boolean', nullable: true, default: null },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
|
||||
tag: { type: 'string', minLength: 1 },
|
||||
query: {
|
||||
type: 'array',
|
||||
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['tag'],
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
minItems: 1,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'array',
|
||||
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'boolean', nullable: true, default: null },
|
||||
renote: { type: 'boolean', nullable: true, default: null },
|
||||
withFiles: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Only show notes that have attached files.',
|
||||
},
|
||||
poll: { type: 'boolean', nullable: true, default: null },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['tag'] },
|
||||
{ required: ['query'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -81,18 +96,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
try {
|
||||
if (ps.tag) {
|
||||
if ('tag' in ps) {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] });
|
||||
} else {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
for (const tags of ps.query!) {
|
||||
for (const tags of ps.query) {
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
for (const tag of tags) {
|
||||
if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');
|
||||
|
|
|
|||
|
|
@ -19,7 +19,26 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
reactions: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
additionalProperties: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
reactionEmojis: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -199,10 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}));
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
|
|
|
|||
|
|
@ -184,10 +184,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}));
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
|
|
|
|||
|
|
@ -33,15 +33,22 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['pageId'] },
|
||||
{ required: ['name', 'username'] },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['pageId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'username'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -59,9 +66,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
let page: MiPage | null = null;
|
||||
|
||||
if (ps.pageId) {
|
||||
if ('pageId' in ps) {
|
||||
page = await this.pagesRepository.findOneBy({ id: ps.pageId });
|
||||
} else if (ps.name && ps.username) {
|
||||
} else {
|
||||
const author = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: ps.username.toLowerCase(),
|
||||
|
|
|
|||
|
|
@ -102,10 +102,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
|
|
|||
|
|
@ -47,23 +47,38 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
},
|
||||
required: ['username', 'host'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['userId'] },
|
||||
{ required: ['username', 'host'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -85,9 +100,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy(ps.userId != null
|
||||
const user = await this.usersRepository.findOneBy('userId' in ps
|
||||
? { id: ps.userId }
|
||||
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
|
||||
: { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
|
||||
|
||||
if (user == null) {
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
|
|
|
|||
|
|
@ -54,25 +54,39 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
},
|
||||
required: ['username', 'host'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
birthday: { ...birthdaySchema, nullable: true },
|
||||
},
|
||||
},
|
||||
|
||||
birthday: { ...birthdaySchema, nullable: true },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['userId'] },
|
||||
{ required: ['username', 'host'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -94,9 +108,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy(ps.userId != null
|
||||
const user = await this.usersRepository.findOneBy('userId' in ps
|
||||
? { id: ps.userId }
|
||||
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
|
||||
: { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
|
||||
|
||||
if (user == null) {
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
|
|
|
|||
|
|
@ -186,12 +186,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query, true);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query, true);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me, {
|
||||
excludeAuthor: true,
|
||||
excludeUserFromMute: ps.userId,
|
||||
});
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateMutedUserQueryForUsers(query, me);
|
||||
this.queryService.generateBlockQueryForUsers(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
|
||||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
userId: {
|
||||
anyOf: [
|
||||
oneOf: [
|
||||
{ type: 'string', format: 'misskey:id' },
|
||||
{
|
||||
type: 'array',
|
||||
|
|
|
|||
|
|
@ -26,17 +26,32 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
detail: { type: 'boolean', default: true },
|
||||
|
||||
username: { type: 'string', nullable: true },
|
||||
host: { type: 'string', nullable: true },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['username'] },
|
||||
{ required: ['host'] },
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['host'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
detail: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -47,8 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, (ps, me) => {
|
||||
return this.userSearchService.searchByUsernameAndHost({
|
||||
username: ps.username,
|
||||
host: ps.host,
|
||||
username: 'username' in ps ? ps.username : undefined,
|
||||
host: 'host' in ps ? ps.host : undefined,
|
||||
}, {
|
||||
limit: ps.limit,
|
||||
detail: ps.detail,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
|
||||
import { paramDef } from './show.js';
|
||||
|
||||
const VALID = true;
|
||||
const INVALID = false;
|
||||
|
||||
describe('api:users/show', () => {
|
||||
describe('validation', () => {
|
||||
const v = getValidator(paramDef);
|
||||
|
||||
test('Reject empty', () => expect(v({})).toBe(INVALID));
|
||||
test('Reject host only', () => expect(v({ host: 'misskey.test' })).toBe(INVALID));
|
||||
test('Accept userId only', () => expect(v({ userId: '1' })).toBe(VALID));
|
||||
test('Accept username and host', () => expect(v({ username: 'alice', host: 'misskey.test' })).toBe(VALID));
|
||||
});
|
||||
});
|
||||
|
|
@ -59,23 +59,44 @@ export const meta = {
|
|||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
userIds: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string', format: 'misskey:id',
|
||||
} },
|
||||
username: { type: 'string' },
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
allOf: [
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
userIds: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string', format: 'misskey:id',
|
||||
} },
|
||||
},
|
||||
required: ['userIds'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
},
|
||||
required: ['username'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['userId'] },
|
||||
{ required: ['userIds'] },
|
||||
{ required: ['username'] },
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -102,9 +123,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
let user;
|
||||
|
||||
const isModerator = await this.roleService.isModerator(me);
|
||||
ps.username = ps.username?.trim();
|
||||
if ('username' in ps) {
|
||||
ps.username = ps.username.trim();
|
||||
}
|
||||
|
||||
if (ps.userIds) {
|
||||
if ('userIds' in ps) {
|
||||
if (ps.userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -129,7 +152,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return _users.map(u => _userMap.get(u.id)!);
|
||||
} else {
|
||||
// Lookup user
|
||||
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
|
||||
if (typeof ps.host === 'string' && 'username' in ps) {
|
||||
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
}
|
||||
|
|
@ -139,7 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.failedToResolveRemoteUser);
|
||||
});
|
||||
} else {
|
||||
const q: FindOptionsWhere<MiUser> = ps.userId != null
|
||||
const q: FindOptionsWhere<MiUser> = 'userId' in ps
|
||||
? { id: ps.userId }
|
||||
: { usernameLower: ps.username!.toLowerCase(), host: IsNull() };
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
|
|||
schema.required = undefined;
|
||||
}
|
||||
|
||||
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
|
||||
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1)
|
||||
|| ['allOf', 'oneOf', 'anyOf'].some(o => (Array.isArray(schema[o]) && schema[o].length >= 0));
|
||||
|
||||
const info = {
|
||||
operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない
|
||||
|
|
|
|||
|
|
@ -38,14 +38,13 @@ export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 're
|
|||
|
||||
if (type === 'res' && schema.ref && (!schema.selfRef || includeSelfRef)) {
|
||||
const $ref = `#/components/schemas/${schema.ref}`;
|
||||
if (schema.nullable || schema.optional) {
|
||||
res.allOf = [{ $ref }];
|
||||
if (schema.nullable) {
|
||||
res.oneOf = [{ $ref }, { type: 'null' }];
|
||||
} else {
|
||||
res.$ref = $ref;
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.nullable) {
|
||||
delete res.type;
|
||||
} else if (schema.nullable) {
|
||||
if (Array.isArray(schema.type) && !schema.type.includes('null')) {
|
||||
res.type.push('null');
|
||||
} else if (typeof schema.type === 'string') {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export class UrlPreviewService {
|
|||
: undefined;
|
||||
|
||||
return summaly(url, {
|
||||
followRedirects: false,
|
||||
followRedirects: this.meta.urlPreviewAllowRedirect,
|
||||
lang: lang ?? 'ja-JP',
|
||||
agent: agent,
|
||||
userAgent: meta.urlPreviewUserAgent ?? undefined,
|
||||
|
|
@ -137,6 +137,7 @@ export class UrlPreviewService {
|
|||
const queryStr = query({
|
||||
url: url,
|
||||
lang: lang ?? 'ja-JP',
|
||||
followRedirects: this.meta.urlPreviewAllowRedirect,
|
||||
userAgent: meta.urlPreviewUserAgent ?? undefined,
|
||||
operationTimeout: meta.urlPreviewTimeout,
|
||||
contentLengthLimit: meta.urlPreviewMaximumContentLength,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import * as assert from 'assert';
|
|||
// node-fetch only supports it's own Blob yet
|
||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||
import { Blob } from 'node-fetch';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import { api, castAsError, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
|
||||
describe('Endpoints', () => {
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
|
|
@ -572,19 +572,10 @@ describe('Endpoints', () => {
|
|||
|
||||
describe('drive', () => {
|
||||
test('ドライブ情報を取得できる', async () => {
|
||||
await uploadFile(alice, {
|
||||
blob: new Blob([new Uint8Array(256)]),
|
||||
});
|
||||
await uploadFile(alice, {
|
||||
blob: new Blob([new Uint8Array(512)]),
|
||||
});
|
||||
await uploadFile(alice, {
|
||||
blob: new Blob([new Uint8Array(1024)]),
|
||||
});
|
||||
const res = await api('drive', {}, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
|
||||
expect(res.body).toHaveProperty('usage', 1792);
|
||||
expect(res.body).toHaveProperty('usage', 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -345,6 +345,44 @@ describe('Timelines', () => {
|
|||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、フォローしているユーザーによるリノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('following/create', { userId: bob.id }, alice);
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('notes/timeline', { limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、フォローしているユーザーによるリノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('following/create', { userId: bob.id }, alice);
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('notes/timeline', { limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]);
|
||||
|
||||
|
|
@ -687,6 +725,42 @@ describe('Timelines', () => {
|
|||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('notes/local-timeline', { limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('notes/local-timeline', { limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
|
|
@ -1383,6 +1457,39 @@ describe('Timelines', () => {
|
|||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => {
|
||||
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
|
||||
|
||||
await api('following/create', { userId: bob.id }, alice);
|
||||
await api('mute/create', { userId: carol.id }, alice);
|
||||
await setTimeout(1000);
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
|
||||
const bobNote = await post(bob, { renoteId: daveNote.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice);
|
||||
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
});
|
||||
|
||||
test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
||||
|
|
@ -1391,6 +1498,8 @@ describe('Timelines', () => {
|
|||
const bobNote1 = await post(bob, { text: 'hi' });
|
||||
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
|
||||
const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id });
|
||||
const bobNote4 = await post(bob, { renoteId: bobNote2.id });
|
||||
const bobNote5 = await post(bob, { renoteId: bobNote3.id });
|
||||
|
||||
await waitForPushToTl();
|
||||
|
||||
|
|
@ -1399,6 +1508,8 @@ describe('Timelines', () => {
|
|||
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote4.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote5.id), true);
|
||||
});
|
||||
|
||||
test.concurrent('自身の visibility: specified なノートが含まれる', async () => {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
|
|
@ -162,10 +162,10 @@ describe('AbuseReportNotificationService', () => {
|
|||
emailService.sendEmail.mockClear();
|
||||
webhookService.enqueueSystemWebhook.mockClear();
|
||||
|
||||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
await systemWebhooksRepository.delete({});
|
||||
await abuseReportNotificationRecipientRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
await systemWebhooksRepository.createQueryBuilder().delete().execute();
|
||||
await abuseReportNotificationRecipientRepository.createQueryBuilder().delete().execute();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
|||
|
|
@ -103,10 +103,10 @@ describe('AnnouncementService', () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await Promise.all([
|
||||
app.get(DI.metasRepository).delete({}),
|
||||
usersRepository.delete({}),
|
||||
announcementsRepository.delete({}),
|
||||
announcementReadsRepository.delete({}),
|
||||
app.get(DI.metasRepository).createQueryBuilder().delete().execute(),
|
||||
usersRepository.createQueryBuilder().delete().execute(),
|
||||
announcementsRepository.createQueryBuilder().delete().execute(),
|
||||
announcementReadsRepository.createQueryBuilder().delete().execute(),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ describe('CustomEmojiService', () => {
|
|||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await emojisRepository.delete({});
|
||||
await emojisRepository.createQueryBuilder().delete().execute();
|
||||
});
|
||||
|
||||
describe('単独', () => {
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ describe('FlashService', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
await flashsRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
await flashsRepository.createQueryBuilder().delete().execute();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
|||
|
|
@ -159,10 +159,10 @@ describe('RoleService', () => {
|
|||
clock.uninstall();
|
||||
|
||||
await Promise.all([
|
||||
app.get(DI.metasRepository).delete({}),
|
||||
usersRepository.delete({}),
|
||||
rolesRepository.delete({}),
|
||||
roleAssignmentsRepository.delete({}),
|
||||
app.get(DI.metasRepository).createQueryBuilder().delete().execute(),
|
||||
usersRepository.createQueryBuilder().delete().execute(),
|
||||
rolesRepository.createQueryBuilder().delete().execute(),
|
||||
roleAssignmentsRepository.createQueryBuilder().delete().execute(),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
|
|
|
|||
|
|
@ -101,8 +101,8 @@ describe('SystemWebhookService', () => {
|
|||
}
|
||||
|
||||
async function afterEachImpl() {
|
||||
await usersRepository.delete({});
|
||||
await systemWebhooksRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await systemWebhooksRepository.createQueryBuilder().delete().execute();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ describe('UserSearchService', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await usersRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ describe('UserWebhookService', () => {
|
|||
}
|
||||
|
||||
async function afterEachImpl() {
|
||||
await usersRepository.delete({});
|
||||
await userWebhooksRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await userWebhooksRepository.createQueryBuilder().delete().execute();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ describe('WebhookTestService', () => {
|
|||
userWebhookService.fetchWebhooks.mockClear();
|
||||
systemWebhookService.fetchSystemWebhooks.mockClear();
|
||||
|
||||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
import { MockResolver } from '../misc/mock-resolver.js';
|
||||
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
|
||||
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
|
|
@ -19,14 +25,14 @@ import { GlobalModule } from '@/GlobalModule.js';
|
|||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
|
||||
import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { genAidx } from '@/misc/id/aidx.js';
|
||||
import { MockResolver } from '../misc/mock-resolver.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const host = 'https://host1.test';
|
||||
|
||||
|
|
@ -120,7 +126,13 @@ describe('ActivityPub', () => {
|
|||
imports: [GlobalModule, CoreModule],
|
||||
})
|
||||
.overrideProvider(DownloadService).useValue({
|
||||
async downloadUrl(): Promise<{ filename: string }> {
|
||||
async downloadUrl(url: string, path: string): Promise<{ filename: string }> {
|
||||
if (url.endsWith('.png')) {
|
||||
fs.copyFileSync(
|
||||
_dirname + '/../resources/hw.png',
|
||||
path,
|
||||
);
|
||||
}
|
||||
return {
|
||||
filename: 'dummy.tmp',
|
||||
};
|
||||
|
|
@ -440,7 +452,7 @@ describe('ActivityPub', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('JSON-LD', () =>{
|
||||
describe('JSON-LD', () => {
|
||||
test('Compaction', async () => {
|
||||
const jsonLd = jsonLdService.use();
|
||||
|
||||
|
|
|
|||
|
|
@ -157,8 +157,8 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
|
||||
afterEach(async () => {
|
||||
clock.uninstall();
|
||||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
await userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
roleService.getModerators.mockReset();
|
||||
announcementService.create.mockReset();
|
||||
emailService.sendEmail.mockReset();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { ServerModule } from '@/server/ServerModule.js';
|
|||
import { ServerService } from '@/server/ServerService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
// TODO: uploadableFileTypes で許可されていないファイルが弾かれるかのテスト
|
||||
|
||||
describe('/drive/files/create', () => {
|
||||
let module: TestingModule;
|
||||
let server: FastifyInstance;
|
||||
|
|
@ -25,6 +27,8 @@ describe('/drive/files/create', () => {
|
|||
|
||||
let root: MiUser;
|
||||
let role_tinyAttachment: MiRole;
|
||||
let role_imageOnly: MiRole;
|
||||
let role_allowAllTypes: MiRole;
|
||||
|
||||
let folder: MiDriveFolder;
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ describe('/drive/files/create', () => {
|
|||
idService = module.get(IdService);
|
||||
|
||||
const usersRepository = module.get<UsersRepository>(DI.usersRepository);
|
||||
await usersRepository.delete({});
|
||||
await usersRepository.createQueryBuilder().delete().execute();
|
||||
root = await usersRepository.insert({
|
||||
id: idService.gen(),
|
||||
username: 'root',
|
||||
|
|
@ -50,7 +54,7 @@ describe('/drive/files/create', () => {
|
|||
}).then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const userProfilesRepository = module.get<UserProfilesRepository>(DI.userProfilesRepository);
|
||||
await userProfilesRepository.delete({});
|
||||
await userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
await userProfilesRepository.insert({
|
||||
userId: root.id,
|
||||
});
|
||||
|
|
@ -64,10 +68,34 @@ describe('/drive/files/create', () => {
|
|||
});
|
||||
|
||||
roleService = module.get<RoleService>(RoleService);
|
||||
role_tinyAttachment = await roleService.create({
|
||||
role_imageOnly = await roleService.create({
|
||||
name: 'test-role001',
|
||||
description: 'Test role001 description',
|
||||
target: 'manual',
|
||||
policies: {
|
||||
uploadableFileTypes: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: ['image/png'],
|
||||
},
|
||||
},
|
||||
});
|
||||
role_allowAllTypes = await roleService.create({
|
||||
name: 'test-role002',
|
||||
description: 'Test role002 description',
|
||||
target: 'manual',
|
||||
policies: {
|
||||
uploadableFileTypes: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: ['*/*'],
|
||||
},
|
||||
},
|
||||
});
|
||||
role_tinyAttachment = await roleService.create({
|
||||
name: 'test-role003',
|
||||
description: 'Test role003 description',
|
||||
target: 'manual',
|
||||
policies: {
|
||||
maxFileSizeMb: {
|
||||
useDefault: false,
|
||||
|
|
@ -82,6 +110,10 @@ describe('/drive/files/create', () => {
|
|||
beforeEach(async () => {
|
||||
await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {
|
||||
});
|
||||
await roleService.unassign(root.id, role_imageOnly.id).catch(() => {
|
||||
});
|
||||
await roleService.unassign(root.id, role_allowAllTypes.id).catch(() => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -110,7 +142,9 @@ describe('/drive/files/create', () => {
|
|||
.field('i', root.token ?? '');
|
||||
}
|
||||
|
||||
test('200 ok', async () => {
|
||||
test('200 ok (all types allowed)', async () => {
|
||||
await roleService.assign(root.id, role_allowAllTypes.id);
|
||||
|
||||
const name = randomString();
|
||||
const comment = randomString();
|
||||
const result = await postFile({
|
||||
|
|
@ -127,7 +161,24 @@ describe('/drive/files/create', () => {
|
|||
expect(result.body.folderId).toBe(folder.id);
|
||||
});
|
||||
|
||||
test('200 ok(with role)', async () => {
|
||||
test('400 when not allowed type', async () => {
|
||||
await roleService.assign(root.id, role_imageOnly.id);
|
||||
|
||||
const name = randomString();
|
||||
const comment = randomString();
|
||||
const result = await postFile({
|
||||
name: name,
|
||||
comment: comment,
|
||||
isSensitive: true,
|
||||
force: true,
|
||||
fileContent: Buffer.from('a'.repeat(10)),
|
||||
});
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.body.error.code).toBe('UNALLOWED_FILE_TYPE');
|
||||
});
|
||||
|
||||
test('200 ok (with size limited role)', async () => {
|
||||
await roleService.assign(root.id, role_allowAllTypes.id);
|
||||
await roleService.assign(root.id, role_tinyAttachment.id);
|
||||
|
||||
const name = randomString();
|
||||
|
|
@ -147,6 +198,7 @@ describe('/drive/files/create', () => {
|
|||
});
|
||||
|
||||
test('413 too large', async () => {
|
||||
await roleService.assign(root.id, role_allowAllTypes.id);
|
||||
await roleService.assign(root.id, role_tinyAttachment.id);
|
||||
|
||||
const name = randomString();
|
||||
|
|
|
|||
|
|
@ -66,9 +66,15 @@ export function getConfig(): UserConfig {
|
|||
return {
|
||||
base: '/embed_vite/',
|
||||
|
||||
// The console is shared with backend, so clearing the console will also clear the backend log.
|
||||
clearScreen: false,
|
||||
|
||||
server: {
|
||||
host,
|
||||
// The backend allows access from any addresses, so vite also allows access from any addresses.
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: host ? [host] : undefined,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
hmr: {
|
||||
// バックエンド経由での起動時、Viteは5174経由でアセットを参照していると思い込んでいるが実際は3000から配信される
|
||||
// そのため、バックエンドのWSサーバーにHMRのWSリクエストが吸収されてしまい、正しくHMRが機能しない
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@drop.prevent.stop="onDrop"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<div v-if="!store.r.readDriveTip.value" style="padding: 8px;">
|
||||
<MkInfo closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo>
|
||||
</div>
|
||||
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
|
||||
|
||||
<div :class="$style.folders">
|
||||
<XFolder
|
||||
|
|
@ -135,7 +133,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, computed, TransitionGroup } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkInfo from './MkInfo.vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import XNavFolder from '@/components/MkDrive.navFolder.vue';
|
||||
import XFolder from '@/components/MkDrive.folder.vue';
|
||||
|
|
@ -661,10 +658,6 @@ function onContextmenu(ev: MouseEvent) {
|
|||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
|
||||
function closeTip() {
|
||||
store.set('readDriveTip', true);
|
||||
}
|
||||
|
||||
useGlobalEvent('driveFileCreated', (file) => {
|
||||
if (file.folderId === (folder.value?.id ?? null)) {
|
||||
filesPaginator.prepend(file);
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ function onClosed() {
|
|||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
margin: auto;
|
||||
background: var(--MI_THEME-bg);
|
||||
container-type: size;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
controlsShowing && $style.active,
|
||||
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@mouseover="onMouseOver"
|
||||
@mouseleave="onMouseLeave"
|
||||
@mouseover.passive="onMouseOver"
|
||||
@mousemove.passive="onMouseMove"
|
||||
@mouseleave.passive="onMouseLeave"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
|
|
@ -309,7 +310,7 @@ const controlsShowing = computed(() => {
|
|||
return false;
|
||||
});
|
||||
const isFullscreen = ref(false);
|
||||
let controlStateTimer: string | number;
|
||||
let controlStateTimer: number | null = null;
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
|
|
@ -342,9 +343,26 @@ function onMouseOver() {
|
|||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
isHoverring.value = true;
|
||||
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseMove() {
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
isHoverring.value = true;
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 100);
|
||||
|
|
@ -509,6 +527,10 @@ onDeactivated(() => {
|
|||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
controlStateTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue';
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
|
@ -283,12 +283,10 @@ if (noteViewInterruptors.length > 0) {
|
|||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note);
|
||||
const $appearNote = reactive({
|
||||
reactions: appearNote.reactions,
|
||||
reactionCount: appearNote.reactionCount,
|
||||
reactionEmojis: appearNote.reactionEmojis,
|
||||
myReaction: appearNote.myReaction,
|
||||
pollChoices: appearNote.poll?.choices,
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
mock: props.mock,
|
||||
});
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
|
|
@ -410,17 +408,6 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
|
|||
});
|
||||
});
|
||||
|
||||
let subscribeManuallyToNoteCapture: () => void = () => { };
|
||||
|
||||
if (!props.mock) {
|
||||
const { subscribe } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
$note: $appearNote,
|
||||
});
|
||||
subscribeManuallyToNoteCapture = subscribe;
|
||||
}
|
||||
|
||||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue';
|
||||
import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
|
@ -304,12 +304,9 @@ if (noteViewInterruptors.length > 0) {
|
|||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note);
|
||||
const $appearNote = reactive({
|
||||
reactions: appearNote.reactions,
|
||||
reactionCount: appearNote.reactionCount,
|
||||
reactionEmojis: appearNote.reactionEmojis,
|
||||
myReaction: appearNote.myReaction,
|
||||
pollChoices: appearNote.poll?.choices,
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
});
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
|
|
@ -397,12 +394,6 @@ const reactionsPagination = computed(() => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const { subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
$note: $appearNote,
|
||||
});
|
||||
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
|
||||
|
||||
<div class="_gaps_s _spacer">
|
||||
<MkTip k="uploader">
|
||||
{{ i18n.ts._uploader.tip }}
|
||||
</MkTip>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<div
|
||||
v-for="ctx in items"
|
||||
|
|
@ -69,7 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSelect>
|
||||
|
||||
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||
<div>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div>
|
||||
|
||||
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
||||
<!-- https://github.com/misskey-dev/misskey/issues/16091 -->
|
||||
<!--<div>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -250,6 +257,23 @@ async function done() {
|
|||
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-cursor-text',
|
||||
text: i18n.ts.rename,
|
||||
action: async () => {
|
||||
const { result, canceled } = await os.inputText({
|
||||
type: 'text',
|
||||
title: i18n.ts.rename,
|
||||
placeholder: item.name,
|
||||
default: item.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (result.trim() === '') return;
|
||||
|
||||
item.name = result;
|
||||
},
|
||||
});
|
||||
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-crop',
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const props = defineProps<{
|
|||
.img {
|
||||
vertical-align: bottom;
|
||||
height: 128px;
|
||||
aspect-ratio: 1;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="!store.r.tips.value[props.k]" :class="[$style.root, { [$style.warn]: warn }]" class="_selectable _gaps_s">
|
||||
<div style="font-weight: bold;"><i class="ti ti-bulb"></i> {{ i18n.ts.tip }}:</div>
|
||||
<div><slot></slot></div>
|
||||
<MkButton primary rounded small @click="closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { store } from '@/store.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
k: keyof (typeof store['s']['tips']);
|
||||
warn?: boolean;
|
||||
}>(), {
|
||||
warn: false,
|
||||
});
|
||||
|
||||
function closeTip() {
|
||||
store.set('tips', {
|
||||
...store.r.tips.value,
|
||||
[props.k]: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 12px 14px;
|
||||
font-size: 90%;
|
||||
background: var(--MI_THEME-infoBg);
|
||||
color: var(--MI_THEME-infoFg);
|
||||
border-radius: var(--MI-radius);
|
||||
|
||||
&.warn {
|
||||
background: var(--MI_THEME-infoWarnBg);
|
||||
color: var(--MI_THEME-infoWarnFg);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -26,6 +26,7 @@ import MkStickyContainer from './global/MkStickyContainer.vue';
|
|||
import MkLazy from './global/MkLazy.vue';
|
||||
import MkResult from './global/MkResult.vue';
|
||||
import MkSystemIcon from './global/MkSystemIcon.vue';
|
||||
import MkTip from './global/MkTip.vue';
|
||||
import PageWithHeader from './global/PageWithHeader.vue';
|
||||
import PageWithAnimBg from './global/PageWithAnimBg.vue';
|
||||
import SearchMarker from './global/SearchMarker.vue';
|
||||
|
|
@ -65,6 +66,7 @@ export const components = {
|
|||
MkLazy: MkLazy,
|
||||
MkResult: MkResult,
|
||||
MkSystemIcon: MkSystemIcon,
|
||||
MkTip: MkTip,
|
||||
PageWithHeader: PageWithHeader,
|
||||
PageWithAnimBg: PageWithAnimBg,
|
||||
SearchMarker: SearchMarker,
|
||||
|
|
@ -98,6 +100,7 @@ declare module '@vue/runtime-core' {
|
|||
MkLazy: typeof MkLazy;
|
||||
MkResult: typeof MkResult;
|
||||
MkSystemIcon: typeof MkSystemIcon;
|
||||
MkTip: typeof MkTip;
|
||||
PageWithHeader: typeof PageWithHeader;
|
||||
PageWithAnimBg: typeof PageWithAnimBg;
|
||||
SearchMarker: typeof SearchMarker;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { onUnmounted } from 'vue';
|
||||
import { onUnmounted, reactive } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import type { Reactive, Ref } from 'vue';
|
||||
import type { Reactive } from 'vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { store } from '@/store.js';
|
||||
|
|
@ -179,60 +179,83 @@ function realtimeSubscribe(props: {
|
|||
});
|
||||
}
|
||||
|
||||
type ReactiveNoteData = Reactive<{
|
||||
export type ReactiveNoteData = {
|
||||
reactions: Misskey.entities.Note['reactions'];
|
||||
reactionCount: Misskey.entities.Note['reactionCount'];
|
||||
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
|
||||
myReaction: Misskey.entities.Note['myReaction'];
|
||||
pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
|
||||
}>;
|
||||
};
|
||||
|
||||
const noReaction = Symbol();
|
||||
|
||||
export function useNoteCapture(props: {
|
||||
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
|
||||
note: Misskey.entities.Note;
|
||||
parentNote: Misskey.entities.Note | null;
|
||||
$note: ReactiveNoteData;
|
||||
mock?: boolean;
|
||||
}): {
|
||||
$note: Reactive<ReactiveNoteData>;
|
||||
subscribe: () => void;
|
||||
} {
|
||||
const { note, parentNote, $note } = props;
|
||||
const { note, parentNote, mock } = props;
|
||||
|
||||
const $note = reactive<ReactiveNoteData>({
|
||||
reactions: Object.entries(note.reactions).reduce((acc, [name, count]) => {
|
||||
// Normalize reactions
|
||||
const normalizedName = name.replace(/^:(\w+):$/, ':$1@.:');
|
||||
if (acc[normalizedName] == null) {
|
||||
acc[normalizedName] = count;
|
||||
} else {
|
||||
acc[normalizedName] += count;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Misskey.entities.Note['reactions']),
|
||||
reactionCount: note.reactionCount,
|
||||
reactionEmojis: note.reactionEmojis,
|
||||
myReaction: note.myReaction,
|
||||
pollChoices: note.poll?.choices ?? [],
|
||||
});
|
||||
|
||||
noteEvents.on(`reacted:${note.id}`, onReacted);
|
||||
noteEvents.on(`unreacted:${note.id}`, onUnreacted);
|
||||
noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
|
||||
|
||||
let latestReactedKey: string | null = null;
|
||||
let latestUnreactedKey: string | null = null;
|
||||
// 操作がダブっていないかどうかを簡易的に記録するためのMap
|
||||
const reactionUserMap = new Map<Misskey.entities.User['id'], string | typeof noReaction>();
|
||||
let latestPollVotedKey: string | null = null;
|
||||
|
||||
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
|
||||
const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
|
||||
if (newReactedKey === latestReactedKey) return;
|
||||
latestReactedKey = newReactedKey;
|
||||
const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
|
||||
|
||||
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return;
|
||||
reactionUserMap.set(ctx.userId, normalizedName);
|
||||
|
||||
if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
|
||||
$note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
|
||||
}
|
||||
|
||||
const currentCount = $note.reactions[ctx.reaction] || 0;
|
||||
const currentCount = $note.reactions[normalizedName] || 0;
|
||||
|
||||
$note.reactions[ctx.reaction] = currentCount + 1;
|
||||
$note.reactions[normalizedName] = currentCount + 1;
|
||||
$note.reactionCount += 1;
|
||||
|
||||
if ($i && (ctx.userId === $i.id)) {
|
||||
$note.myReaction = ctx.reaction;
|
||||
$note.myReaction = normalizedName;
|
||||
}
|
||||
}
|
||||
|
||||
function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
|
||||
const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`;
|
||||
if (newUnreactedKey === latestUnreactedKey) return;
|
||||
latestUnreactedKey = newUnreactedKey;
|
||||
const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
|
||||
|
||||
const currentCount = $note.reactions[ctx.reaction] || 0;
|
||||
// 確実に一度リアクションされて取り消されている場合のみ処理をとめる(APIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため)
|
||||
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return;
|
||||
reactionUserMap.set(ctx.userId, noReaction);
|
||||
|
||||
$note.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
|
||||
const currentCount = $note.reactions[normalizedName] || 0;
|
||||
|
||||
$note.reactions[normalizedName] = Math.max(0, currentCount - 1);
|
||||
$note.reactionCount = Math.max(0, $note.reactionCount - 1);
|
||||
if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction];
|
||||
if ($note.reactions[normalizedName] === 0) delete $note.reactions[normalizedName];
|
||||
|
||||
if ($i && (ctx.userId === $i.id)) {
|
||||
$note.myReaction = null;
|
||||
|
|
@ -257,10 +280,20 @@ export function useNoteCapture(props: {
|
|||
}
|
||||
|
||||
function subscribe() {
|
||||
if (mock) {
|
||||
// モックモードでは購読しない
|
||||
return;
|
||||
}
|
||||
|
||||
if ($i && store.s.realtimeMode) {
|
||||
realtimeSubscribe(props);
|
||||
realtimeSubscribe({
|
||||
note,
|
||||
});
|
||||
} else {
|
||||
pollingSubscribe(props);
|
||||
pollingSubscribe({
|
||||
note,
|
||||
$note,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,6 +310,7 @@ export function useNoteCapture(props: {
|
|||
if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
// リノートで表示されているノートでもないし、投稿からある程度経過しているので自動で購読しない
|
||||
return {
|
||||
$note,
|
||||
subscribe: () => {
|
||||
subscribe();
|
||||
},
|
||||
|
|
@ -286,6 +320,7 @@ export function useNoteCapture(props: {
|
|||
if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
// リノートで表示されているノートだが、リノートされてからある程度経過しているので自動で購読しない
|
||||
return {
|
||||
$note,
|
||||
subscribe: () => {
|
||||
subscribe();
|
||||
},
|
||||
|
|
@ -296,6 +331,7 @@ export function useNoteCapture(props: {
|
|||
subscribe();
|
||||
|
||||
return {
|
||||
$note,
|
||||
subscribe: () => {
|
||||
// すでに購読しているので何もしない
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkInfo v-if="!store.r.abusesTutorial.value" closable @close="closeTutorial()">
|
||||
<MkTip k="abuses">
|
||||
{{ i18n.ts._abuseUserReport.resolveTutorial }}
|
||||
</MkInfo>
|
||||
</MkTip>
|
||||
|
||||
<div :class="$style.inputs" class="_gaps">
|
||||
<MkSelect v-model="state" style="margin: 0; flex: 1;">
|
||||
|
|
@ -65,7 +65,6 @@ import XAbuseReport from '@/components/MkAbuseReport.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { store } from '@/store.js';
|
||||
|
||||
const reports = useTemplateRef('reports');
|
||||
|
|
@ -90,10 +89,6 @@ function resolved(reportId) {
|
|||
reports.value?.paginator.removeItem(reportId);
|
||||
}
|
||||
|
||||
function closeTutorial() {
|
||||
store.set('abusesTutorial', false);
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
|||
|
|
@ -418,7 +418,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkTextarea :modelValue="role.policies.uploadableFileTypes.value.join('\n')" :disabled="role.policies.uploadableFileTypes.useDefault" :readonly="readonly" @update:modelValue="role.policies.uploadableFileTypes.value = $event.split('\n')">
|
||||
<template #caption>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</template>
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div>
|
||||
</template>
|
||||
</MkTextarea>
|
||||
<MkRange v-model="role.policies.uploadableFileTypes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template>
|
||||
<template #suffix>...</template>
|
||||
<MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')">
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div>
|
||||
</template>
|
||||
</MkTextarea>
|
||||
</MkFolder>
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSwitch>
|
||||
|
||||
<template v-if="urlPreviewForm.state.urlPreviewEnabled">
|
||||
<MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.allowRedirect }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength">
|
||||
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
|
||||
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
|
||||
|
|
@ -288,7 +293,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
|
|
@ -301,7 +306,6 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import { useForm } from '@/composables/use-form.js';
|
||||
import MkFormFooter from '@/components/MkFormFooter.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
|
|
@ -370,6 +374,7 @@ const adForm = useForm({
|
|||
|
||||
const urlPreviewForm = useForm({
|
||||
urlPreviewEnabled: meta.urlPreviewEnabled,
|
||||
urlPreviewAllowRedirect: meta.urlPreviewAllowRedirect,
|
||||
urlPreviewTimeout: meta.urlPreviewTimeout,
|
||||
urlPreviewMaximumContentLength: meta.urlPreviewMaximumContentLength,
|
||||
urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength,
|
||||
|
|
@ -378,6 +383,7 @@ const urlPreviewForm = useForm({
|
|||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
urlPreviewEnabled: state.urlPreviewEnabled,
|
||||
urlPreviewAllowRedirect: state.urlPreviewAllowRedirect,
|
||||
urlPreviewTimeout: state.urlPreviewTimeout,
|
||||
urlPreviewMaximumContentLength: state.urlPreviewMaximumContentLength,
|
||||
urlPreviewRequireContentLength: state.urlPreviewRequireContentLength,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div class="_spacer _gaps" style="--MI_SPACER-w: 700px;">
|
||||
<MkTip k="clips">
|
||||
{{ i18n.ts._clip.tip }}
|
||||
</MkTip>
|
||||
<div v-if="tab === 'my'" class="_gaps">
|
||||
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div class="_gaps">
|
||||
<MkTip k="userLists">
|
||||
{{ i18n.ts._userLists.tip }}
|
||||
</MkTip>
|
||||
|
||||
<MkResult v-if="items.length === 0" type="empty"/>
|
||||
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<hr>
|
||||
|
||||
<MkButton @click="resetAllTips"><i class="ti ti-bulb"></i> {{ i18n.ts.redisplayAllTips }}</MkButton>
|
||||
<MkButton @click="hideAllTips"><i class="ti ti-bulb-off"></i> {{ i18n.ts.hideAllTips }}</MkButton>
|
||||
|
||||
<hr>
|
||||
|
||||
<FormSlot>
|
||||
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
||||
|
|
@ -152,6 +157,7 @@ import { prefer } from '@/preferences.js';
|
|||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { signout } from '@/signout.js';
|
||||
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||
import { store, TIPS } from '@/store.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
|
@ -194,6 +200,20 @@ function migrate() {
|
|||
migrateOldSettings();
|
||||
}
|
||||
|
||||
function resetAllTips() {
|
||||
store.set('tips', {});
|
||||
os.success();
|
||||
}
|
||||
|
||||
function hideAllTips() {
|
||||
const v = {};
|
||||
for (const k of TIPS) {
|
||||
v[k] = true;
|
||||
}
|
||||
store.set('tips', v);
|
||||
os.success();
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||
<MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()">
|
||||
<MkTip v-if="isBasicTimeline(src)" :k="`tl.${src}`" style="margin-bottom: var(--MI-margin);">
|
||||
{{ i18n.ts._timelineDescription[src] }}
|
||||
</MkInfo>
|
||||
</MkTip>
|
||||
<MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="_panel" fixed style="margin-bottom: var(--MI-margin);"/>
|
||||
<MkStreamingNotesTimeline
|
||||
ref="tlComponent"
|
||||
|
|
@ -32,7 +32,6 @@ import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
|||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { BasicTimelineType } from '@/timelines.js';
|
||||
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { store } from '@/store.js';
|
||||
|
|
@ -204,13 +203,6 @@ function focus(): void {
|
|||
tlComponent.value.focus();
|
||||
}
|
||||
|
||||
function closeTutorial(): void {
|
||||
if (!isBasicTimeline(src.value)) return;
|
||||
const before = store.s.timelineTutorials;
|
||||
before[src.value] = true;
|
||||
store.set('timelineTutorials', before);
|
||||
}
|
||||
|
||||
function switchTlIfNeeded() {
|
||||
if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) {
|
||||
src.value = availableBasicTimelines()[0];
|
||||
|
|
|
|||
|
|
@ -194,10 +194,10 @@ export const PREF_DEF = {
|
|||
default: 'auto' as 'auto' | 'popup' | 'drawer',
|
||||
},
|
||||
useBlurEffectForModal: {
|
||||
default: DEFAULT_DEVICE_KIND === 'desktop',
|
||||
default: true,
|
||||
},
|
||||
useBlurEffect: {
|
||||
default: DEFAULT_DEVICE_KIND === 'desktop',
|
||||
default: true,
|
||||
},
|
||||
useStickyIcons: {
|
||||
default: true,
|
||||
|
|
@ -351,7 +351,7 @@ export const PREF_DEF = {
|
|||
},
|
||||
|
||||
'sound.masterVolume': {
|
||||
default: 0.3,
|
||||
default: 0.5,
|
||||
},
|
||||
'sound.notUseSound': {
|
||||
default: false,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ import { miLocalStorage } from '@/local-storage.js';
|
|||
import { Pizzax } from '@/lib/pizzax.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
|
||||
export const TIPS = [
|
||||
'drive',
|
||||
'uploader',
|
||||
'clips',
|
||||
'userLists',
|
||||
'tl.home',
|
||||
'tl.local',
|
||||
'tl.social',
|
||||
'tl.global',
|
||||
'abuses',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 「状態」を管理するストア(not「設定」)
|
||||
*/
|
||||
|
|
@ -22,22 +34,9 @@ export const store = markRaw(new Pizzax('base', {
|
|||
where: 'account',
|
||||
default: 0,
|
||||
},
|
||||
timelineTutorials: {
|
||||
where: 'account',
|
||||
default: {
|
||||
home: false,
|
||||
local: false,
|
||||
social: false,
|
||||
global: false,
|
||||
},
|
||||
},
|
||||
abusesTutorial: {
|
||||
where: 'account',
|
||||
default: false,
|
||||
},
|
||||
readDriveTip: {
|
||||
where: 'account',
|
||||
default: false,
|
||||
tips: {
|
||||
where: 'device',
|
||||
default: {} as Partial<Record<typeof TIPS[number], boolean>>, // true = 既読
|
||||
},
|
||||
memo: {
|
||||
where: 'account',
|
||||
|
|
|
|||
|
|
@ -101,27 +101,15 @@ html._themeChanging_ {
|
|||
}
|
||||
|
||||
html::view-transition-new(theme-changing) {
|
||||
z-index: 4000001;
|
||||
animation: themeChangingNew 0.5s ease;
|
||||
animation-fill-mode: forwards;
|
||||
z-index: 4000000;
|
||||
}
|
||||
|
||||
html::view-transition-old(theme-changing) {
|
||||
z-index: 4000000;
|
||||
z-index: 4000001;
|
||||
animation: themeChangingOld 0.5s ease;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes themeChangingNew {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes themeChangingOld {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { globalEvents } from '@/events.js';
|
|||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { deepEqual } from '@/utility/deep-equal.js';
|
||||
|
||||
export type Theme = {
|
||||
id: string;
|
||||
|
|
@ -127,6 +128,7 @@ function applyThemeInternal(theme: Theme, persist: boolean) {
|
|||
}
|
||||
|
||||
let timeout: number | null = null;
|
||||
let currentTheme: Theme | null = null;
|
||||
|
||||
export function applyTheme(theme: Theme, persist = true) {
|
||||
if (timeout) {
|
||||
|
|
@ -134,6 +136,9 @@ export function applyTheme(theme: Theme, persist = true) {
|
|||
timeout = null;
|
||||
}
|
||||
|
||||
if (deepEqual(currentTheme, theme)) return;
|
||||
currentTheme = theme;
|
||||
|
||||
if (window.document.startViewTransition != null && prefer.s.animation) {
|
||||
window.document.documentElement.classList.add('_themeChanging_');
|
||||
window.document.startViewTransition(async () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue