diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-05-12 12:41:53 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-05-12 12:41:53 +0900 |
| commit | de6348e8a00a72c2410a8907d59a9bd29142a200 (patch) | |
| tree | f163329a67ac5c6dc1c33845b4fc131a8c59a39b /packages | |
| parent | Merge pull request #10814 from misskey-dev/develop (diff) | |
| parent | fix(frontend): fix retention rate heatmap rendering (diff) | |
| download | misskey-de6348e8a00a72c2410a8907d59a9bd29142a200.tar.gz misskey-de6348e8a00a72c2410a8907d59a9bd29142a200.tar.bz2 misskey-de6348e8a00a72c2410a8907d59a9bd29142a200.zip | |
Merge pull request #10833 from misskey-dev/develop
* refactor(frontend): use css modules
* feat: 投稿したコンテンツのAIによる学習を軽減するオプションを追加
Resolve #10819
* enhance(backend): publicReactionsをデフォルトtrueに
* 念のためnoimageaiもつける
* add X-Robots-Tag: noai
* Update ja-JP.yml
* fix(frontend): ブラーエフェクトを有効にしている状態で高負荷になる問題を修正
* enhance(backend): graceful shutdown for job queue and refactor
* fix(backend): テスト時は一部のサービスを停止
* fix test
* New Crowdin updates (#10815)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Korean)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (Chinese Traditional)
* refactor
* bump
* refactor(frontend): use css module
* refactor(frontend): use css module
* delete unused component
* センシティブワードを正規表現、CWにも適用するように (#10688)
* cwにセンシティブが効いてない
* CWが無いときにTextを見るように
* 比較演算子間違えた
* とりあえずチェック
* 正規表現対応
* /test/giにも対応
* matchでしなくてもいいのでは感
* レビュー修正
* Update packages/backend/src/core/NoteCreateService.ts
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Update packages/backend/src/core/NoteCreateService.ts
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* 修正
* wipかも
* wordsでスペース区切りのものできたかも
* なんか動いたかも
* test作成
* 文言の修正
* 修正
* note参照
---------
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Update CHANGELOG.md
* New Crowdin updates (#10823)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (German)
* ci: fix typo
* fix(frontend): より明確な説明にしたのとtypo修正
* fix typo
* fix(frontend): カラーバーがリプライには表示されないのを修正
* fix(frontend): チャンネル内の検索ボックスが挙動不審な問題を修正
Fix #10793
* enhance(backend): ノートのハッシュタグもMeilisearchに突っ込むように
今後ハッシュタグ検索とか実装するときのため
* feat(frontend): ユーザー指定ノート検索
* fix(frontend): fix retention chart rendering
* Update about-misskey.vue
* meta: Remove @rinsuki from reviewer-lottery (#10830)
* New Crowdin updates (#10824)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (French)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Japanese, Kansai)
* New translations ja-JP.yml (Chinese Traditional)
* New translations ja-JP.yml (Spanish)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Italian)
* New translations ja-JP.yml (Korean)
* New translations ja-JP.yml (Norwegian)
* New translations ja-JP.yml (Russian)
* New translations ja-JP.yml (Chinese Simplified)
* New translations ja-JP.yml (Indonesian)
* New translations ja-JP.yml (Thai)
* enhance(frontend): アカウント初期設定ウィザードにプライバシー設定を追加
* Update CHANGELOG.md
* fix(backend): ひとつのMeilisearchサーバーを複数のMisskeyサーバーで使えない問題を修正
* fix MkUserSetupDialog.Privacy.vue
* ci: skip non-Japanese locale on TurboSnap
* ci: notify on changes for push events
* ci: fix missing branch
* Update basic.cy.js
* [ci skip] New Crowdin updates (#10834)
* New translations ja-JP.yml (English)
* New translations ja-JP.yml (Arabic)
* New translations ja-JP.yml (German)
* New translations ja-JP.yml (Chinese Simplified)
* New translations ja-JP.yml (Japanese, Kansai)
* New translations ja-JP.yml (Arabic)
* :art:
* :art:
* enhance(frontend): add retention line chart
* update deps
* refactor
* fix(frontend): Pageにおいて画像ブロックに画像を設定できない問題を修正
Fix #10837
---------
Co-authored-by: nenohi <kimutipartylove@gmail.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: rinsuki <428rinsuki+git@gmail.com>
Diffstat (limited to 'packages')
59 files changed, 1155 insertions, 846 deletions
diff --git a/packages/backend/migration/1683682889948-prevent-ai-larning.js b/packages/backend/migration/1683682889948-prevent-ai-larning.js new file mode 100644 index 0000000000..9d1a19c10b --- /dev/null +++ b/packages/backend/migration/1683682889948-prevent-ai-larning.js @@ -0,0 +1,11 @@ +export class PreventAiLarning1683682889948 { + name = 'PreventAiLarning1683682889948' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "preventAiLarning" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "preventAiLarning"`); + } +} diff --git a/packages/backend/migration/1683683083083-public-reactions-default-true.js b/packages/backend/migration/1683683083083-public-reactions-default-true.js new file mode 100644 index 0000000000..195ea02a5e --- /dev/null +++ b/packages/backend/migration/1683683083083-public-reactions-default-true.js @@ -0,0 +1,11 @@ +export class PublicReactionsDefaultTrue1683683083083 { + name = 'PublicReactionsDefaultTrue1683683083083' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "publicReactions" SET DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "publicReactions" SET DEFAULT false`); + } +} diff --git a/packages/backend/migration/1683789676867-fix-typo.js b/packages/backend/migration/1683789676867-fix-typo.js new file mode 100644 index 0000000000..c0dbbf0050 --- /dev/null +++ b/packages/backend/migration/1683789676867-fix-typo.js @@ -0,0 +1,11 @@ +export class FixTypo1683789676867 { + name = 'FixTypo1683789676867' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" RENAME COLUMN "preventAiLarning" TO "preventAiLearning"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" RENAME COLUMN "preventAiLearning" TO "preventAiLarning"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index e0ece2bfe5..4bab4a7341 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -58,7 +58,7 @@ "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.2.1", - "@fastify/http-proxy": "9.0.0", + "@fastify/http-proxy": "9.1.0", "@fastify/multipart": "7.6.0", "@fastify/static": "6.10.1", "@fastify/view": "7.4.1", @@ -89,11 +89,11 @@ "escape-regexp": "0.0.1", "fastify": "4.17.0", "feed": "4.2.2", - "file-type": "18.3.0", + "file-type": "18.4.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "12.6.0", - "happy-dom": "9.10.2", + "happy-dom": "9.16.0", "hpagent": "1.2.0", "ioredis": "5.3.2", "ip-cidr": "3.1.0", @@ -110,11 +110,11 @@ "ms": "3.0.0-canary.1", "nested-property": "4.0.0", "node-fetch": "3.3.1", - "nodemailer": "6.9.1", + "nodemailer": "6.9.2", "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", - "otpauth": "9.1.1", + "otpauth": "9.1.2", "parse5": "7.1.2", "pg": "8.10.0", "private-ip": "3.0.0", @@ -149,7 +149,7 @@ "tsc-alias": "1.8.6", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.15", + "typeorm": "0.3.16", "typescript": "5.0.4", "ulid": "2.3.0", "unzipper": "0.10.11", @@ -178,7 +178,7 @@ "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "18.16.3", + "@types/node": "20.1.3", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -191,7 +191,7 @@ "@types/redis": "4.0.11", "@types/rename": "1.0.4", "@types/sanitize-html": "2.9.0", - "@types/semver": "7.3.13", + "@types/semver": "7.5.0", "@types/sharp": "0.32.0", "@types/sinonjs__fake-timers": "8.1.2", "@types/tinycolor2": "1.4.3", @@ -202,11 +202,11 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.2", - "@typescript-eslint/parser": "5.59.2", - "aws-sdk-client-mock": "^2.1.1", + "@typescript-eslint/eslint-plugin": "5.59.5", + "@typescript-eslint/parser": "5.59.5", + "aws-sdk-client-mock": "2.1.1", "cross-env": "7.0.3", - "eslint": "8.39.0", + "eslint": "8.40.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", "jest": "29.5.0", diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index 45ded5495c..3995545d7f 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -18,10 +18,12 @@ export async function server() { const serverService = app.get(ServerService); await serverService.launch(); - app.get(ChartManagementService).start(); - app.get(JanitorService).start(); - app.get(QueueStatsService).start(); - app.get(ServerStatsService).start(); + if (process.env.NODE_ENV !== 'test') { + app.get(ChartManagementService).start(); + app.get(JanitorService).start(); + app.get(QueueStatsService).start(); + app.get(ServerStatsService).start(); + } return app; } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index b41fb603bb..c6e1075389 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -62,6 +62,7 @@ export type Source = { port: string; apiKey: string; ssl?: boolean; + index: string; }; proxy?: string; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 364976e4a7..977c9052c0 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -3,6 +3,7 @@ import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import RE2 from 're2'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; @@ -238,7 +239,8 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.localOnly = true; if (data.visibility === 'public' && data.channel == null) { - if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { + const sensitiveWords = (await this.metaService.fetch()).sensitiveWords; + if (this.isSensitive(data, sensitiveWords)) { data.visibility = 'home'; } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { data.visibility = 'home'; @@ -670,6 +672,31 @@ export class NoteCreateService implements OnApplicationShutdown { // Register to search database this.index(note); } + + @bindThis + private isSensitive(note: Option, sensitiveWord: string[]): boolean { + if (sensitiveWord.length > 0) { + const text = note.cw ?? note.text ?? ''; + if (text === '') return false; + const matched = sensitiveWord.some(filter => { + // represents RegExp + const regexp = filter.match(/^\/(.+)\/(.*)$/); + // This should never happen due to input sanitisation. + if (!regexp) { + const words = filter.split(' '); + return words.every(keyword => text.includes(keyword)); + } + try { + return new RE2(regexp[1], regexp[2]).test(text); + } catch (err) { + // This should never happen due to input sanitisation. + return false; + } + }); + if (matched) return true; + } + return false; + } @bindThis private incRenoteCount(renote: Note) { diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index d4905a5f88..1d73947776 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -1,4 +1,5 @@ -import { Module } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import Bull from 'bull'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -41,9 +42,9 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>; export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; export type DeliverQueue = Bull.Queue<DeliverJobData>; export type InboxQueue = Bull.Queue<InboxJobData>; -export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>; +export type DbQueue = Bull.Queue; export type RelationshipQueue = Bull.Queue<RelationshipJobData>; -export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>; +export type ObjectStorageQueue = Bull.Queue; export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>; const $system: Provider = { @@ -118,4 +119,36 @@ const $webhookDeliver: Provider = { $webhookDeliver, ], }) -export class QueueModule {} +export class QueueModule implements OnApplicationShutdown { + constructor( + @Inject('queue:system') public systemQueue: SystemQueue, + @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + @Inject('queue:inbox') public inboxQueue: InboxQueue, + @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, + @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, + @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + ) {} + + async onApplicationShutdown(signal: string): Promise<void> { + if (process.env.NODE_ENV === 'test') { + // XXX: + // Shutting down the existing connections causes errors on Jest as + // Misskey has asynchronous postgres/redis connections that are not + // awaited. + // Let's wait for some random time for them to finish. + await setTimeout(5000); + } + await Promise.all([ + this.systemQueue.close(), + this.endedPollNotificationQueue.close(), + this.deliverQueue.close(), + this.inboxQueue.close(), + this.dbQueue.close(), + this.relationshipQueue.close(), + this.objectStorageQueue.close(), + this.webhookDeliverQueue.close(), + ]); + } +} diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index e68fde088d..9502afcc9b 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -68,7 +68,7 @@ export class SearchService { private idService: IdService, ) { if (meilisearch) { - this.meilisearchNoteIndex = meilisearch.index('notes'); + this.meilisearchNoteIndex = meilisearch.index(`${config.meilisearch!.index}---notes`); this.meilisearchNoteIndex.updateSettings({ searchableAttributes: [ 'text', @@ -82,6 +82,7 @@ export class SearchService { 'userId', 'userHost', 'channelId', + 'tags', ], typoTolerance: { enabled: false, @@ -107,6 +108,7 @@ export class SearchService { channelId: note.channelId, cw: note.cw, text: note.text, + tags: note.tags, }], { primaryKey: 'id', }); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 453c1473dd..7f61e1d6f3 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -445,6 +445,7 @@ export class UserEntityService implements OnModuleInit { carefulBot: profile!.carefulBot, autoAcceptFollowed: profile!.autoAcceptFollowed, noCrawle: profile!.noCrawle, + preventAiLearning: profile!.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, hideOnlineStatus: user.hideOnlineStatus, diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 60c1c55de5..236ee8f988 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -76,7 +76,7 @@ export class UserProfile { public emailNotificationTypes: string[]; @Column('boolean', { - default: false, + default: true, }) public publicReactions: boolean; @@ -148,6 +148,11 @@ export class UserProfile { public noCrawle: boolean; @Column('boolean', { + default: true, + }) + public preventAiLearning: boolean; + + @Column('boolean', { default: false, }) public alwaysMarkNsfw: boolean; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 529c1303d1..f9a20ac398 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -302,7 +302,11 @@ export const packedMeDetailedOnlySchema = { }, noCrawle: { type: 'boolean', - nullable: true, optional: false, + nullable: false, optional: false, + }, + preventAiLearning: { + type: 'boolean', + nullable: false, optional: false, }, isExplorable: { type: 'boolean', diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts deleted file mode 100644 index df8ac3a301..0000000000 --- a/packages/backend/src/queue/DbQueueProcessorsService.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; -import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; -import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; -import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; -import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; -import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; -import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; -import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; -import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; -import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; -import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; -import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; -import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; -import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; -import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; -import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; -import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; -import type Bull from 'bull'; - -@Injectable() -export class DbQueueProcessorsService { - constructor( - @Inject(DI.config) - private config: Config, - - private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, - private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, - private exportNotesProcessorService: ExportNotesProcessorService, - private exportFavoritesProcessorService: ExportFavoritesProcessorService, - private exportFollowingProcessorService: ExportFollowingProcessorService, - private exportMutingProcessorService: ExportMutingProcessorService, - private exportBlockingProcessorService: ExportBlockingProcessorService, - private exportUserListsProcessorService: ExportUserListsProcessorService, - private exportAntennasProcessorService: ExportAntennasProcessorService, - private importFollowingProcessorService: ImportFollowingProcessorService, - private importMutingProcessorService: ImportMutingProcessorService, - private importBlockingProcessorService: ImportBlockingProcessorService, - private importUserListsProcessorService: ImportUserListsProcessorService, - private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, - private importAntennasProcessorService: ImportAntennasProcessorService, - private deleteAccountProcessorService: DeleteAccountProcessorService, - ) { - } - - @bindThis - public start(q: Bull.Queue): void { - q.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done)); - q.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done)); - q.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done)); - q.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done)); - q.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done)); - q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done)); - q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); - q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); - q.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done)); - q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); - q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); - q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); - q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); - q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); - q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); - q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); - q.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done)); - q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); - } -} diff --git a/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts b/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts deleted file mode 100644 index 865e47c3f8..0000000000 --- a/packages/backend/src/queue/ObjectStorageQueueProcessorsService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; -import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; -import type Bull from 'bull'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class ObjectStorageQueueProcessorsService { - constructor( - @Inject(DI.config) - private config: Config, - - private deleteFileProcessorService: DeleteFileProcessorService, - private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, - ) { - } - - @bindThis - public start(q: Bull.Queue): void { - q.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job)); - q.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done)); - } -} diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 3d4cc77321..e1c6b93d9b 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -3,14 +3,10 @@ import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; -import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; -import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; -import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; -import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; @@ -68,10 +64,6 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeleteFileProcessorService, CleanRemoteFilesProcessorService, RelationshipProcessorService, - SystemQueueProcessorsService, - ObjectStorageQueueProcessorsService, - DbQueueProcessorsService, - RelationshipQueueProcessorsService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 706110f6fc..dc025f9889 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -5,15 +5,36 @@ import type Logger from '@/logger.js'; import { QueueService } from '@/core/QueueService.js'; import { bindThis } from '@/decorators.js'; import { getJobInfo } from './get-job-info.js'; -import { SystemQueueProcessorsService } from './SystemQueueProcessorsService.js'; -import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; -import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; +import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; +import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; +import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; +import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; +import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; +import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; +import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; +import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js'; +import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js'; +import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; +import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js'; +import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; +import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; +import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; +import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; +import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; +import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; +import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CleanProcessorService } from './processors/CleanProcessorService.js'; +import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; -import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; @Injectable() export class QueueProcessorService { @@ -25,14 +46,35 @@ export class QueueProcessorService { private queueLoggerService: QueueLoggerService, private queueService: QueueService, - private systemQueueProcessorsService: SystemQueueProcessorsService, - private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, - private dbQueueProcessorsService: DbQueueProcessorsService, - private relationshipQueueProcessorsService: RelationshipQueueProcessorsService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, + private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, + private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, + private exportNotesProcessorService: ExportNotesProcessorService, + private exportFavoritesProcessorService: ExportFavoritesProcessorService, + private exportFollowingProcessorService: ExportFollowingProcessorService, + private exportMutingProcessorService: ExportMutingProcessorService, + private exportBlockingProcessorService: ExportBlockingProcessorService, + private exportUserListsProcessorService: ExportUserListsProcessorService, + private exportAntennasProcessorService: ExportAntennasProcessorService, + private importFollowingProcessorService: ImportFollowingProcessorService, + private importMutingProcessorService: ImportMutingProcessorService, + private importBlockingProcessorService: ImportBlockingProcessorService, + private importUserListsProcessorService: ImportUserListsProcessorService, + private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, + private importAntennasProcessorService: ImportAntennasProcessorService, + private deleteAccountProcessorService: DeleteAccountProcessorService, + private deleteFileProcessorService: DeleteFileProcessorService, + private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, + private relationshipProcessorService: RelationshipProcessorService, + private tickChartsProcessorService: TickChartsProcessorService, + private resyncChartsProcessorService: ResyncChartsProcessorService, + private cleanChartsProcessorService: CleanChartsProcessorService, + private aggregateRetentionProcessorService: AggregateRetentionProcessorService, + private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; } @@ -119,14 +161,6 @@ export class QueueProcessorService { .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); - this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); - this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); - this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); - this.dbQueueProcessorsService.start(this.queueService.dbQueue); - this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue); - this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); - this.queueService.systemQueue.add('tickCharts', { }, { repeat: { cron: '55 * * * *' }, @@ -163,6 +197,46 @@ export class QueueProcessorService { removeOnComplete: true, }); - this.systemQueueProcessorsService.start(this.queueService.systemQueue); + this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); + this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); + this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); + this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); + + this.queueService.dbQueue.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); + this.queueService.dbQueue.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done)); + this.queueService.dbQueue.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); + this.queueService.dbQueue.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); + this.queueService.dbQueue.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); + this.queueService.dbQueue.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); + this.queueService.dbQueue.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); + this.queueService.dbQueue.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); + this.queueService.dbQueue.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); + this.queueService.dbQueue.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done)); + this.queueService.dbQueue.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); + + this.queueService.objectStorageQueue.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job)); + this.queueService.objectStorageQueue.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done)); + + { + const maxJobs = this.config.relashionshipJobConcurrency ?? 16; + this.queueService.relationshipQueue.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); + this.queueService.relationshipQueue.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); + this.queueService.relationshipQueue.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); + this.queueService.relationshipQueue.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); + } + + this.queueService.systemQueue.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done)); + this.queueService.systemQueue.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done)); + this.queueService.systemQueue.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done)); + this.queueService.systemQueue.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done)); + this.queueService.systemQueue.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done)); + this.queueService.systemQueue.process('clean', (job, done) => this.cleanProcessorService.process(job, done)); } } diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts deleted file mode 100644 index 736b4fa80d..0000000000 --- a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { bindThis } from '@/decorators.js'; -import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; -import type Bull from 'bull'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; - -@Injectable() -export class RelationshipQueueProcessorsService { - constructor( - @Inject(DI.config) - private config: Config, - - private relationshipProcessorService: RelationshipProcessorService, - ) { - } - - @bindThis - public start(q: Bull.Queue): void { - const maxJobs = this.config.relashionshipJobConcurrency ?? 16; - q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); - q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); - q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); - q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); - } -} diff --git a/packages/backend/src/queue/SystemQueueProcessorsService.ts b/packages/backend/src/queue/SystemQueueProcessorsService.ts deleted file mode 100644 index 7fb0da4b10..0000000000 --- a/packages/backend/src/queue/SystemQueueProcessorsService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { bindThis } from '@/decorators.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; -import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; -import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; -import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; -import { CleanProcessorService } from './processors/CleanProcessorService.js'; -import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; -import type Bull from 'bull'; - -@Injectable() -export class SystemQueueProcessorsService { - constructor( - @Inject(DI.config) - private config: Config, - - private tickChartsProcessorService: TickChartsProcessorService, - private resyncChartsProcessorService: ResyncChartsProcessorService, - private cleanChartsProcessorService: CleanChartsProcessorService, - private aggregateRetentionProcessorService: AggregateRetentionProcessorService, - private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, - private cleanProcessorService: CleanProcessorService, - ) { - } - - @bindThis - public start(q: Bull.Queue): void { - q.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done)); - q.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done)); - q.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done)); - q.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done)); - q.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done)); - q.process('clean', (job, done) => this.cleanProcessorService.process(job, done)); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 42229c8f23..f49d2a0966 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -68,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { emailVerified: profile.emailVerified, autoAcceptFollowed: profile.autoAcceptFollowed, noCrawle: profile.noCrawle, + preventAiLearning: profile.preventAiLearning, alwaysMarkNsfw: profile.alwaysMarkNsfw, autoSensitive: profile.autoSensitive, carefulBot: profile.carefulBot, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 6c66300bb7..74be00a8b8 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -98,7 +98,7 @@ export const meta = { message: 'This feature is restricted by your role.', code: 'RESTRICTED_BY_ROLE', id: '8feff0ba-5ab5-585b-31f4-4df816663fad', - } + }, }, res: { @@ -138,6 +138,7 @@ export const paramDef = { carefulBot: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' }, noCrawle: { type: 'boolean' }, + preventAiLearning: { type: 'boolean' }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, showTimelineReplies: { type: 'boolean' }, @@ -242,6 +243,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 50b23a0682..f780280c1f 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -35,8 +35,8 @@ import { RoleService } from '@/core/RoleService.js'; import manifest from './manifest.json' assert { type: 'json' }; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import { ClientLoggerService } from './ClientLoggerService.js'; +import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -423,6 +423,10 @@ export class ClientServerService { : []; reply.header('Cache-Control', 'public, max-age=15'); + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('user', { user, profile, me, avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), @@ -467,6 +471,10 @@ export class ClientServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('note', { note: _note, profile, @@ -506,6 +514,10 @@ export class ClientServerService { } else { reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); } + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('page', { page: _page, profile, @@ -530,6 +542,10 @@ export class ClientServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId }); const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('flash', { flash: _flash, profile, @@ -554,6 +570,10 @@ export class ClientServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('clip', { clip: _clip, profile, @@ -576,6 +596,10 @@ export class ClientServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); + if (profile.preventAiLearning) { + reply.header('X-Robots-Tag', 'noimageai'); + reply.header('X-Robots-Tag', 'noai'); + } return await reply.view('gallery-post', { post: _post, profile, diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug index 4c692bf59b..74dc62f1e7 100644 --- a/packages/backend/src/server/web/views/clip.pug +++ b/packages/backend/src/server/web/views/clip.pug @@ -21,6 +21,9 @@ block og block meta if profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/flash.pug b/packages/backend/src/server/web/views/flash.pug index 5166855ea2..5594fcdfbf 100644 --- a/packages/backend/src/server/web/views/flash.pug +++ b/packages/backend/src/server/web/views/flash.pug @@ -21,6 +21,9 @@ block og block meta if profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug index ca0663a481..10f2d269bc 100644 --- a/packages/backend/src/server/web/views/gallery-post.pug +++ b/packages/backend/src/server/web/views/gallery-post.pug @@ -21,6 +21,9 @@ block og block meta if user.host || profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index 65696ea138..badfcccd61 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -22,6 +22,9 @@ block og block meta if user.host || isRenote || profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug index 4219e76a52..ddffc361c8 100644 --- a/packages/backend/src/server/web/views/page.pug +++ b/packages/backend/src/server/web/views/page.pug @@ -21,6 +21,9 @@ block og block meta if profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug index 119993fdb5..f4c83aa89d 100644 --- a/packages/backend/src/server/web/views/user.pug +++ b/packages/backend/src/server/web/views/user.pug @@ -20,6 +20,9 @@ block og block meta if user.host || profile.noCrawle meta(name='robots' content='noindex') + if profile.preventAiLearning + meta(name='robots' content='noimageai') + meta(name='robots' content='noai') meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 9c851a5dd6..d2eb8f01d7 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -541,6 +541,61 @@ describe('Note', () => { assert.strictEqual(res.status, 400); }); + + test('センシティブな投稿はhomeになる (単語指定)', async () => { + const sensitive = await api('admin/update-meta', { + sensitiveWords: [ + "test", + ] + }, alice); + + assert.strictEqual(sensitive.status, 204); + + await new Promise(x => setTimeout(x, 2)); + + const note1 = await api('/notes/create', { + text: 'hogetesthuge', + }, alice); + + assert.strictEqual(note1.status, 200); + assert.strictEqual(note1.body.createdNote.visibility, 'home'); + + }); + + test('センシティブな投稿はhomeになる (正規表現)', async () => { + const sensitive = await api('admin/update-meta', { + sensitiveWords: [ + "/Test/i", + ] + }, alice); + + assert.strictEqual(sensitive.status, 204); + + const note2 = await api('/notes/create', { + text: 'hogetesthuge', + }, alice); + + assert.strictEqual(note2.status, 200); + assert.strictEqual(note2.body.createdNote.visibility, 'home'); + }); + + test('センシティブな投稿はhomeになる (スペースアンド)', async () => { + const sensitive = await api('admin/update-meta', { + sensitiveWords: [ + "Test hoge" + ] + }, alice); + + assert.strictEqual(sensitive.status, 204); + + const note2 = await api('/notes/create', { + text: 'hogeTesthuge', + }, alice); + + assert.strictEqual(note2.status, 200); + assert.strictEqual(note2.body.createdNote.visibility, 'home'); + + }); }); describe('notes/delete', () => { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 51537dda16..a7f8210c8e 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -145,6 +145,7 @@ describe('ユーザー', () => { carefulBot: user.carefulBot, autoAcceptFollowed: user.autoAcceptFollowed, noCrawle: user.noCrawle, + preventAiLearning: user.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, hideOnlineStatus: user.hideOnlineStatus, @@ -370,7 +371,7 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.pinnedNotes, []); assert.strictEqual(response.pinnedPageId, null); assert.strictEqual(response.pinnedPage, null); - assert.strictEqual(response.publicReactions, false); + assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.ffVisibility, 'public'); assert.strictEqual(response.twoFactorEnabled, false); assert.strictEqual(response.usePasswordLessLogin, false); @@ -390,6 +391,7 @@ describe('ユーザー', () => { assert.strictEqual(response.carefulBot, false); assert.strictEqual(response.autoAcceptFollowed, true); assert.strictEqual(response.noCrawle, false); + assert.strictEqual(response.preventAiLearning, true); assert.strictEqual(response.isExplorable, true); assert.strictEqual(response.isDeleted, false); assert.strictEqual(response.hideOnlineStatus, false); @@ -462,6 +464,8 @@ describe('ユーザー', () => { { parameters: (): object => ({ autoAcceptFollowed: false }) }, { parameters: (): object => ({ noCrawle: true }) }, { parameters: (): object => ({ noCrawle: false }) }, + { parameters: (): object => ({ preventAiLearning: false }) }, + { parameters: (): object => ({ preventAiLearning: true }) }, { parameters: (): object => ({ isBot: true }) }, { parameters: (): object => ({ isBot: false }) }, { parameters: (): object => ({ isCat: true }) }, diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index 755bec6869..fc0f0c286b 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -45,7 +45,7 @@ fs.readFile( micromatch(Array.from(modules), [ '../../assets/**', '../../fluent-emojis/**', - '../../locales/**', + '../../locales/ja-JP.yml', '../../misskey-assets/**', 'assets/**', 'public/**', diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 7646e152f9..5b4004d8e3 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -17,13 +17,13 @@ "@discordapp/twemoji": "14.1.2", "@rollup/plugin-alias": "5.0.0", "@rollup/plugin-json": "6.0.0", - "@rollup/plugin-replace": "^5.0.2", + "@rollup/plugin-replace": "5.0.2", "@rollup/pluginutils": "5.0.2", "@syuilo/aiscript": "0.13.2", "@tabler/icons-webfont": "2.17.0", - "@vitejs/plugin-vue": "4.2.1", - "@vue-macros/reactivity-transform": "^0.3.5", - "@vue/compiler-sfc": "3.2.47", + "@vitejs/plugin-vue": "4.2.2", + "@vue-macros/reactivity-transform": "0.3.6", + "@vue/compiler-sfc": "3.3.1", "autosize": "5.0.2", "blurhash": "2.0.5", "broadcast-channel": "4.20.2", @@ -34,14 +34,14 @@ "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "6.17.3", - "compare-versions": "5.0.1", + "chromatic": "6.17.4", + "compare-versions": "5.0.3", "cropperjs": "2.0.0-beta.2", "date-fns": "2.30.0", "escape-regexp": "0.0.1", "eventemitter3": "5.0.1", "gsap": "3.11.5", - "idb-keyval": "6.2.0", + "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", @@ -53,7 +53,7 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.21.3", + "rollup": "3.21.6", "s-age": "1.1.2", "sanitize-html": "2.10.0", "sass": "1.62.1", @@ -70,40 +70,40 @@ "typescript": "5.0.4", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.3.4", - "vue": "3.2.47", + "vite": "4.3.5", + "vue": "3.3.1", "vue-plyr": "7.0.0", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.0.7", - "@storybook/addon-essentials": "7.0.7", - "@storybook/addon-interactions": "7.0.7", - "@storybook/addon-links": "7.0.7", - "@storybook/addon-storysource": "7.0.7", - "@storybook/addons": "7.0.7", - "@storybook/blocks": "7.0.7", - "@storybook/core-events": "7.0.7", + "@storybook/addon-actions": "7.0.10", + "@storybook/addon-essentials": "7.0.10", + "@storybook/addon-interactions": "7.0.10", + "@storybook/addon-links": "7.0.10", + "@storybook/addon-storysource": "7.0.10", + "@storybook/addons": "7.0.10", + "@storybook/blocks": "7.0.10", + "@storybook/core-events": "7.0.10", "@storybook/jest": "0.1.0", - "@storybook/manager-api": "7.0.7", - "@storybook/preview-api": "7.0.7", - "@storybook/react": "7.0.7", - "@storybook/react-vite": "7.0.7", + "@storybook/manager-api": "7.0.10", + "@storybook/preview-api": "7.0.10", + "@storybook/react": "7.0.10", + "@storybook/react-vite": "7.0.10", "@storybook/testing-library": "0.1.0", - "@storybook/theming": "7.0.7", - "@storybook/types": "7.0.7", - "@storybook/vue3": "7.0.7", - "@storybook/vue3-vite": "7.0.7", + "@storybook/theming": "7.0.10", + "@storybook/types": "7.0.10", + "@storybook/vue3": "7.0.10", + "@storybook/vue3-vite": "7.0.10", "@testing-library/jest-dom": "5.16.5", "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", "@types/estree": "1.0.1", "@types/gulp": "4.0.10", - "@types/gulp-rename": "2.0.1", - "@types/matter-js": "0.18.2", + "@types/gulp-rename": "2.0.2", + "@types/matter-js": "0.18.3", "@types/micromatch": "4.0.2", - "@types/node": "18.16.3", + "@types/node": "20.1.3", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", @@ -113,19 +113,19 @@ "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.2", - "@typescript-eslint/parser": "5.59.2", - "@vitest/coverage-c8": "0.30.1", - "@vue/runtime-core": "3.2.47", + "@typescript-eslint/eslint-plugin": "5.59.5", + "@typescript-eslint/parser": "5.59.5", + "@vitest/coverage-c8": "0.31.0", + "@vue/runtime-core": "3.3.1", "astring": "1.8.4", "chokidar-cli": "3.0.0", "cross-env": "7.0.3", - "cypress": "12.11.0", - "eslint": "8.39.0", + "cypress": "12.12.0", + "eslint": "8.40.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.11.0", + "eslint-plugin-vue": "9.12.0", "fast-glob": "3.2.12", - "happy-dom": "9.10.2", + "happy-dom": "9.16.0", "micromatch": "3.1.10", "msw": "1.2.1", "msw-storybook-addon": "1.8.0", @@ -133,13 +133,13 @@ "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.0", - "storybook": "7.0.7", + "storybook": "7.0.10", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.2", - "vitest": "0.30.1", + "vitest": "0.31.0", "vitest-fetch-mock": "0.2.2", - "vue-eslint-parser": "9.1.1", - "vue-tsc": "1.6.3" + "vue-eslint-parser": "9.2.1", + "vue-tsc": "1.6.4" } } diff --git a/packages/frontend/src/components/MkCheckbox.vue b/packages/frontend/src/components/MkCheckbox.vue deleted file mode 100644 index a8e24dd839..0000000000 --- a/packages/frontend/src/components/MkCheckbox.vue +++ /dev/null @@ -1,144 +0,0 @@ -<template> -<div - class="ziffeoms" - :class="{ disabled, checked }" -> - <input - ref="input" - type="checkbox" - :disabled="disabled" - @keydown.enter="toggle" - > - <span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle"> - <i class="check ti ti-check"></i> - </span> - <span class="label"> - <!-- TODO: 無名slotの方は廃止 --> - <span @click="toggle"><slot name="label"></slot><slot></slot></span> - <p class="caption"><slot name="caption"></slot></p> - </span> -</div> -</template> - -<script lang="ts" setup> -import { toRefs, Ref } from 'vue'; -import * as os from '@/os'; -import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { i18n } from '@/i18n'; - -const props = defineProps<{ - modelValue: boolean | Ref<boolean>; - disabled?: boolean; -}>(); - -const emit = defineEmits<{ - (ev: 'update:modelValue', v: boolean): void; -}>(); - -let button = $shallowRef<HTMLElement>(); -const checked = toRefs(props).modelValue; -const toggle = () => { - if (props.disabled) return; - emit('update:modelValue', !checked.value); - - if (!checked.value) { - const rect = button.getBoundingClientRect(); - const x = rect.left + (button.offsetWidth / 2); - const y = rect.top + (button.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y, particle: false }, {}, 'end'); - } -}; -</script> - -<style lang="scss" scoped> -.ziffeoms { - position: relative; - display: flex; - transition: all 0.2s ease; - - > * { - user-select: none; - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: relative; - display: inline-flex; - flex-shrink: 0; - margin: 0; - box-sizing: border-box; - width: 23px; - height: 23px; - outline: none; - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 4px; - cursor: pointer; - transition: inherit; - - > .check { - margin: auto; - opacity: 0; - color: var(--fgOnAccent); - font-size: 13px; - transform: scale(0.5); - transition: all 0.2s ease; - } - } - - &:hover { - > .button { - border-color: var(--inputBorderHover) !important; - } - } - - > .label { - margin-left: 12px; - margin-top: 2px; - display: block; - transition: inherit; - color: var(--fg); - - > span { - display: block; - line-height: 20px; - cursor: pointer; - transition: inherit; - } - - > .caption { - margin: 8px 0 0 0; - color: var(--fgTransparentWeak); - font-size: 0.85em; - - &:empty { - display: none; - } - } - } - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.checked { - > .button { - background-color: var(--accent) !important; - border-color: var(--accent) !important; - - > .check { - opacity: 1; - transform: scale(1); - } - } - } -} -</style> diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 0f87fef6b1..6fcd8f7811 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -52,9 +52,12 @@ <MkFoldableSection class="item"> <template #header>Retention rate</template> - <div class="_panel" :class="$style.retention"> + <div class="_panel" :class="$style.retentionHeatmap"> <MkRetentionHeatmap/> </div> + <div class="_panel" :class="$style.retentionLine"> + <MkRetentionLineChart/> + </div> </MkFoldableSection> <MkFoldableSection class="item"> @@ -86,6 +89,7 @@ import { i18n } from '@/i18n'; import MkHeatmap from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; +import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import { initChart } from '@/scripts/init-chart'; initChart(); @@ -202,7 +206,12 @@ onMounted(() => { margin-bottom: 16px; } -.retention { +.retentionHeatmap { + padding: 16px; + margin-bottom: 16px; +} + +.retentionLine { padding: 16px; margin-bottom: 16px; } diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index ad7dc4da11..63c55b904a 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -1,15 +1,15 @@ <template> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown"> - <div ref="headerEl" class="header"> - <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> - <span class="title"> + <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown"> + <div ref="headerEl" :class="$style.header"> + <button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> + <span :class="$style.title"> <slot name="header"></slot> </span> - <button v-if="!withOkButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button> - <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button> + <button v-if="!withOkButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button> + <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button> </div> - <div class="body"> + <div :class="$style.body"> <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> @@ -81,8 +81,8 @@ defineExpose({ }); </script> -<style lang="scss" scoped> -.ebkgoccj { +<style lang="scss" module> +.root { margin: auto; overflow: hidden; display: flex; @@ -96,51 +96,52 @@ defineExpose({ --root-margin: 16px; } - > .header { - $height: 46px; - $height-narrow: 42px; - display: flex; - flex-shrink: 0; - background: var(--windowHeader); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + --headerHeight: 46px; + --headerHeightNarrow: 42px; +} - > button { - height: $height; - width: $height; +.header { + display: flex; + flex-shrink: 0; + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} - @media (max-width: 500px) { - height: $height-narrow; - width: $height-narrow; - } - } +.headerButton { + height: var(--headerHeight); + width: var(--headerHeight); - > .title { - flex: 1; - line-height: $height; - padding-left: 32px; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - pointer-events: none; + @media (max-width: 500px) { + height: var(--headerHeightNarrow); + width: var(--headerHeightNarrow); + } +} - @media (max-width: 500px) { - line-height: $height-narrow; - padding-left: 16px; - } - } +.title { + flex: 1; + line-height: var(--headerHeight); + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; - > button + .title { - padding-left: 0; - } + @media (max-width: 500px) { + line-height: var(--headerHeightNarrow); + padding-left: 16px; } +} - > .body { - flex: 1; - overflow: auto; - background: var(--panel); - container-type: size; - } +.headerButton + .title { + padding-left: 0; +} + +.body { + flex: 1; + overflow: auto; + background: var(--panel); + container-type: size; } </style> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index c293641355..9ac0b7858f 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -1,6 +1,7 @@ <template> <div :class="[$style.root, { [$style.children]: depth > 1 }]"> <div :class="$style.main"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> <div :class="$style.body"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> @@ -62,6 +63,7 @@ if (props.detail) { .root { padding: 16px 32px; font-size: 0.9em; + position: relative; &.children { padding: 10px 0 0 16px; @@ -73,6 +75,16 @@ if (props.detail) { display: flex; } +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 8px); + border-radius: 999px; + pointer-events: none; +} + .avatar { flex-shrink: 0; display: block; diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index 5db2f5ee6d..eea94d4692 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -1,8 +1,7 @@ <template> <div v-adaptive-border - class="novjtctn" - :class="{ disabled, checked }" + :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]" :aria-checked="checked" :aria-disabled="disabled" @click="toggle" @@ -10,11 +9,12 @@ <input type="radio" :disabled="disabled" + :class="$style.input" > - <span class="button"> + <span :class="$style.button"> <span></span> </span> - <span class="label"><slot></slot></span> + <span :class="$style.label"><slot></slot></span> </div> </template> @@ -39,8 +39,8 @@ function toggle(): void { } </script> -<style lang="scss" scoped> -.novjtctn { +<style lang="scss" module> +.root { position: relative; display: inline-block; text-align: left; @@ -53,17 +53,11 @@ function toggle(): void { border-radius: 6px; font-size: 90%; transition: all 0.2s; - - > * { - user-select: none; - } + user-select: none; &.disabled { opacity: 0.6; - - &, * { - cursor: not-allowed !important; - } + cursor: not-allowed !important; } &:hover { @@ -74,10 +68,7 @@ function toggle(): void { background-color: var(--accentedBg) !important; border-color: var(--accentedBg) !important; color: var(--accent); - - &, * { - cursor: default !important; - } + cursor: default !important; > .button { border-color: var(--accent); @@ -89,44 +80,44 @@ function toggle(): void { } } } +} - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } +.input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + +.button { + position: absolute; + width: 14px; + height: 14px; + background: none; + border: solid 2px var(--inputBorder); + border-radius: 100%; + transition: inherit; - > .button { + &:after { + content: ''; + display: block; position: absolute; - width: 14px; - height: 14px; - background: none; - border: solid 2px var(--inputBorder); + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; border-radius: 100%; - transition: inherit; - - &:after { - content: ''; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); - } + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); } +} - > .label { - margin-left: 28px; - display: block; - line-height: 20px; - cursor: pointer; - } +.label { + margin-left: 28px; + display: block; + line-height: 20px; + cursor: pointer; } </style> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index f33f68cab7..311d5c425c 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -40,7 +40,7 @@ async function renderChart() { let raw = await os.api('retention', { }); - raw = raw.slice(0, maxDays); + raw = raw.slice(0, maxDays + 1); const data = []; for (const record of raw) { @@ -90,8 +90,13 @@ async function renderChart() { borderRadius: 3, backgroundColor(c) { const value = c.dataset.data[c.dataIndex].v; - const a = value / max(c.dataset.data[c.dataIndex].y); - return alpha(color, a); + const m = max(c.dataset.data[c.dataIndex].y); + if (m === 0) { + return alpha(color, 0); + } else { + const a = value / m; + return alpha(color, a); + } }, fill: true, width(c) { @@ -129,6 +134,10 @@ async function renderChart() { autoSkip: false, callback: (value, index, values) => value, }, + title: { + display: true, + text: 'Days later', + }, }, y: { type: 'time', @@ -166,7 +175,12 @@ async function renderChart() { }, label(context) { const v = context.dataset.data[context.dataIndex]; - return [`Active: ${v.v} (${Math.round((v.v / max(v.y)) * 100)}%)`]; + const m = max(v.y); + if (m === 0) { + return [`Active: ${v.v} (-%)`]; + } else { + return [`Active: ${v.v} (${Math.round((v.v / m) * 100)}%)`]; + } }, }, //mode: 'index', diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue new file mode 100644 index 0000000000..8bd0279806 --- /dev/null +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -0,0 +1,130 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, shallowRef } from 'vue'; +import { Chart } from 'chart.js'; +import tinycolor from 'tinycolor2'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; +import { alpha } from '@/scripts/color'; +import { initChart } from '@/scripts/init-chart'; +import * as os from '@/os'; + +initChart(); + +const chartEl = shallowRef<HTMLCanvasElement>(null); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +const getYYYYMMDD = (date: Date) => { + const y = date.getFullYear().toString().padStart(2, '0'); + const m = (date.getMonth() + 1).toString().padStart(2, '0'); + const d = date.getDate().toString().padStart(2, '0'); + return `${y}/${m}/${d}`; +}; + +const getDate = (ymd: string) => { + const [y, m, d] = ymd.split('-').map(x => parseInt(x, 10)); + const date = new Date(y, m + 1, d, 0, 0, 0, 0); + return date; +}; + +onMounted(async () => { + let raw = await os.api('retention', { }); + + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); + const color = accent.toHex(); + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: raw.map((record, i) => ({ + label: getYYYYMMDD(new Date(record.createdAt)), + pointRadius: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: alpha(color, Math.min(1, (raw.length - (i - 1)) / raw.length)), + fill: false, + tension: 0.4, + data: [{ + x: '0', + y: 100, + d: getYYYYMMDD(new Date(record.createdAt)), + }, ...Object.entries(record.data).sort((a, b) => getDate(a[0]) > getDate(b[0]) ? 1 : -1).map(([k, v], i) => ({ + x: (i + 1).toString(), + y: (v / record.users) * 100, + d: getYYYYMMDD(new Date(record.createdAt)), + }))], + })), + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + title: { + display: true, + text: 'Days later', + }, + }, + y: { + title: { + display: true, + text: 'Rate (%)', + }, + ticks: { + callback: (value, index, values) => value + '%', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + callbacks: { + title(context) { + const v = context[0].dataset.data[context[0].dataIndex]; + return `${v.x} days later`; + }, + label(context) { + const v = context.dataset.data[context.dataIndex]; + const p = Math.round(v.y) + '%'; + return `${v.d} ${p}`; + }, + }, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 2de890186a..4efb65c287 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -1,13 +1,13 @@ <template> -<div class="vblkjoeq"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div ref="container" class="input" :class="{ inline, disabled, focused }" @mousedown.prevent="show"> - <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> +<div> + <div :class="$style.label" @click="focus"><slot name="label"></slot></div> + <div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show"> + <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <select ref="inputEl" v-model="v" v-adaptive-border - class="select" + :class="$style.inputCore" :disabled="disabled" :required="required" :readonly="readonly" @@ -18,9 +18,9 @@ > <slot></slot> </select> - <div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div> + <div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div> </div> - <div class="caption"><slot name="caption"></slot></div> + <div :class="$style.caption"><slot name="caption"></slot></div> <MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> @@ -169,121 +169,116 @@ function show(ev: MouseEvent) { } </script> -<style lang="scss" scoped> -.vblkjoeq { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--fgTransparentWeak); +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .input { - position: relative; - cursor: pointer; +.input { + position: relative; + cursor: pointer; - &:hover { - > .select { - border-color: var(--inputBorderHover) !important; - } - } + &.inline { + display: inline-block; + margin: 0; + } - > .select { - appearance: none; - -webkit-appearance: none; - display: block; - height: v-bind("height + 'px'"); - width: 100%; - margin: 0; - padding: 0 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - cursor: pointer; - transition: border-color 0.1s ease-out; - pointer-events: none; - user-select: none; + &.focused { + > .inputCore { + border-color: var(--accent) !important; + //box-shadow: 0 0 0 4px var(--focus); } + } - > .prefix, - > .suffix { - display: flex; - align-items: center; - position: absolute; - z-index: 1; - top: 0; - padding: 0 12px; - font-size: 1em; - height: v-bind("height + 'px'"); - pointer-events: none; - - &:empty { - display: none; - } + &.disabled { + opacity: 0.7; - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + &, + > .inputCore { + cursor: not-allowed !important; } + } - > .prefix { - left: 0; - padding-right: 6px; + &:hover { + > .inputCore { + border-color: var(--inputBorderHover) !important; } + } +} - > .suffix { - right: 0; - padding-left: 6px; - } +.inputCore { + appearance: none; + -webkit-appearance: none; + display: block; + height: v-bind("height + 'px'"); + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + cursor: pointer; + pointer-events: none; + user-select: none; +} - &.inline { - display: inline-block; - margin: 0; - } +.prefix, +.suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: v-bind("height + 'px'"); + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: border-box; + pointer-events: none; - &.focused { - > select { - border-color: var(--accent) !important; - } - } + &:empty { + display: none; + } +} - &.disabled { - opacity: 0.7; +.prefix { + left: 0; + padding-right: 6px; +} - &, * { - cursor: not-allowed !important; - } - } - } +.suffix { + right: 0; + padding-left: 6px; } -</style> -<style lang="scss" module> .chevron { transition: transform 0.1s ease-out; } diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index d9f6716f92..63738b6a44 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -1,21 +1,19 @@ <template> -<div - class="ziffeomt" - :class="{ disabled, checked }" -> +<div :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]"> <input ref="input" type="checkbox" :disabled="disabled" + :class="$style.input" @keydown.enter="toggle" > - <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle"> - <div class="knob"></div> + <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" :class="$style.button" data-cy-switch-toggle @click.prevent="toggle"> + <div :class="$style.knob"></div> </span> - <span class="label"> + <span :class="$style.body"> <!-- TODO: 無名slotの方は廃止 --> - <span @click="toggle"><slot name="label"></slot><slot></slot></span> - <p class="caption"><slot name="caption"></slot></p> + <span :class="$style.label" @click="toggle"><slot name="label"></slot><slot></slot></span> + <p :class="$style.caption"><slot name="caption"></slot></p> </span> </div> </template> @@ -45,52 +43,12 @@ const toggle = () => { }; </script> -<style lang="scss" scoped> -.ziffeomt { +<style lang="scss" module> +.root { position: relative; display: flex; transition: all 0.2s ease; - - > * { - user-select: none; - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: relative; - display: inline-flex; - flex-shrink: 0; - margin: 0; - box-sizing: border-box; - width: 32px; - height: 23px; - outline: none; - background: var(--switchOffBg); - background-clip: content-box; - border: solid 1px var(--switchOffBg); - border-radius: 999px; - cursor: pointer; - transition: inherit; - user-select: none; - - > .knob { - position: absolute; - top: 3px; - left: 3px; - width: 15px; - height: 15px; - background: var(--switchOffFg); - border-radius: 999px; - transition: all 0.2s ease; - } - } + user-select: none; &:hover { > .button { @@ -98,31 +56,6 @@ const toggle = () => { } } - > .label { - margin-left: 12px; - margin-top: 2px; - display: block; - transition: inherit; - color: var(--fg); - - > span { - display: block; - line-height: 20px; - cursor: pointer; - transition: inherit; - } - - > .caption { - margin: 8px 0 0 0; - color: var(--fgTransparentWeak); - font-size: 0.85em; - - &:empty { - display: none; - } - } - } - &.disabled { opacity: 0.6; cursor: not-allowed; @@ -140,4 +73,66 @@ const toggle = () => { } } } + +.input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + +.button { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + width: 32px; + height: 23px; + outline: none; + background: var(--switchOffBg); + background-clip: content-box; + border: solid 1px var(--switchOffBg); + border-radius: 999px; + cursor: pointer; + transition: inherit; + user-select: none; +} + +.knob { + position: absolute; + top: 3px; + left: 3px; + width: 15px; + height: 15px; + background: var(--switchOffFg); + border-radius: 999px; + transition: all 0.2s ease; +} + +.body { + margin-left: 12px; + margin-top: 2px; + display: block; + transition: inherit; + color: var(--fg); +} + +.label { + display: block; + line-height: 20px; + cursor: pointer; + transition: inherit; +} + +.caption { + margin: 8px 0 0 0; + color: var(--fgTransparentWeak); + font-size: 0.85em; + + &:empty { + display: none; + } +} </style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index b89e3e4c9d..a2a195cb09 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -40,10 +40,6 @@ import * as os from '@/os'; import { $i } from '@/account'; import MkPagination from '@/components/MkPagination.vue'; -const emit = defineEmits<{ - (ev: 'done'): void; -}>(); - const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts new file mode 100644 index 0000000000..70817d83c3 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.stories.impl.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkUserSetupDialog_Privacy from './MkUserSetupDialog.Privacy.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_Privacy, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserSetupDialog_Privacy v-bind="props" />', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkUserSetupDialog_Privacy>; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue new file mode 100644 index 0000000000..e9f4f68df8 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -0,0 +1,64 @@ +<template> +<div class="_gaps"> + <MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo> + + <MkFolder> + <template #label>{{ i18n.ts.makeFollowManuallyApprove }}</template> + <template #suffix>{{ isLocked ? i18n.ts.on : i18n.ts.off }}</template> + + <MkSwitch v-model="isLocked">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts.hideOnlineStatus }}</template> + <template #suffix>{{ hideOnlineStatus ? i18n.ts.on : i18n.ts.off }}</template> + + <MkSwitch v-model="hideOnlineStatus">{{ i18n.ts.hideOnlineStatus }}<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template></MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts.noCrawle }}</template> + <template #suffix>{{ noCrawle ? i18n.ts.on : i18n.ts.off }}</template> + + <MkSwitch v-model="noCrawle">{{ i18n.ts.noCrawle }}<template #caption>{{ i18n.ts.noCrawleDescription }}</template></MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts.preventAiLearning }}</template> + <template #suffix>{{ preventAiLearning ? i18n.ts.on : i18n.ts.off }}</template> + + <MkSwitch v-model="preventAiLearning">{{ i18n.ts.preventAiLearning }}<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template></MkSwitch> + </MkFolder> + + <MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; + +let isLocked = ref(false); +let hideOnlineStatus = ref(false); +let noCrawle = ref(false); +let preventAiLearning = ref(true); + +watch([isLocked, hideOnlineStatus, noCrawle, preventAiLearning], () => { + os.api('i/update', { + isLocked: !!isLocked.value, + hideOnlineStatus: !!hideOnlineStatus.value, + noCrawle: !!noCrawle.value, + preventAiLearning: !!preventAiLearning.value, + }); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index adb8d43349..f26ea11214 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,10 +37,6 @@ import { chooseFileFromPc } from '@/scripts/select-file'; import * as os from '@/os'; import { $i } from '@/account'; -const emit = defineEmits<{ - (ev: 'done'): void; -}>(); - const name = ref(''); const description = ref(''); diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index 096b88c309..4e80a5c0fb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -7,9 +7,17 @@ @close="close(true)" @closed="emit('closed')" > - <template #header>{{ i18n.ts.initialAccountSetting }}</template> + <template v-if="page === 1" #header>{{ i18n.ts._initialAccountSetting.profileSetting }}</template> + <template v-else-if="page === 2" #header>{{ i18n.ts._initialAccountSetting.privacySetting }}</template> + <template v-else-if="page === 3" #header>{{ i18n.ts.follow }}</template> + <template v-else-if="page === 4" #header>{{ i18n.ts.pushNotification }}</template> + <template v-else-if="page === 5" #header>{{ i18n.ts.done }}</template> + <template v-else #header>{{ i18n.ts.initialAccountSetting }}</template> <div style="overflow-x: clip;"> + <div :class="$style.progressBar"> + <div :class="$style.progressBarValue" :style="{ width: `${(page / 5) * 100}%` }"></div> + </div> <Transition mode="out-in" :enter-active-class="$style.transition_x_enterActive" @@ -40,12 +48,22 @@ <template v-else-if="page === 2"> <div style="height: 100cqh; overflow: auto;"> <MkSpacer :margin-min="20" :margin-max="28"> - <XFollow/> + <XPrivacy/> <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> </MkSpacer> </div> </template> <template v-else-if="page === 3"> + <div style="height: 100cqh; overflow: auto;"> + <MkSpacer :margin-min="20" :margin-max="28"> + <XFollow/> + </MkSpacer> + <div :class="$style.pageFooter"> + <MkButton primary rounded gradate style="margin: 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </div> + </template> + <template v-else-if="page === 4"> <div :class="$style.centerPage"> <MkSpacer :margin-min="20" :margin-max="28"> <div class="_gaps" style="text-align: center;"> @@ -58,7 +76,7 @@ </MkSpacer> </div> </template> - <template v-else-if="page === 4"> + <template v-else-if="page === 5"> <div :class="$style.centerPage"> <MkSpacer :margin-min="20" :margin-max="28"> <div class="_gaps" style="text-align: center;"> @@ -87,6 +105,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; import XFollow from '@/components/MkUserSetupDialog.Follow.vue'; +import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { host } from '@/config'; @@ -134,6 +153,21 @@ async function close(skip: boolean) { transform: translateX(-50px); } +.progressBar { + position: absolute; + top: 0; + left: 0; + z-index: 10; + width: 100%; + height: 4px; +} + +.progressBarValue { + height: 100%; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + transition: all 0.5s cubic-bezier(0,.5,.5,1); +} + .centerPage { display: flex; justify-content: center; @@ -142,4 +176,14 @@ async function close(skip: boolean) { padding-bottom: 30px; box-sizing: border-box; } + +.pageFooter { + position: sticky; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} </style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 33e594acd8..ad1c02a488 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -32,6 +32,7 @@ <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> </template> + <script lang="ts"> export type Widget = { name: string; @@ -42,6 +43,7 @@ export type DefaultStoredWidget = { place: string | null; } & Widget; </script> + <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { v4 as uuid } from 'uuid'; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index ad36dcabe4..42abdcbdcc 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -2,7 +2,7 @@ <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> <img :class="$style.inner" :src="url" decoding="async"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> - <div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]"> + <div v-if="user.isCat" :class="[$style.ears]"> <div :class="$style.earLeft"> <div v-if="false" :class="$style.layer"> <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> @@ -154,24 +154,6 @@ watch(() => props.user.avatarBlurhash, () => { padding: 50%; pointer-events: none; - &.mask { - -webkit-mask: - url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') center / 50% 50%, - linear-gradient(#fff, #fff); - -webkit-mask-composite: destination-out, source-over; - mask: - url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%, - linear-gradient(#fff, #fff); // polyfill of `image(#fff)` - - > .earLeft { - animation: eartightleft 6s infinite; - } - - > .earRight { - animation: eartightright 6s infinite; - } - } - > .earLeft, > .earRight { contain: strict; diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index 0237644d29..6ea81d257f 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -1,6 +1,6 @@ <template> -<div class="lzyxtsnt"> - <ImgWithBlurhash v-if="image" :hash="image.blurhash" :src="image.url" :alt="image.comment" :title="image.comment" :cover="false"/> +<div> + <ImgWithBlurhash v-if="image" style="max-width: 100%;" :hash="image.blurhash" :src="image.url" :alt="image.comment" :title="image.comment" :width="image.properties.width" :height="image.properties.height" :cover="false"/> </div> </template> @@ -17,11 +17,3 @@ const props = defineProps<{ const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); </script> - -<style lang="scss" scoped> -.lzyxtsnt { - > img { - max-width: 100%; - } -} -</style> diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index e592c629ce..9e0594db3c 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -238,6 +238,7 @@ const patrons = [ 'ずも', 'binvinyl', '渡志郎', + 'ぷーざ', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index ffd3b6e233..bf788e3609 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -27,7 +27,7 @@ <MkTextarea v-model="sensitiveWords"> <template #label>{{ i18n.ts.sensitiveWords }}</template> - <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> + <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> </MkTextarea> </div> </FormSuspense> diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index af1b4d2056..9aa564a7da 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -46,7 +46,7 @@ </MkInput> <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> </div> - <MkNotes v-if="searchPagination" :key="searchQuery" :pagination="searchPagination"/> + <MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/> </div> </div> </MkSpacer> @@ -93,6 +93,7 @@ let channel = $ref(null); let favorited = $ref(false); let searchQuery = $ref(''); let searchPagination = $ref(); +let searchKey = $ref(''); const featuredPagination = $computed(() => ({ endpoint: 'notes/featured' as const, limit: 10, @@ -149,10 +150,12 @@ async function search() { endpoint: 'notes/search', limit: 10, params: { - query: searchQuery, + query: query, channelId: channel.id, }, }; + + searchKey = query; } const headerActions = $computed(() => { diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 3f4ff5182b..f9c833dd29 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -28,9 +28,9 @@ <MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin"> <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> - <div class="vxjfqztj"> - <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> - <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`">{{ tag.tag }}</MkA> + <div> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px; font-weight: bold;">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/user-tags/${tag.tag}`" style="margin-right: 16px;">{{ tag.tag }}</MkA> </div> </MkFoldableSection> @@ -132,15 +132,3 @@ os.api('hashtags/list', { tagsRemote = tags; }); </script> - -<style lang="scss" scoped> -.vxjfqztj { - > * { - margin-right: 16px; - - &.local { - font-weight: bold; - } - } -} -</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index e97a4b07f1..1b292e8f3c 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -37,7 +37,7 @@ async function choose() { file = fileResponse[0]; emit('update:modelValue', { ...props.modelValue, - fileId: fileResponse.id, + fileId: file.id, }); }); } diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue new file mode 100644 index 0000000000..d9b44d15f5 --- /dev/null +++ b/packages/frontend/src/pages/search.note.vue @@ -0,0 +1,98 @@ +<template> +<div class="_gaps"> + <div class="_gaps"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkFolder> + <template #label>{{ i18n.ts.options }}</template> + + <MkFolder> + <template #label>{{ i18n.ts.specifyUser }}</template> + <template v-if="user" #suffix>@{{ user.username }}</template> + + <div style="text-align: center;" class="_gaps"> + <div v-if="user">@{{ user.username }}</div> + <div> + <MkButton v-if="user == null" primary rounded inline @click="selectUser">{{ i18n.ts.selectUser }}</MkButton> + <MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton> + </div> + </div> + </MkFolder> + </MkFolder> + <div> + <MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton> + </div> + </div> + + <MkFoldableSection v-if="notePagination"> + <template #header>{{ i18n.ts.searchResult }}</template> + <MkNotes :key="key" :pagination="notePagination"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted } from 'vue'; +import MkNotes from '@/components/MkNotes.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import MkInfo from '@/components/MkInfo.vue'; +import { useRouter } from '@/router'; +import MkFolder from '@/components/MkFolder.vue'; + +const router = useRouter(); + +let key = $ref(0); +let searchQuery = $ref(''); +let searchOrigin = $ref('combined'); +let notePagination = $ref(); +let user = $ref(null); + +function selectUser() { + os.selectUser().then(_user => { + user = _user; + }); +} + +async function search() { + const query = searchQuery.toString().trim(); + + if (query == null || query === '') return; + + if (query.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } + + return; + } + + notePagination = { + endpoint: 'notes/search', + limit: 10, + params: { + query: searchQuery, + userId: user ? user.id : null, + }, + }; + + key++; +} +</script> diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue new file mode 100644 index 0000000000..23a8978fd1 --- /dev/null +++ b/packages/frontend/src/pages/search.user.vue @@ -0,0 +1,77 @@ +<template> +<div class="_gaps"> + <div class="_gaps"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkRadios v-model="searchOrigin" @update:model-value="search()"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> + </div> + + <MkFoldableSection v-if="userPagination"> + <template #header>{{ i18n.ts.searchResult }}</template> + <MkUserList :key="key" :pagination="userPagination"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted } from 'vue'; +import MkUserList from '@/components/MkUserList.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import MkInfo from '@/components/MkInfo.vue'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +let key = $ref(''); +let searchQuery = $ref(''); +let searchOrigin = $ref('combined'); +let userPagination = $ref(); + +async function search() { + const query = searchQuery.toString().trim(); + + if (query == null || query === '') return; + + if (query.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } + + return; + } + + userPagination = { + endpoint: 'users/search', + limit: 10, + params: { + query: searchQuery, + origin: searchOrigin, + }, + }; + + key = query; +} +</script> diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 5523d5cf4d..9f3d8da560 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -1,133 +1,38 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer v-if="tab === 'note'" :content-max="800"> - <div v-if="notesSearchAvailable" class="_gaps"> - <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - </MkInput> - <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> - </div> - <MkFoldableSection v-if="notePagination"> - <template #header>{{ i18n.ts.searchResult }}</template> - <MkNotes :key="key" :pagination="notePagination"/> - </MkFoldableSection> + <MkSpacer v-if="tab === 'note'" :content-max="800"> + <div v-if="notesSearchAvailable"> + <XNote/> </div> <div v-else> <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'user'" :content-max="800"> - <div class="_gaps"> - <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search"> - <template #prefix><i class="ti ti-search"></i></template> - </MkInput> - <MkRadios v-model="searchOrigin" @update:model-value="search()"> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkRadios> - <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> - </div> - <MkFoldableSection v-if="userPagination"> - <template #header>{{ i18n.ts.searchResult }}</template> - <MkUserList :key="key" :pagination="userPagination"/> - </MkFoldableSection> - </div> + <MkSpacer v-else-if="tab === 'user'" :content-max="800"> + <XUser/> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed, onMounted } from 'vue'; -import MkNotes from '@/components/MkNotes.vue'; -import MkUserList from '@/components/MkUserList.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import MkButton from '@/components/MkButton.vue'; +import { computed, defineAsyncComponent, onMounted } from 'vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import * as os from '@/os'; -import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { $i } from '@/account'; import { instance } from '@/instance'; import MkInfo from '@/components/MkInfo.vue'; -import { useRouter } from '@/router'; - -const router = useRouter(); -const props = defineProps<{ - query: string; - channel?: string; - type?: string; - origin?: string; -}>(); +const XNote = defineAsyncComponent(() => import('./search.note.vue')); +const XUser = defineAsyncComponent(() => import('./search.user.vue')); -let key = $ref(''); let tab = $ref('note'); -let searchQuery = $ref(''); -let searchOrigin = $ref('combined'); -let notePagination = $ref(); -let userPagination = $ref(); const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes)); -onMounted(() => { - tab = props.type ?? 'note'; - searchQuery = props.query ?? ''; - searchOrigin = props.origin ?? 'combined'; -}); - -async function search() { - const query = searchQuery.toString().trim(); - - if (query == null || query === '') return; - - if (query.startsWith('https://')) { - const promise = os.api('ap/show', { - uri: query, - }); - - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - - const res = await promise; - - if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); - } - - return; - } - - if (tab === 'note') { - notePagination = { - endpoint: 'notes/search', - limit: 10, - params: { - query: searchQuery, - channelId: props.channel, - }, - }; - } else if (tab === 'user') { - userPagination = { - endpoint: 'users/search', - limit: 10, - params: { - query: searchQuery, - origin: searchOrigin, - }, - }; - } - - key = query; -} - const headerActions = $computed(() => []); const headerTabs = $computed(() => [{ @@ -141,7 +46,7 @@ const headerTabs = $computed(() => [{ }]); definePageMetadata(computed(() => ({ - title: searchQuery ? i18n.t('searchWith', { q: searchQuery }) : i18n.ts.search, + title: i18n.ts.search, icon: 'ti ti-search', }))); </script> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index c83c48d5ad..a1af0ba80b 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -24,6 +24,10 @@ {{ i18n.ts.noCrawle }} <template #caption>{{ i18n.ts.noCrawleDescription }}</template> </MkSwitch> + <MkSwitch v-model="preventAiLearning" @update:model-value="save()"> + {{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span> + <template #caption>{{ i18n.ts.preventAiLearningDescription }}</template> + </MkSwitch> <MkSwitch v-model="isExplorable" @update:model-value="save()"> {{ i18n.ts.makeExplorable }} <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> @@ -71,6 +75,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; let isLocked = $ref($i.isLocked); let autoAcceptFollowed = $ref($i.autoAcceptFollowed); let noCrawle = $ref($i.noCrawle); +let preventAiLearning = $ref($i.preventAiLearning); let isExplorable = $ref($i.isExplorable); let hideOnlineStatus = $ref($i.hideOnlineStatus); let publicReactions = $ref($i.publicReactions); @@ -86,6 +91,7 @@ function save() { isLocked: !!isLocked, autoAcceptFollowed: !!autoAcceptFollowed, noCrawle: !!noCrawle, + preventAiLearning: !!preventAiLearning, isExplorable: !!isExplorable, hideOnlineStatus: !!hideOnlineStatus, publicReactions: !!publicReactions, diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 2e9bc76059..23836b05f5 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -24,9 +24,9 @@ "@swc/jest": "0.2.26", "@types/jest": "29.5.1", "@types/node": "18.16.3", - "@typescript-eslint/eslint-plugin": "5.59.2", - "@typescript-eslint/parser": "5.59.2", - "eslint": "8.39.0", + "@typescript-eslint/eslint-plugin": "5.59.5", + "@typescript-eslint/parser": "5.59.5", + "eslint": "8.40.0", "jest": "29.5.0", "jest-fetch-mock": "3.0.3", "jest-websocket-mock": "2.4.0", diff --git a/packages/sw/package.json b/packages/sw/package.json index 75bb40ccb1..96c4bfe889 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -14,9 +14,9 @@ "misskey-js": "workspace:*" }, "devDependencies": { - "@typescript-eslint/parser": "5.59.2", + "@typescript-eslint/parser": "5.59.5", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67", - "eslint": "8.39.0", + "eslint": "8.40.0", "eslint-plugin-import": "2.27.5", "typescript": "5.0.4" } |