diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-05-07 02:46:42 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-07 02:46:42 +0000 |
| commit | 9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b (patch) | |
| tree | c41c3ee20b995c3a74a75d4005ab980d217a3727 /packages | |
| parent | Merge pull request #15842 from misskey-dev/develop (diff) | |
| parent | Release: 2025.5.0 (diff) | |
| download | misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.gz misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.tar.bz2 misskey-9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b.zip | |
Merge pull request #15933 from misskey-dev/develop
Release: 2025.5.0
Diffstat (limited to 'packages')
120 files changed, 1440 insertions, 815 deletions
diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index ae7b2baf49..d15a703ba2 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -1,4 +1,5 @@ import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; import sharedConfig from '../shared/eslint.config.js'; export default [ @@ -7,6 +8,13 @@ export default [ ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'], }, { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { files: ['**/*.ts', '**/*.tsx'], languageOptions: { parserOptions: { diff --git a/packages/backend/jest.js b/packages/backend/jest.js new file mode 100644 index 0000000000..0cb2c2ab77 --- /dev/null +++ b/packages/backend/jest.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import child_process from 'node:child_process'; +import path from 'node:path'; +import url from 'node:url'; + +import semver from 'semver'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const args = []; +args.push(...[ + ...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [], + '--experimental-vm-modules', + '--experimental-import-meta-resolve', + path.join(__dirname, 'node_modules/jest/bin/jest.js'), + ...process.argv.slice(2), +]); + +child_process.spawn(process.execPath, args, { stdio: 'inherit' }); diff --git a/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js b/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js new file mode 100644 index 0000000000..19983a72bd --- /dev/null +++ b/packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class DeliverSuspendedSoftware1743403874305 { + name = 'DeliverSuspendedSoftware1743403874305' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "deliverSuspendedSoftware" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deliverSuspendedSoftware"`); + } +} diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js index 49e835d38c..12108a6b3c 100644 --- a/packages/backend/migration/1745378064470-composite-note-index.js +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -3,11 +3,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js"; + export class CompositeNoteIndex1745378064470 { name = 'CompositeNoteIndex1745378064470'; + transaction = isConcurrentIndexMigrationEnabled() ? false : undefined; async up(queryRunner) { - await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + const concurrently = isConcurrentIndexMigrationEnabled(); + + if (concurrently) { + const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`); + if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); + await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + } + } else { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + } + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`); // Flush all cached Linear Scan Plans and redo statistics for composite index // this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly @@ -15,7 +29,8 @@ export class CompositeNoteIndex1745378064470 { } async down(queryRunner) { + const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : ''; await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); - await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); + await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); } } diff --git a/packages/backend/migration/js/migration-config.js b/packages/backend/migration/js/migration-config.js new file mode 100644 index 0000000000..8cfbb21470 --- /dev/null +++ b/packages/backend/migration/js/migration-config.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function isConcurrentIndexMigrationEnabled() { + return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; +} diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js index 229e5bf1fe..f979c36ad7 100644 --- a/packages/backend/ormconfig.js +++ b/packages/backend/ormconfig.js @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { loadConfig } from './built/config.js'; import { entities } from './built/postgres.js'; +import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js"; const config = loadConfig(); @@ -14,4 +15,5 @@ export default new DataSource({ extra: config.db.extra, entities: entities, migrations: ['migration/*.js'], + migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all', }); diff --git a/packages/backend/package.json b/packages/backend/package.json index c4de44df18..36f7781908 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,12 +22,12 @@ "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", - "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", - "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", - "jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs", - "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", - "jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs", - "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", + "jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", + "jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs", + "jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs", + "jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs", + "jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs", + "jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache", "test": "pnpm jest", "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test:fed": "pnpm jest:fed", @@ -78,7 +78,7 @@ "@fastify/multipart": "9.0.3", "@fastify/static": "8.1.1", "@fastify/view": "10.0.2", - "@misskey-dev/sharp-read-bmp": "1.3.0", + "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.1", "@napi-rs/canvas": "0.1.69", "@nestjs/common": "11.1.0", @@ -168,7 +168,8 @@ "rxjs": "7.8.2", "sanitize-html": "2.16.0", "secure-json-parse": "3.0.2", - "sharp": "0.34.1", + "sharp": "0.33.5", + "semver": "7.7.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 5544eeeddd..435bd8dd45 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -24,8 +24,13 @@ const $config: Provider = { const $db: Provider = { provide: DI.db, useFactory: async (config) => { - const db = createPostgresDataSource(config); - return await db.initialize(); + try { + const db = createPostgresDataSource(config); + return await db.initialize(); + } catch (e) { + console.log(e); + throw e; + } }, inject: [DI.config], }; diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 4fc1193f32..8d2de89efd 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { NotificationService } from '@/core/NotificationService.js'; - -export const ACHIEVEMENT_TYPES = [ - 'notes1', - 'notes10', - 'notes100', - 'notes500', - 'notes1000', - 'notes5000', - 'notes10000', - 'notes20000', - 'notes30000', - 'notes40000', - 'notes50000', - 'notes60000', - 'notes70000', - 'notes80000', - 'notes90000', - 'notes100000', - 'login3', - 'login7', - 'login15', - 'login30', - 'login60', - 'login100', - 'login200', - 'login300', - 'login400', - 'login500', - 'login600', - 'login700', - 'login800', - 'login900', - 'login1000', - 'passedSinceAccountCreated1', - 'passedSinceAccountCreated2', - 'passedSinceAccountCreated3', - 'loggedInOnBirthday', - 'loggedInOnNewYearsDay', - 'noteClipped1', - 'noteFavorited1', - 'myNoteFavorited1', - 'profileFilled', - 'markedAsCat', - 'following1', - 'following10', - 'following50', - 'following100', - 'following300', - 'followers1', - 'followers10', - 'followers50', - 'followers100', - 'followers300', - 'followers500', - 'followers1000', - 'collectAchievements30', - 'viewAchievements3min', - 'iLoveMisskey', - 'foundTreasure', - 'client30min', - 'client60min', - 'noteDeletedWithin1min', - 'postedAtLateNight', - 'postedAt0min0sec', - 'selfQuote', - 'htl20npm', - 'viewInstanceChart', - 'outputHelloWorldOnScratchpad', - 'open3windows', - 'driveFolderCircularReference', - 'reactWithoutRead', - 'clickedClickHere', - 'justPlainLucky', - 'setNameToSyuilo', - 'cookieClicked', - 'brainDiver', - 'smashTestNotificationButton', - 'tutorialCompleted', - 'bubbleGameExplodingHead', - 'bubbleGameDoubleExplodingHead', -] as const; +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; @Injectable() export class AchievementService { diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 1ffeb4b3a4..6253f792ed 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -36,6 +36,7 @@ type TimelineOptions = { excludeNoFiles?: boolean; excludeReplies?: boolean; excludePureRenotes: boolean; + ignoreAuthorFromUserSuspension?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>, }; @@ -139,6 +140,23 @@ export class FanoutTimelineEndpointService { }; } + { + const parentFilter = filter; + filter = (note) => { + const noteJoined = note as MiNote & { + renoteUser: MiUser | null; + replyUser: MiUser | null; + }; + if (!ps.ignoreAuthorFromUserSuspension) { + if (note.user!.isSuspended) return false; + } + if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false; + if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false; + + return parentFilter(note); + }; + } + const redisTimeline: MiNote[] = []; let readFromRedis = 0; let lastSuccessfulRate = 1; // rateをキャッシュする? diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 119eb49c02..b9cef5b0ec 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -43,29 +43,36 @@ export class QueryService { ) { } - public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> { + public makePaginationQuery<T extends ObjectLiteral>( + q: SelectQueryBuilder<T>, + sinceId?: string | null, + untilId?: string | null, + sinceDate?: number | null, + untilDate?: number | null, + targetColumn = 'id', + ): SelectQueryBuilder<T> { if (sinceId && untilId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.orderBy(`${q.alias}.id`, 'ASC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'ASC'); } else if (untilId) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceDate && untilDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else if (sinceDate) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); - q.orderBy(`${q.alias}.id`, 'ASC'); + q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'ASC'); } else if (untilDate) { - q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) }); - q.orderBy(`${q.alias}.id`, 'DESC'); + q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) }); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } else { - q.orderBy(`${q.alias}.id`, 'DESC'); + q.orderBy(`${q.alias}.${targetColumn}`, 'DESC'); } return q; } @@ -287,4 +294,26 @@ export class QueryService { .andWhere(instanceSuspension('renoteUser')); } } + + // Requirements: user replyUser renoteUser must be joined + @bindThis + public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void { + if (excludeAuthor) { + const brakets = (user: string) => new Brackets(qb => qb + .where(`note.${user}Id IS NULL`) + .orWhere(`user.id = ${user}.id`) + .orWhere(`${user}.isSuspended = FALSE`)); + q + .andWhere(brakets('replyUser')) + .andWhere(brakets('renoteUser')); + } else { + const brakets = (user: string) => new Brackets(qb => qb + .where(`note.${user}Id IS NULL`) + .orWhere(`${user}.isSuspended = FALSE`)); + q + .andWhere('user.isSuspended = FALSE') + .andWhere(brakets('replyUser')) + .andWhere(brakets('renoteUser')); + } + } } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index d94281920e..20a776ded8 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -235,6 +235,7 @@ export class SearchService { this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); @@ -297,11 +298,17 @@ export class SearchService { ]) : [new Set<string>(), new Set<string>()]; - const query = this.notesRepository.createQueryBuilder('note'); + const query = this.notesRepository.createQueryBuilder('note') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) }); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 23fb928ac9..67ec6cc7b0 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -6,10 +6,12 @@ import { URL, domainToASCII } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import RE2 from 're2'; +import semver from 'semver'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MiMeta } from '@/models/Meta.js'; +import { MiMeta, SoftwareSuspension } from '@/models/Meta.js'; +import { MiInstance } from '@/models/Instance.js'; @Injectable() export class UtilityService { @@ -143,4 +145,20 @@ export class UtilityService { const host = this.extractDbHost(uri); return this.isFederationAllowedHost(host); } + + @bindThis + public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined { + if (software.softwareName == null) return undefined; + if (software.softwareVersion == null) { + // software version is null; suspend iff versionRange is * + return this.meta.deliverSuspendedSoftware.find(x => + x.software === software.softwareName + && x.versionRange.trim() === '*'); + } else { + const softwareVersion = software.softwareVersion; + return this.meta.deliverSuspendedSoftware.find(x => + x.software === software.softwareName + && semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true })); + } + } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 284537b986..3688cfb363 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -31,6 +31,7 @@ export class InstanceEntityService { me?: { id: MiUser['id']; } | null | undefined, ): Promise<Packed<'FederationInstance'>> { const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; + const softwareSuspended = this.utilityService.isDeliverSuspendedSoftware(instance); return { id: instance.id, @@ -41,8 +42,8 @@ export class InstanceEntityService { followingCount: instance.followingCount, followersCount: instance.followersCount, isNotResponding: instance.isNotResponding, - isSuspended: instance.suspensionState !== 'none', - suspensionState: instance.suspensionState, + isSuspended: instance.suspensionState !== 'none' || Boolean(softwareSuspended), + suspensionState: instance.suspensionState === 'none' && softwareSuspended ? 'softwareSuspended' : instance.suspensionState, isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 27aa3d89de..e4eb10efca 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; +import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -78,6 +79,8 @@ export const refs = { User: packedUserSchema, UserList: packedUserListSchema, + Achievement: packedAchievementSchema, + AchievementName: packedAchievementNameSchema, Ad: packedAdSchema, Announcement: packedAnnouncementSchema, App: packedAppSchema, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 1fbf5371bc..46f3b2e3c0 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -664,4 +664,14 @@ export class MiMeta { nullable: true, }) public googleAnalyticsMeasurementId: string | null; + + @Column('jsonb', { + default: [], + }) + public deliverSuspendedSoftware: SoftwareSuspension[]; } + +export type SoftwareSuspension = { + software: string, + versionRange: string, +}; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index c5ca2b5776..3dcbdb735b 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -10,6 +10,16 @@ import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; +// Note: When you create a new index for existing column of this table, +// it might be better to index concurrently under isConcurrentIndexMigrationEnabled flag +// by editing generated migration file since this table is very large, +// and it will make a long lock to create index in most cases. +// Please note that `CREATE INDEX CONCURRENTLY` is not supported in transaction, +// so you need to set `transaction = false` in migration if isConcurrentIndexMigrationEnabled() is true. +// Please refer 1745378064470-composite-note-index.js for example. +// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail +// because it will always run CREATE INDEX in transaction based on decorators. +// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production, @Index(['userId', 'id']) @Entity('note') export class MiNote { @@ -229,7 +239,6 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; - //#endregion constructor(data: Partial<MiNote>) { if (data == null) return; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 5544555296..c4c1fa5ec9 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -274,7 +274,7 @@ export class MiUserProfile { default: [], }) public achievements: { - name: string; + name: typeof ACHIEVEMENT_TYPES[number]; unlockedAt: number; }[]; @@ -295,3 +295,84 @@ export class MiUserProfile { } } } + +export const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'myNoteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'foundTreasure', + 'client30min', + 'client60min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'viewInstanceChart', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', + 'smashTestNotificationButton', + 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', +] as const; diff --git a/packages/backend/src/models/json-schema/achievement.ts b/packages/backend/src/models/json-schema/achievement.ts new file mode 100644 index 0000000000..39a621a570 --- /dev/null +++ b/packages/backend/src/models/json-schema/achievement.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; + +export const packedAchievementNameSchema = { + type: 'string', + enum: ACHIEVEMENT_TYPES, + optional: false, +} as const; + +export const packedAchievementSchema = { + type: 'object', + properties: { + name: { + ref: 'AchievementName', + }, + unlockedAt: { + type: 'number', + optional: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 912a0399d8..85f84952f1 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -48,7 +48,7 @@ export const packedFederationInstanceSchema = { suspensionState: { type: 'string', nullable: false, optional: false, - enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'], + enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding', 'softwareSuspended'], }, isBlocked: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 7f23d2d6a1..6de120c8d7 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; import { notificationTypes, userExportableEntities } from '@/types.js'; const baseSchema = { @@ -312,9 +311,7 @@ export const packedNotificationSchema = { enum: ['achievementEarned'], }, achievement: { - type: 'string', - optional: false, nullable: false, - enum: ACHIEVEMENT_TYPES, + ref: 'AchievementName', }, }, }, { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e475296702..2b5f706ff9 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -630,18 +630,7 @@ export const packedMeDetailedOnlySchema = { type: 'array', nullable: false, optional: false, items: { - type: 'object', - nullable: false, optional: false, - properties: { - name: { - type: 'string', - nullable: false, optional: false, - }, - unlockedAt: { - type: 'number', - nullable: false, optional: false, - }, - }, + ref: 'Achievement', }, }, loggedInDays: { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5a16496011..391ccdac05 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -71,6 +71,15 @@ export class DeliverProcessorService { return 'skip (suspended)'; } + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(host) + : this.federatedInstanceService.fetch(host)); + + // suspend server by software + if (i != null && this.utilityService.isDeliverSuspendedSoftware(i)) { + return 'skip (software suspended)'; + } + try { await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); @@ -79,10 +88,6 @@ export class DeliverProcessorService { // Update instance stats process.nextTick(async () => { - const i = await (this.meta.enableStatsForFederatedInstances - ? this.federatedInstanceService.fetchOrRegister(host) - : this.federatedInstanceService.fetch(host)); - if (i == null) return; if (i.isNotResponding) { diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 7decdd2c10..c859f1d82c 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -7,7 +7,7 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; @@ -73,7 +73,7 @@ export class ServerService implements OnApplicationShutdown { } @bindThis - public async launch() { + public async launch(): Promise<void> { const fastify = Fastify({ trustProxy: true, logger: false, @@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown { reply.header('content-type', 'text/plain; charset=utf-8'); reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); done(null, [ - 'Refusing to relay remote ActivityPub object lookup.', - '', + "Refusing to relay remote ActivityPub object lookup.", + "", `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, ].join('\n')); }); @@ -301,7 +301,6 @@ export class ServerService implements OnApplicationShutdown { } await fastify.ready(); - return fastify; } @bindThis @@ -310,6 +309,13 @@ export class ServerService implements OnApplicationShutdown { await this.#fastify.close(); } + /** + * Get the Fastify instance for testing. + */ + public get fastify(): FastifyInstance { + return this.#fastify; + } + @bindThis async onApplicationShutdown(signal: string): Promise<void> { await this.dispose(); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 960c7b5476..a42fdaf730 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -6,11 +6,8 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; -import { Transform } from 'node:stream'; -import { type MultipartFile } from '@fastify/multipart'; import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; -import { AttachmentFile } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -19,7 +16,7 @@ import type Logger from '@/logger.js'; import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; -import { type RolePolicies, RoleService } from '@/core/RoleService.js'; +import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -203,6 +200,18 @@ export class ApiCallService implements OnApplicationShutdown { return; } + const [path, cleanup] = await createTemp(); + await stream.pipeline(multipartData.file, fs.createWriteStream(path)); + + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + cleanup(); + reply.code(413); + reply.send(); + return; + } + const fields = {} as Record<string, unknown>; for (const [k, v] of Object.entries(multipartData.fields)) { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; @@ -217,7 +226,10 @@ export class ApiCallService implements OnApplicationShutdown { return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, multipartData, request).then((res) => { + this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { this.#sendApiError(reply, err); @@ -282,7 +294,10 @@ export class ApiCallService implements OnApplicationShutdown { user: MiLocalUser | null | undefined, token: MiAccessToken | null | undefined, data: any, - multipartFile: MultipartFile | null, + file: { + name: string; + path: string; + } | null, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, ) { const isSecure = user != null && token == null; @@ -356,37 +371,6 @@ export class ApiCallService implements OnApplicationShutdown { } } - // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } - } - - if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) - || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - kind: 'permission', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { @@ -420,89 +404,47 @@ export class ApiCallService implements OnApplicationShutdown { } } - let attachmentFile: AttachmentFile | null = null; - let cleanup = () => {}; - if (ep.meta.requireFile && request.method === 'POST' && multipartFile) { - const policies = await this.roleService.getUserPolicies(user!.id); - const result = await this.handleAttachmentFile( - Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize), - multipartFile, - ); - attachmentFile = result.attachmentFile; - cleanup = result.cleanup; + if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) + || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } } // API invoking if (this.config.sentryForBackend) { return await Sentry.startSpan({ name: 'API: ' + ep.name, - }, () => { - return ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); - }); + }, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } else { - return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); - } - } - - @bindThis - private async handleAttachmentFile( - fileSizeLimit: number, - multipartFile: MultipartFile, - ) { - function createTooLongError() { - return new ApiError({ - httpStatusCode: 413, - kind: 'client', - message: 'File size is too large.', - code: 'FILE_SIZE_TOO_LARGE', - id: 'ff827ce8-9b4b-4808-8511-422222a3362f', - }); - } - - function createLimitStream(limit: number) { - let total = 0; - - return new Transform({ - transform(chunk, _, callback) { - total += chunk.length; - if (total > limit) { - callback(createTooLongError()); - } else { - callback(null, chunk); - } - }, - }); + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } - - const [path, cleanup] = await createTemp(); - try { - await stream.pipeline( - multipartFile.file, - createLimitStream(fileSizeLimit), - fs.createWriteStream(path), - ); - - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartFile.file.truncated) { - throw createTooLongError(); - } - } catch (err) { - cleanup(); - throw err; - } - - return { - attachmentFile: { - name: multipartFile.filename, - path, - }, - cleanup, - }; } @bindThis diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index b063487305..e061aa3a8e 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export type Response = Record<string, any> | void; -export type AttachmentFile = { +type File = { name: string | null; path: string; }; // TODO: paramsの型をT['params']のスキーマ定義から推論する type Executor<T extends IEndpointMeta, Ps extends Schema> = - (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => - Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; + (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => + Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> { - public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; + public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) { const validate = ajv.compile(paramDef); - this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => { + this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => { let cleanup: undefined | (() => void) = undefined; if (meta.requireFile) { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 53e2b2b237..4a106e7175 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -528,6 +528,24 @@ export const meta = { optional: false, nullable: false, }, }, + deliverSuspendedSoftware: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + software: { + type: 'string', + optional: false, nullable: false, + }, + versionRange: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, }, }, } as const; @@ -672,6 +690,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, federation: instance.federation, federationHosts: instance.federationHosts, + deliverSuspendedSoftware: instance.deliverSuspendedSoftware, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index bc05587668..31eeaa5e38 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -185,6 +185,17 @@ export const paramDef = { type: 'string', }, }, + deliverSuspendedSoftware: { + type: 'array', + items: { + type: 'object', + properties: { + software: { type: 'string' }, + versionRange: { type: 'string' }, + }, + required: ['software', 'versionRange'], + }, + }, }, required: [], } as const; @@ -671,6 +682,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.federation = ps.federation; } + if (ps.deliverSuspendedSoftware !== undefined) { + set.deliverSuspendedSoftware = ps.deliverSuspendedSoftware; + } + if (Array.isArray(ps.federationHosts)) { set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 4708dab73c..f37cdc6658 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -112,6 +112,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index d2f36f251e..294b5e4bc4 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -48,7 +48,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId) + const query = this.queryService + .makePaginationQuery( + this.channelFollowingsRepository.createQueryBuilder(), + ps.sinceId, + ps.untilId, + null, + null, + 'followeeId', + ) .andWhere({ followerId: me.id }); const followings = await query diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 620cdb0f5d..2401ab8208 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -122,6 +122,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 2b65407cea..33f32d1d8a 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -85,9 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + // this.queryService.generateSuspendedUserQueryForNote(query); // To avoid problems with removing notes, ignoring suspended user for now if (me) { - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); } diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 17face8f82..11c255a361 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -61,6 +61,7 @@ export const meta = { message: 'Cannot upload the file because it exceeds the maximum file size.', code: 'MAX_FILE_SIZE_EXCEEDED', id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a', + httpStatusCode: 413, }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index e70905ef1b..0e42647ef7 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -5,7 +5,8 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; +import { AchievementService } from '@/core/AchievementService.js'; +import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 218a3c1a4c..712a86eb13 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -71,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index e7aba2d306..a57c84d432 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -97,6 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 39b519a599..6a3ee817e4 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -244,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 8b2d5397b2..d1dc22f233 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -157,6 +157,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index f5cddd5bad..c3722b1b5a 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -73,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 178e311ed1..ce2435b8eb 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -73,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index d9aaed2f10..f491cc38ab 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -57,6 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 079231d432..d0781bd8dd 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -82,6 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 42752eaeec..e6d6a1b629 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -200,6 +200,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 58a4223207..ec7c4b0f97 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -185,6 +185,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index b0d3f6d2f9..16b0783a01 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -103,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts index f7139b3684..bae216e347 100644 --- a/packages/backend/src/server/api/endpoints/users/achievements.ts +++ b/packages/backend/src/server/api/endpoints/users/achievements.ts @@ -14,15 +14,7 @@ export const meta = { res: { type: 'array', items: { - type: 'object', - properties: { - name: { - type: 'string', - }, - unlockedAt: { - type: 'number', - }, - }, + ref: 'Achievement', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts index 053fd60548..90bd11bc25 100644 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts @@ -88,6 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const notes = (await query.getMany()).filter(note => { if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index b0585f75fc..0c64df569d 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -130,6 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- useDbFallback: true, ignoreAuthorFromMute: true, ignoreAuthorFromInstanceBlock: true, + ignoreAuthorFromUserSuspension: true, excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, @@ -186,6 +187,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query, true); + this.queryService.generateSuspendedUserQueryForNote(query, true); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); this.queryService.generateBlockedUserQueryForNotes(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index bb9000a7a0..d6f1ecd8ed 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -99,10 +99,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) - .leftJoinAndSelect('reaction.note', 'note'); + .leftJoinAndSelect('reaction.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSuspendedUserQueryForNote(query); const reactions = (await query .limit(ps.limit) diff --git a/packages/backend/test-federation/README.md b/packages/backend/test-federation/README.md index 967d51f085..4ea88c1b80 100644 --- a/packages/backend/test-federation/README.md +++ b/packages/backend/test-federation/README.md @@ -10,15 +10,15 @@ cd packages/backend/test-federation First, you need to start servers by executing following commands: ```sh bash ./setup.sh -docker compose up --scale tester=0 +NODE_VERSION=22 docker compose up --scale tester=0 ``` Then you can run all tests by a following command: ```sh -docker compose run --no-deps --rm tester +NODE_VERSION=22 docker compose run --no-deps --rm tester ``` For testing a specific file, run a following command: ```sh -docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts +NODE_VERSION=22 docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts ``` diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index a7e907c3ee..e4483acd7a 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -12,7 +12,7 @@ services: retries: 20 misskey: - image: node:20 + image: node:${NODE_VERSION} env_file: - ./.config/docker.env environment: diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 4df4ced365..bd0ac15a31 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -16,7 +16,7 @@ services: " tester: - image: node:20 + image: node:${NODE_VERSION} depends_on: a.test: condition: service_healthy @@ -51,6 +51,10 @@ services: target: /misskey/packages/backend/jest.config.fed.cjs read_only: true - type: bind + source: ../jest.js + target: /misskey/packages/backend/jest.js + read_only: true + - type: bind source: ../../misskey-js/built target: /misskey/packages/misskey-js/built read_only: true @@ -85,7 +89,7 @@ services: command: pnpm -F backend test:fed daemon: - image: node:20 + image: node:${NODE_VERSION} depends_on: redis.test: condition: service_healthy diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index f9e65aaa84..49c6a0636b 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -159,8 +159,8 @@ describe('API', () => { user: { token: application3 }, }, { status: 403, - code: 'PERMISSION_DENIED', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + code: 'ROLE_PERMISSION_DENIED', + id: 'c3d38592-54c0-429d-be96-5636b0431a61', }); await failedApiCall({ diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index 7ae1ee4523..570cc61c4b 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -909,7 +909,7 @@ describe('クリップ', () => { assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]); }); - test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { + test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートは含まれない)', async () => { const publicClip = await create({ isPublic: true }); await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); @@ -919,8 +919,6 @@ describe('クリップ', () => { const res = await notes({ clipId: publicClip.id }, { user: undefined }); const expects = [ aliceNote, aliceHomeNote, - // 認証なしだと非公開ノートは結果には含むけどhideされる。 - hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), ]; assert.deepStrictEqual( res.sort(compareBy(s => s.id)).map(x => x.id), diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ce3f931bb0..ca6a639be8 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -232,7 +232,7 @@ describe('UserEntityService', () => { }); test('MeDetailed', async() => { - const achievements = [{ name: 'achievement', unlockedAt: new Date().getTime() }]; + const achievements = [{ name: 'iLoveMisskey' as const, unlockedAt: new Date().getTime() }]; const me = await createUser({}, { birthday: '2000-01-01', achievements: achievements, diff --git a/packages/backend/test/unit/server/api/drive/files/create.ts b/packages/backend/test/unit/server/api/drive/files/create.ts index b98892fa03..9b38f4d744 100644 --- a/packages/backend/test/unit/server/api/drive/files/create.ts +++ b/packages/backend/test/unit/server/api/drive/files/create.ts @@ -3,29 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { S3Client } from '@aws-sdk/client-s3'; import { Test, TestingModule } from '@nestjs/testing'; -import { mockClient } from 'aws-sdk-client-mock'; import { FastifyInstance } from 'fastify'; import request from 'supertest'; +import { randomString } from '../../../../../utils.js'; import { CoreModule } from '@/core/CoreModule.js'; import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { MiUser } from '@/models/User.js'; import { ServerModule } from '@/server/ServerModule.js'; import { ServerService } from '@/server/ServerService.js'; +import { IdService } from '@/core/IdService.js'; describe('/drive/files/create', () => { let module: TestingModule; let server: FastifyInstance; - const s3Mock = mockClient(S3Client); let roleService: RoleService; + let idService: IdService; let root: MiUser; let role_tinyAttachment: MiRole; + let folder: MiDriveFolder; + beforeAll(async () => { module = await Test.createTestingModule({ imports: [GlobalModule, CoreModule, ServerModule], @@ -33,21 +35,34 @@ describe('/drive/files/create', () => { module.enableShutdownHooks(); const serverService = module.get<ServerService>(ServerService); - server = await serverService.launch(); + await serverService.launch(); + server = serverService.fastify; + + idService = module.get(IdService); const usersRepository = module.get<UsersRepository>(DI.usersRepository); + await usersRepository.delete({}); root = await usersRepository.insert({ - id: 'root', + id: idService.gen(), username: 'root', usernameLower: 'root', token: '1234567890123456', }).then(x => usersRepository.findOneByOrFail(x.identifiers[0])); const userProfilesRepository = module.get<UserProfilesRepository>(DI.userProfilesRepository); + await userProfilesRepository.delete({}); await userProfilesRepository.insert({ userId: root.id, }); + const driveFoldersRepository = module.get<DriveFoldersRepository>(DI.driveFoldersRepository); + folder = await driveFoldersRepository.insertOne({ + id: idService.gen(), + name: 'root-folder', + parentId: null, + userId: root.id, + }); + roleService = module.get<RoleService>(RoleService); role_tinyAttachment = await roleService.create({ name: 'test-role001', @@ -65,8 +80,8 @@ describe('/drive/files/create', () => { }); beforeEach(async () => { - s3Mock.reset(); - await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {}); + await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => { + }); }); afterAll(async () => { @@ -74,35 +89,76 @@ describe('/drive/files/create', () => { await module.close(); }); - test('200 ok', async () => { - const result = await request(server.server) + async function postFile(props: { + name: string, + comment: string, + isSensitive: boolean, + force: boolean, + fileContent: Buffer | string, + }) { + const { name, comment, isSensitive, force, fileContent } = props; + + return await request(server.server) .post('/api/drive/files/create') .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(1024 * 1024))); + .attach('file', fileContent) + .field('name', name) + .field('comment', comment) + .field('isSensitive', isSensitive) + .field('force', force) + .field('folderId', folder.id) + .field('i', root.token ?? ''); + } + + test('200 ok', async () => { + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(1000 * 1000)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('200 ok(with role)', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(10))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(10)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('413 too large', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(11))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(11)), + }); expect(result.statusCode).toBe(413); - expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE'); + expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED'); }); }); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 6a2d6afb38..19193e20fd 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -34,7 +34,7 @@ "tsconfig-paths": "4.2.0", "typescript": "5.8.3", "uuid": "11.1.0", - "vite": "6.3.3", + "vite": "6.3.4", "vue": "3.5.13" }, "devDependencies": { diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue index 061254a39a..68897ca7e1 100644 --- a/packages/frontend-embed/src/pages/not-found.vue +++ b/packages/frontend-embed/src/pages/not-found.vue @@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div class="_fullinfo"> - <img :src="notFoundImageUrl" draggable="false"/> <div>{{ i18n.ts.notFoundDescription }}</div> </div> </div> @@ -14,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject, computed } from 'vue'; -import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js'; import { DI } from '@/di.js'; import { i18n } from '@/i18n.js'; const serverMetadata = inject(DI.serverMetadata)!; - -const notFoundImageUrl = computed(() => serverMetadata.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL); </script> diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss index b67f929933..035d687ee4 100644 --- a/packages/frontend-embed/src/style.scss +++ b/packages/frontend-embed/src/style.scss @@ -286,13 +286,6 @@ rt { ._fullinfo { padding: 64px 32px; text-align: center; - - > img { - vertical-align: bottom; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; - } } ._link { diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 84b5afe78f..8c49b41f4d 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -112,10 +112,6 @@ export const ROLE_POLICIES = [ 'chatAvailability', ] as const; -export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg'; -export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg'; -export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg'; - export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = { tada: ['speed=', 'delay='], diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 02f050467f..ad2a72f7fd 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -74,7 +74,7 @@ "typescript": "5.8.3", "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.3.3", + "vite": "6.3.4", "vue": "3.5.13", "vuedraggable": "next", "wanakana": "5.3.1" diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 6e5b29654b..81c92bfb5c 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick"> +<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick"> <div ref="rootEl" :class="$style.root"> <div :class="$style.header"> <span :class="$style.icon"> @@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.title">{{ announcement.title }}</span> </div> <div :class="$style.text"><Mfm :text="announcement.text"/></div> - <MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton> + <div ref="bottomEl"></div> + <div :class="$style.footer"> + <MkButton + primary + full + :disabled="!hasReachedBottom" + @click="ok" + >{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton> + </div> </div> </MkModal> </template> <script lang="ts" setup> -import { onMounted, useTemplateRef } from 'vue'; +import { onMounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -32,12 +40,12 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; import { updateCurrentAccountPartial } from '@/accounts.js'; -const props = withDefaults(defineProps<{ +const props = defineProps<{ announcement: Misskey.entities.Announcement; -}>(), { -}); +}>(); const rootEl = useTemplateRef('rootEl'); +const bottomEl = useTemplateRef('bottomEl'); const modal = useTemplateRef('modal'); async function ok() { @@ -72,7 +80,34 @@ function onBgClick() { }); } +const hasReachedBottom = ref(false); + onMounted(() => { + if (bottomEl.value && rootEl.value) { + const bottomElRect = bottomEl.value.getBoundingClientRect(); + const rootElRect = rootEl.value.getBoundingClientRect(); + if ( + bottomElRect.top >= rootElRect.top && + bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分) + ) { + hasReachedBottom.value = true; + return; + } + + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + hasReachedBottom.value = true; + observer.disconnect(); + } + } + }, { + root: rootEl.value, + rootMargin: '0px 0px -75px 0px', + }); + + observer.observe(bottomEl.value); + } }); </script> @@ -80,9 +115,12 @@ onMounted(() => { .root { margin: auto; position: relative; - padding: 32px; + padding: 32px 32px 0; min-width: 320px; max-width: 480px; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; box-sizing: border-box; background: var(--MI_THEME-panel); border-radius: var(--MI-radius); @@ -103,4 +141,14 @@ onMounted(() => { .text { margin: 1em 0; } + +.footer { + position: sticky; + bottom: 0; + left: -32px; + backdrop-filter: var(--MI-blur, blur(15px)); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); + margin: 0 -32px; + padding: 24px 32px; +} </style> diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index fdb7d2a1c4..d0b50f04f2 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.notFound }}</div> - </div> - </template> + <template #empty><MkResult type="empty"/></template> <template #default="{ items }"> <MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/> @@ -23,7 +18,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue index c508ea8451..b33ed428c7 100644 --- a/packages/frontend/src/components/MkChatHistories.vue +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkA> </div> -<div v-if="!initializing && history.length == 0" class="_fullinfo"> - <div>{{ i18n.ts._chat.noHistory }}</div> -</div> +<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/> <MkLoading v-if="initializing"/> </template> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 81d508c161..3f7519a43f 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="!input && !select" - :class="[$style.icon, { - [$style.type_success]: type === 'success', - [$style.type_error]: type === 'error', - [$style.type_warning]: type === 'warning', - [$style.type_info]: type === 'info', - }]" + :class="[$style.icon]" > - <i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i> - <i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i> - <i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i> - <i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i> - <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> + <MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/> + <MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/> + <MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/> + <MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/> + <MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> @@ -202,22 +197,6 @@ function onInputKeydown(evt: KeyboardEvent) { margin: 0 auto; } -.type_info { - color: #55c4dd; -} - -.type_success { - color: var(--MI_THEME-success); -} - -.type_error { - color: var(--MI_THEME-error); -} - -.type_warning { - color: var(--MI_THEME-warn); -} - .title { margin: 0 0 8px 0; font-weight: bold; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 1236b843f2..e86861c874 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -31,6 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" + @enter="enter" + @afterEnter="afterEnter" + @leave="leave" + @afterLeave="afterLeave" > <KeepAlive> <div v-show="opened"> @@ -86,6 +90,42 @@ const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); +//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す) +function enter(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + const elementHeight = el.getBoundingClientRect().height; + el.style.height = '0'; + el.offsetHeight; // reflow + el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`; +} + +function afterEnter(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + el.style.height = ''; +} + +function leave(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + const elementHeight = el.getBoundingClientRect().height; + el.style.height = `${elementHeight}px`; + el.offsetHeight; // reflow + el.style.height = '0'; +} + +function afterLeave(el: Element) { + if (CSS.supports('interpolate-size', 'allow-keywords')) return; + if (!(el instanceof HTMLElement)) return; + + el.style.height = ''; +} +//#endregion + function toggle() { if (!opened.value) { openedAtLeastOnce.value = true; @@ -108,17 +148,27 @@ onMounted(() => { .transition_toggle_enterActive, .transition_toggle_leaveActive { overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない - transition: opacity 0.3s, height 0.3s !important; + transition: opacity 0.3s, height 0.3s; } + +@supports (interpolate-size: allow-keywords) { + .transition_toggle_enterFrom, + .transition_toggle_leaveTo { + height: 0; + } + + .root { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + } +} + .transition_toggle_enterFrom, .transition_toggle_leaveTo { opacity: 0; - height: 0; } .root { display: block; - interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 } .header { diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 0884cdc016..6ac4441cac 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> </template> </div> - <div v-else class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else type="empty"/> </div> </MkModalWindow> </template> @@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue'; import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ title: string; diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index eba8a73aec..380fb7b2d8 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import type { CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; +import type { CSSProperties } from 'vue'; import { instance as localInstance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; @@ -61,19 +61,9 @@ $height: 2ex; border-radius: 4px 0 0 4px; overflow: clip; color: #fff; - text-shadow: /* .866 ≈ sin(60deg) */ - 1px 0 1px #000, - .866px .5px 1px #000, - .5px .866px 1px #000, - 0 1px 1px #000, - -.5px .866px 1px #000, - -.866px .5px 1px #000, - -1px 0 1px #000, - -.866px -.5px 1px #000, - -.5px -.866px 1px #000, - 0 -1px 1px #000, - .5px -.866px 1px #000, - .866px -.5px 1px #000; + + // text-shadowは重いから使うな + mask-image: linear-gradient(90deg, rgb(0,0,0), rgb(0,0,0) calc(100% - 16px), diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 9d862a4eac..509099e0b9 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> @@ -34,7 +29,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 9672efca0a..21104b41df 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> - <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> <MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/> <img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> <div @@ -176,7 +175,6 @@ import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { ensureSignin } from '@/i.js'; -import { infoImageUrl } from '@/instance.js'; const $i = ensureSignin(); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index b8fada1020..3c88b8af0d 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh :refresher="() => reload()"> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> <MkPagination ref="pagingComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotifications }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template> <template #default="{ items: notifications }"> <component @@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> @@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue'; import MkNote from '@/components/MkNote.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { prefer } from '@/preferences.js'; @@ -103,18 +97,38 @@ defineExpose({ </script> <style lang="scss" module> -.transition_x_move, -.transition_x_enterActive, +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.item, + .item { + /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + .transition_x_leaveActive { - transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); } -.transition_x_enterFrom, -.transition_x_leaveTo { + +.transition_x_enterFrom { opacity: 0; - transform: translateY(-50%); + transform: translateY(max(-64px, -100%)); } -.transition_x_leaveActive { - position: absolute; + +@supports (interpolate-size: allow-keywords) { + .transition_x_enterFrom { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; } .notifications { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 9adc3d98da..54da5a889d 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -16,12 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkError v-else-if="error" @retry="init()"/> <div v-else-if="empty" key="_empty_"> - <slot name="empty"> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - </slot> + <slot name="empty"><MkResult type="empty"/></slot> </div> <div v-else ref="rootEl" class="_gaps"> @@ -88,7 +83,6 @@ function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): M </script> <script lang="ts" setup> -import { infoImageUrl } from '@/instance.js'; import MkButton from '@/components/MkButton.vue'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 22ae563d13..b0638db785 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="rootEl"> - <div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> +<div ref="rootEl" :class="isPulling ? $style.isPulling : null"> + <!-- 小数が含まれるとレンダリングが高頻度になりすぎパフォーマンスが悪化するためround --> + <div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${Math.round(pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR)))}px;`"> <div :class="$style.frameContent"> <MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/> - <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i> + <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i> <div :class="$style.text"> - <template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template> + <template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template> <template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template> <template v-else>{{ i18n.ts.pullDownToRefresh }}</template> </div> @@ -29,24 +30,21 @@ import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; -const FIRE_THRESHOLD = 230; +const FIRE_THRESHOLD = 200; const RELEASE_TRANSITION_DURATION = 200; const PULL_BRAKE_BASE = 1.5; const PULL_BRAKE_FACTOR = 170; -const isPullStart = ref(false); -const isPullEnd = ref(false); +const isPulling = ref(false); +const isPulledEnough = ref(false); const isRefreshing = ref(false); const pullDistance = ref(0); -let supportPointerDesktop = false; let startScreenY: number | null = null; const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; -let disabled = false; - const props = withDefaults(defineProps<{ refresher: () => Promise<void>; }>(), { @@ -57,19 +55,72 @@ const emit = defineEmits<{ (ev: 'refresh'): void; }>(); -function getScreenY(event) { - if (supportPointerDesktop) { +function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number { + if (event.touches && event.touches[0] && event.touches[0].screenY != null) { + return event.touches[0].screenY; + } else { return event.screenY; } - return event.touches[0].screenY; } -function moveStart(event) { - if (!isPullStart.value && !isRefreshing.value && !disabled) { - isPullStart.value = true; - startScreenY = getScreenY(event); - pullDistance.value = 0; +// When at the top of the page, disable vertical overscroll so passive touch listeners can take over. +function lockDownScroll() { + if (scrollEl == null) return; + scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom'; + scrollEl.style.overscrollBehavior = 'none'; +} + +function unlockDownScroll() { + if (scrollEl == null) return; + scrollEl.style.touchAction = 'auto'; + scrollEl.style.overscrollBehavior = 'contain'; +} + +function moveStartByMouse(event: MouseEvent) { + if (event.button !== 1) return; + if (isRefreshing.value) return; + + const scrollPos = scrollEl!.scrollTop; + if (scrollPos !== 0) { + unlockDownScroll(); + return; + } + + lockDownScroll(); + + event.preventDefault(); // 中クリックによるスクロール、テキスト選択などを防ぐ + + isPulling.value = true; + startScreenY = getScreenY(event); + pullDistance.value = 0; + + window.addEventListener('mousemove', moving, { passive: true }); + window.addEventListener('mouseup', () => { + window.removeEventListener('mousemove', moving); + onPullRelease(); + }, { passive: true, once: true }); +} + +function moveStartByTouch(event: TouchEvent) { + if (isRefreshing.value) return; + + const scrollPos = scrollEl!.scrollTop; + if (scrollPos !== 0) { + unlockDownScroll(); + return; } + + lockDownScroll(); + + isPulling.value = true; + startScreenY = getScreenY(event); + pullDistance.value = 0; + + window.addEventListener('touchmove', moving, { passive: true }); + window.addEventListener('touchend', () => { + window.removeEventListener('touchmove', moving); + onPullRelease(); + }, { passive: true, once: true }); } function moveBySystem(to: number): Promise<void> { @@ -108,31 +159,36 @@ async function closeContent() { } } -function moveEnd() { - if (isPullStart.value && !isRefreshing.value) { - startScreenY = null; - if (isPullEnd.value) { - isPullEnd.value = false; - isRefreshing.value = true; - fixOverContent().then(() => { - emit('refresh'); - props.refresher().then(() => { - refreshFinished(); - }); +function onPullRelease() { + startScreenY = null; + if (isPulledEnough.value) { + isPulledEnough.value = false; + isRefreshing.value = true; + fixOverContent().then(() => { + emit('refresh'); + props.refresher().then(() => { + refreshFinished(); }); - } else { - closeContent().then(() => isPullStart.value = false); - } + }); + } else { + closeContent().then(() => isPulling.value = false); } } -function moving(event: TouchEvent | PointerEvent) { - if (!isPullStart.value || isRefreshing.value || disabled) return; +function toggleScrollLockOnTouchEnd() { + const scrollPos = scrollEl!.scrollTop; + if (scrollPos === 0) { + lockDownScroll(); + } else { + unlockDownScroll(); + } +} - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) { +function moving(event: MouseEvent | TouchEvent) { + if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; - isPullEnd.value = false; - moveEnd(); + isPulledEnough.value = false; + onPullRelease(); return; } @@ -144,15 +200,7 @@ function moving(event: TouchEvent | PointerEvent) { const moveHeight = moveScreenY - startScreenY!; pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); - if (pullDistance.value > 0) { - if (event.cancelable) event.preventDefault(); - } - - if (pullDistance.value > SCROLL_STOP) { - event.stopPropagation(); - } - - isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; + isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; } /** @@ -162,65 +210,33 @@ function moving(event: TouchEvent | PointerEvent) { */ function refreshFinished() { closeContent().then(() => { - isPullStart.value = false; + isPulling.value = false; isRefreshing.value = false; }); } -function setDisabled(value) { - disabled = value; -} - -function onScrollContainerScroll() { - const scrollPos = scrollEl!.scrollTop; - - // When at the top of the page, disable vertical overscroll so passive touch listeners can take over. - if (scrollPos === 0) { - scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom'; - registerEventListenersForReadyToPull(); - } else { - scrollEl!.style.touchAction = 'auto'; - unregisterEventListenersForReadyToPull(); - } -} - -function registerEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.addEventListener('touchstart', moveStart, { passive: true }); - rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない -} - -function unregisterEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.removeEventListener('touchstart', moveStart); - rootEl.value.removeEventListener('touchmove', moving); -} - onMounted(() => { if (rootEl.value == null) return; - scrollEl = getScrollContainer(rootEl.value); - if (scrollEl == null) return; - - scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true }); - - rootEl.value.addEventListener('touchend', moveEnd, { passive: true }); - - registerEventListenersForReadyToPull(); + lockDownScroll(); + rootEl.value.addEventListener('mousedown', moveStartByMouse, { passive: false }); // preventDefaultするため + rootEl.value.addEventListener('touchstart', moveStartByTouch, { passive: true }); + rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true }); }); onUnmounted(() => { - if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll); - - unregisterEventListenersForReadyToPull(); -}); - -defineExpose({ - setDisabled, + unlockDownScroll(); + if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse); + if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch); + if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd); }); </script> <style lang="scss" module> +.isPulling { + will-change: contents; +} + .frame { position: relative; overflow: clip; @@ -242,7 +258,6 @@ defineExpose({ display: flex; flex-direction: column; align-items: center; - font-size: 14px; > .icon, > .loader { margin: 6px 0; @@ -258,6 +273,7 @@ defineExpose({ > .text { margin: 5px 0; + font-size: 90%; } } </style> diff --git a/packages/frontend/src/components/MkSwiper.vue b/packages/frontend/src/components/MkSwiper.vue index 1d0ffaea11..b66bfb0e9d 100644 --- a/packages/frontend/src/components/MkSwiper.vue +++ b/packages/frontend/src/components/MkSwiper.vue @@ -53,12 +53,12 @@ const MIN_SWIPE_DISTANCE = 20; // スワイプ時の動作を発火する最小の距離 const SWIPE_DISTANCE_THRESHOLD = 70; -// スワイプを中断するY方向の移動距離 -const SWIPE_ABORT_Y_THRESHOLD = 75; - // スワイプできる最大の距離 const MAX_SWIPE_DISTANCE = 120; +// スワイプ方向を判定する角度の許容範囲(度数) +const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50; + // ▲ しきい値 ▲ // let startScreenX: number | null = null; @@ -69,6 +69,7 @@ const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === t const pullDistance = ref(0); const isSwipingForClass = ref(false); let swipeAborted = false; +let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null; function touchStart(event: TouchEvent) { if (!prefer.r.enableHorizontalSwipe.value) return; @@ -79,6 +80,7 @@ function touchStart(event: TouchEvent) { startScreenX = event.touches[0].screenX; startScreenY = event.touches[0].screenY; + swipeDirectionLocked = null; // スワイプ方向をリセット } function touchMove(event: TouchEvent) { @@ -95,15 +97,24 @@ function touchMove(event: TouchEvent) { let distanceX = event.touches[0].screenX - startScreenX; let distanceY = event.touches[0].screenY - startScreenY; - if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) { - swipeAborted = true; + // スワイプ方向をロック + if (!swipeDirectionLocked) { + const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI)); + if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) { + swipeDirectionLocked = 'vertical'; + } else { + swipeDirectionLocked = 'horizontal'; + } + } + // 縦方向のスワイプの場合は中断 + if (swipeDirectionLocked === 'vertical') { + swipeAborted = true; pullDistance.value = 0; isSwiping.value = false; window.setTimeout(() => { isSwipingForClass.value = false; }, 400); - return; } @@ -164,6 +175,8 @@ function touchEnd(event: TouchEvent) { window.setTimeout(() => { isSwipingForClass.value = false; }, 400); + + swipeDirectionLocked = null; // スワイプ方向をリセット } /** 横スワイプに関与する可能性のある要素を調べる */ @@ -190,7 +203,7 @@ watch(tabModel, (newTab, oldTab) => { const newIndex = props.tabs.findIndex(tab => tab.key === newTab); const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab); - if (oldIndex >= 0 && newIndex && oldIndex < newIndex) { + if (oldIndex >= 0 && newIndex >= 0 && oldIndex < newIndex) { transitionName.value = 'swipeAnimationLeft'; } else { transitionName.value = 'swipeAnimationRight'; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8ca690f2ce..6a265aa836 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()"> - <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> + <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)"> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items: notes }"> <component @@ -21,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" :leaveToClass="$style.transition_x_leaveTo" - :moveClass=" $style.transition_x_move" + :moveClass="$style.transition_x_move" tag="div" > <template v-for="(note, i) in notes" :key="note.id"> @@ -36,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> @@ -53,7 +48,6 @@ import { prefer } from '@/preferences.js'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -93,7 +87,6 @@ type TimelineQueryType = { roleId?: string }; -const prComponent = useTemplateRef('prComponent'); const pagingComponent = useTemplateRef('pagingComponent'); let tlNotesCount = 0; @@ -306,18 +299,38 @@ defineExpose({ </script> <style lang="scss" module> -.transition_x_move, -.transition_x_enterActive, +.transition_x_move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); +} + +.transition_x_enterActive { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + + &.note, + .note { + /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */ + content-visibility: visible !important; + } +} + .transition_x_leaveActive { - transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; + transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1); } -.transition_x_enterFrom, -.transition_x_leaveTo { + +.transition_x_enterFrom { opacity: 0; - transform: translateY(-50%); + transform: translateY(max(-64px, -100%)); } -.transition_x_leaveActive { - position: absolute; + +@supports (interpolate-size: allow-keywords) { + .transition_x_leaveTo { + interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要 + height: 0; + } +} + +.transition_x_leaveTo { + opacity: 0; } .reverse { diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 0d1ffd715f..90087cb000 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkPagination :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div :class="$style.root"> @@ -25,7 +20,6 @@ import type { Paging } from '@/components/MkPagination.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 3bd2a2ffae..2a423bfa55 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> - <div v-if="user != null"> + <MkError v-if="error" @retry="fetchUser()"/> + <div v-else-if="user != null"> <div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"> <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> </div> @@ -85,6 +86,7 @@ const zIndex = os.claimZIndex('middle'); const user = ref<Misskey.entities.UserDetailed | null>(null); const top = ref(0); const left = ref(0); +const error = ref(false); function showMenu(ev: MouseEvent) { if (user.value == null) return; @@ -92,19 +94,27 @@ function showMenu(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } -onMounted(() => { +async function fetchUser() { if (typeof props.q === 'object') { user.value = props.q; + error.value = false; } else { - const query = props.q.startsWith('@') ? + const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ? Misskey.acct.parse(props.q.substring(1)) : { userId: props.q }; misskeyApi('users/show', query).then(res => { if (!props.showing) return; user.value = res; + error.value = false; + }, () => { + error.value = true; }); } +} + +onMounted(() => { + fetchUser(); const rect = props.source.getBoundingClientRect(); const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX; diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 95ed255189..6a5c4c18bf 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -4,20 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> - <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> - <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> - <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> - </div> -</Transition> +<MkResult type="error"> + <MkButton :class="$style.button" rounded @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> +</MkResult> </template> <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { prefer } from '@/preferences.js'; -import { serverErrorImageUrl } from '@/instance.js'; const emit = defineEmits<{ (ev: 'retry'): void; @@ -25,25 +19,7 @@ const emit = defineEmits<{ </script> <style lang="scss" module> -.root { - padding: 32px; - text-align: center; - align-items: center; -} - -.text { - margin: 0 0 8px 0; -} - .button { margin: 0 auto; } - -.img { - vertical-align: bottom; - width: 128px; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; -} </style> diff --git a/packages/frontend/src/components/global/MkResult.stories.impl.ts b/packages/frontend/src/components/global/MkResult.stories.impl.ts new file mode 100644 index 0000000000..05f8c9069b --- /dev/null +++ b/packages/frontend/src/components/global/MkResult.stories.impl.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkResult from './MkResult.vue'; +import type { StoryObj } from '@storybook/vue3'; +export const Default = { + render(args) { + return { + components: { + MkResult, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkResult v-bind="props" />', + }; + }, + args: { + type: 'empty', + text: 'Lorem Ipsum', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkResult>; +export const emptyWithNoText = { + ...Default, + args: { + ...Default.args, + text: undefined, + }, +} satisfies StoryObj<typeof MkResult>; +export const notFound = { + ...Default, + args: { + ...Default.args, + type: 'notFound', + }, +} satisfies StoryObj<typeof MkResult>; +export const errorType = { + ...Default, + args: { + ...Default.args, + type: 'error', + }, +} satisfies StoryObj<typeof MkResult>; diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue new file mode 100644 index 0000000000..fdfc7091e8 --- /dev/null +++ b/packages/frontend/src/components/global/MkResult.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> + <div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps"> + <img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/> + <img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'notFound'" type="question" :class="$style.icon"/> + <img v-if="type === 'error' && instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/> + <MkSystemIcon v-else-if="type === 'error'" type="error" :class="$style.icon"/> + + <div style="opacity: 0.7;">{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : type === 'error' ? i18n.ts.somethingHappened : null) }}</div> + <slot></slot> + </div> +</Transition> +</template> + +<script lang="ts" setup> +import {} from 'vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + type: 'empty' | 'notFound' | 'error'; + text?: string; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; + text-align: center; + padding: 32px; +} + +.img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} + +.icon { + width: 65px; + height: 65px; + margin: 0 auto; +} +</style> diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue new file mode 100644 index 0000000000..3285d5a940 --- /dev/null +++ b/packages/frontend/src/components/global/MkSystemIcon.vue @@ -0,0 +1,109 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160"> + <path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.anim]"/> + <path d="M80,52L80,52" :class="[$style.line, $style.fade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160"> + <path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.anim]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160"> + <path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.anim]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160"> + <path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.anim]"/> + <path d="M80,108L80,108" :class="[$style.line, $style.fade]"/> + <path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:390;" :class="[$style.line, $style.anim]"/> +</svg> +<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160"> + <path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.anim]"/> + <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.anim]"/> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/> +</svg> +</template> + +<script lang="ts" setup> +import {} from 'vue'; + +const props = defineProps<{ + type: 'info' | 'question' | 'success' | 'warn' | 'error'; +}>(); +</script> + +<style lang="scss" module> +.icon { + stroke-linecap: round; + stroke-linejoin: round; + + &.info { + color: var(--MI_THEME-accent); + } + + &.question { + color: var(--MI_THEME-fg); + } + + &.success { + color: var(--MI_THEME-success); + } + + &.warn { + color: var(--MI_THEME-warn); + } + + &.error { + color: var(--MI_THEME-error); + } +} + +.line { + fill: none; + stroke: currentColor; + stroke-width: 8px; +} + +.fill { + fill: currentColor; +} + +.anim { + stroke-dasharray: var(--l); + stroke-dashoffset: var(--l); + animation: line-animation var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation-delay: var(--delay, 0s); +} + +.fade { + opacity: 0; + animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; + animation-delay: var(--delay, 0s); +} + +@keyframes line-animation { + 0% { + stroke-dashoffset: var(--l); + opacity: 0; + } + 100% { + stroke-dashoffset: 0; + opacity: 1; + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +</style> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index 58c222038a..33a34e0b67 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> <div :class="$style.body"> - <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> + <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> <slot></slot> </MkSwiper> <slot v-else></slot> @@ -25,6 +25,7 @@ import type { PageHeaderProps } from './MkPageHeader.vue'; import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js'; import MkSwiper from '@/components/MkSwiper.vue'; import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<PageHeaderProps & { reversed?: boolean; diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index ec6ea7c569..9981772ae8 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -24,6 +24,8 @@ import MkAd from './global/MkAd.vue'; import MkPageHeader from './global/MkPageHeader.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; +import MkResult from './global/MkResult.vue'; +import MkSystemIcon from './global/MkSystemIcon.vue'; import PageWithHeader from './global/PageWithHeader.vue'; import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; @@ -61,6 +63,8 @@ export const components = { MkPageHeader: MkPageHeader, MkStickyContainer: MkStickyContainer, MkLazy: MkLazy, + MkResult: MkResult, + MkSystemIcon: MkSystemIcon, PageWithHeader: PageWithHeader, PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, @@ -92,6 +96,8 @@ declare module '@vue/runtime-core' { MkPageHeader: typeof MkPageHeader; MkStickyContainer: typeof MkStickyContainer; MkLazy: typeof MkLazy; + MkResult: typeof MkResult; + MkSystemIcon: typeof MkSystemIcon; PageWithHeader: typeof PageWithHeader; PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index e75e3dfd34..2943e60e43 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -7,7 +7,6 @@ import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; -import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; // TODO: 他のタブと永続化されたstateを同期 @@ -30,12 +29,6 @@ if (providedAt > cachedAt) { export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {}); -export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL); - -export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO_IMAGE_URL); - -export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL); - export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true); export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> { diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index 791267f5ca..d656f93fa3 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-if="!loaded"/> <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div v-show="loaded" :class="$style.root"> - <img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/> + <img v-if="instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/> <div class="_gaps"> <div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div> <div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div> @@ -36,7 +36,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; import { prefer } from '@/preferences.js'; -import { serverErrorImageUrl } from '@/instance.js'; +import { instance } from '@/instance.js'; const props = withDefaults(defineProps<{ error?: Error; diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 3701e69fc6..7a40c978b5 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTl :events="timeline"> <template #left="{ event }"> <div> - <MkAvatar :user="event.user" style="width: 24px; height: 24px;"/> + <MkAvatar :user="event.user" style="width: 26px; height: 26px;"/> </div> </template> <template #right="{ event, timestamp, delta }"> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 69645957bf..61d72777b8 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -24,12 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> <MkPagination :pagination="usersPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div class="_gaps_s"> @@ -70,7 +65,6 @@ import MkButton from '@/components/MkButton.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; -import { infoImageUrl } from '@/instance.js'; import { useRouter } from '@/router.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index d7454882b2..5914e243c6 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -230,6 +230,31 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template> <template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template> </MkTextarea> + + <MkFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template> + <template #footer> + <div class="_buttons"> + <MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + </div> + </template> + + <div :class="$style.metadataRoot" class="_gaps_s"> + <MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo> + <div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem"> + <button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button> + <div :class="$style.dragItemForm"> + <FormSplit :minWidth="200"> + <MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName"> + </MkInput> + <MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version"> + </MkInput> + </FormSplit> + </div> + </div> + </div> + </MkFolder> </div> </MkFolder> @@ -368,10 +393,12 @@ const urlPreviewForm = useForm({ const federationForm = useForm({ federation: meta.federation, federationHosts: meta.federationHosts.join('\n'), + deliverSuspendedSoftware: meta.deliverSuspendedSoftware, }, async (state) => { await os.apiWithDialog('admin/update-meta', { federation: state.federation, federationHosts: state.federationHosts.split('\n'), + deliverSuspendedSoftware: state.deliverSuspendedSoftware, }); fetchInstance(true); }); @@ -398,4 +425,53 @@ definePage(() => ({ font-size: 0.85em; color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } + +.metadataRoot { + container-type: inline-size; +} + +.fieldDragItem { + display: flex; + padding: 10px; + align-items: flex-end; + border-radius: 6px; + + /* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */ + @container (max-width: 452px) { + align-items: center; + } +} + +.dragItemHandle { + cursor: grab; + width: 32px; + height: 32px; + margin: 0 8px 0 0; + opacity: 0.5; + flex-shrink: 0; + + &:active { + cursor: grabbing; + } +} + +.dragItemRemove { + @extend .dragItemHandle; + + color: #ff2a2a; + opacity: 1; + cursor: pointer; + + &:hover, &:focus { + opacity: .7; + } + + &:active { + cursor: pointer; + } +} + +.dragItemForm { + flex-grow: 1; +} </style> diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue index 82b22ea9dd..3cbe186e9d 100644 --- a/packages/frontend/src/pages/chat/home.invitations.vue +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -27,9 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> </div> - <div v-if="!fetching && invitations.length == 0" class="_fullinfo"> - <div>{{ i18n.ts._chat.noInvitations }}</div> - </div> + <MkResult v-if="!fetching && invitations.length == 0" type="empty" :text="i18n.ts._chat.noInvitations"/> <MkLoading v-if="fetching"/> </div> </template> diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue index f9fd6bfd55..8887aec3d5 100644 --- a/packages/frontend/src/pages/chat/home.joiningRooms.vue +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="memberships.length > 0" class="_gaps_s"> <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/> </div> - <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> - <div>{{ i18n.ts._chat.noRooms }}</div> - </div> + <MkResult v-if="!fetching && memberships.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/> <MkLoading v-if="fetching"/> </div> </template> diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue index ce7da15563..9a7ae5dd72 100644 --- a/packages/frontend/src/pages/chat/home.ownedRooms.vue +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="rooms.length > 0" class="_gaps_s"> <XRoom v-for="room in rooms" :key="room.id" :room="room"/> </div> - <div v-if="!fetching && rooms.length == 0" class="_fullinfo"> - <div>{{ i18n.ts._chat.noRooms }}</div> - </div> + <MkResult v-if="!fetching && rooms.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/> <MkLoading v-if="fetching"/> </div> </template> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue index 20b6e22a46..1e4eaf5639 100644 --- a/packages/frontend/src/pages/chat/room.search.vue +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -24,10 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/> </div> </div> - <div v-else class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.notFound }}</div> - </div> + <MkResult v-else type="notFound"/> </MkFoldableSection> </div> </template> @@ -38,7 +35,6 @@ import * as Misskey from 'misskey-js'; import XMessage from './XMessage.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue new file mode 100644 index 0000000000..4dae1b57a9 --- /dev/null +++ b/packages/frontend/src/pages/debug.vue @@ -0,0 +1,66 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader> + <div class="_spacer" style="--MI_SPACER-w: 600px;"> + <div class="_gaps_m"> + <MkResult v-if="resultType === 'empty'" type="empty"/> + <MkResult v-if="resultType === 'notFound'" type="notFound"/> + <MkResult v-if="resultType === 'error'" type="error"/> + <MkSelect + v-model="resultType" :items="[ + { label: 'empty', value: 'empty' }, + { label: 'notFound', value: 'notFound' }, + { label: 'error', value: 'error' }, + ]" + ></MkSelect> + + <MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 60px;"/> + <MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 60px;"/> + <MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 60px;"/> + <MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 60px;"/> + <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 60px;"/> + <MkSelect + v-model="iconType" :items="[ + { label: 'info', value: 'info' }, + { label: 'question', value: 'question' }, + { label: 'success', value: 'success' }, + { label: 'warn', value: 'warn' }, + { label: 'error', value: 'error' }, + ]" + ></MkSelect> + + <div class="_buttons"> + <MkButton @click="os.alert({ type: 'error', title: 'Error', text: 'error' })">Error</MkButton> + <MkButton @click="os.alert({ type: 'warning', title: 'Warning', text: 'warning' })">Warning</MkButton> + <MkButton @click="os.alert({ type: 'info', title: 'Info', text: 'info' })">Info</MkButton> + <MkButton @click="os.alert({ type: 'success', title: 'Success', text: 'success' })">Success</MkButton> + <MkButton @click="os.alert({ type: 'question', title: 'Question', text: 'question' })">Question</MkButton> + </div> + </div> + </div> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import { definePage } from '@/page.js'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; + +const resultType = ref('empty'); +const iconType = ref('info'); + +definePage(() => ({ + title: 'DEBUG ROOM', + icon: 'ti ti-help-circle', +})); +</script> diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 5390a48be5..21be0b18a9 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -68,10 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </div> </div> - <div v-else class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else type="empty"/> </div> </template> @@ -82,7 +79,6 @@ import MkInfo from '@/components/MkInfo.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import bytes from '@/filters/bytes.js'; -import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 4f57c1209e..b0a18987b4 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <MkPagination :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noNotes }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template> <template #default="{ items }"> <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false"> @@ -30,7 +25,6 @@ import MkNote from '@/components/MkNote.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { infoImageUrl } from '@/instance.js'; const pagination = { endpoint: 'i/favorites' as const, diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 8ea385a74f..9b4e3faaef 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> <div class="_spacer" style="--MI_SPACER-w: 800px;"> <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noFollowRequests }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noFollowRequests"/></template> <template #default="{items}"> <div class="mk-follow-requests _gaps"> <div v-for="req in items" :key="req.id" class="user _panel"> @@ -48,7 +43,6 @@ import { userPage, acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { infoImageUrl } from '@/instance.js'; import { $i } from '@/i.js'; const paginationComponent = useTemplateRef('paginationComponent'); diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 28ce02b87c..96a43f67e8 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkKeyValue> <MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton> - <MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> + <MkButton v-if="suspensionState !== 'none'" :disabled="!instance || suspensionState == 'softwareSuspended'" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton> <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> @@ -164,7 +164,7 @@ const tab = ref('overview'); const chartSrc = ref<ChartSrc>('instance-requests'); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); -const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none'); +const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none'); const isBlocked = ref(false); const isSilenced = ref(false); const isMediaSilenced = ref(false); diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index cc114ae9b3..406c08bcf2 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader> <div v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" class="_spacer" style="--MI_SPACER-w: 1200px;"> - <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> - <div :class="$style.text"> - <i class="ti ti-alert-triangle"></i> - {{ i18n.ts.nothing }} - </div> - </div> + <MkResult type="empty"/> </div> <div v-else class="_spacer" style="--MI_SPACER-w: 800px;"> <div class="_gaps_m" style="text-align: center;"> @@ -43,7 +37,7 @@ import MkButton from '@/components/MkButton.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; import { definePage } from '@/page.js'; -import { serverErrorImageUrl, instance } from '@/instance.js'; +import { instance } from '@/instance.js'; import { $i } from '@/i.js'; const pagingComponent = useTemplateRef('pagingComponent'); @@ -96,23 +90,3 @@ definePage(() => ({ icon: 'ti ti-user-plus', })); </script> - -<style lang="scss" module> -.root { - padding: 32px; - text-align: center; - align-items: center; -} - -.text { - margin: 0 0 8px 0; -} - -.img { - vertical-align: bottom; - width: 128px; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; -} -</style> diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index e9e3c79be5..4368aff8be 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;"> - <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> - <p :class="$style.text"> - <i class="ti ti-alert-triangle"></i> - {{ i18n.ts.nothing }} - </p> - </div> + <MkResult type="error"/> </div> <div v-else-if="list" class="_spacer" style="--MI_SPACER-w: 700px;"> <div v-if="list" class="members _margin"> @@ -42,7 +36,6 @@ import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkButton from '@/components/MkButton.vue'; import { definePage } from '@/page.js'; -import { serverErrorImageUrl } from '@/instance.js'; const props = defineProps<{ listId: string; diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 6f623abb64..95a3108e3a 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <div> - <div v-if="antennas.length === 0" class="empty"> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - </div> + <MkResult v-if="antennas.length === 0" type="empty"/> <MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> @@ -32,7 +27,6 @@ import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; -import { infoImageUrl } from '@/instance.js'; const antennas = computed(() => antennasCache.value.value ?? []); diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index c974f3afc7..41afabff99 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <div class="_gaps"> - <div v-if="items.length === 0" class="empty"> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - </div> + <MkResult v-if="items.length === 0" type="empty"/> <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> @@ -35,7 +30,6 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { userListsCache } from '@/cache.js'; -import { infoImageUrl } from '@/instance.js'; import { ensureSignin } from '@/i.js'; const $i = ensureSignin(); diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 684a3bb5bd..ba871420fe 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -4,11 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <div class="_fullinfo"> - <img :src="notFoundImageUrl" draggable="false"/> - <div>{{ i18n.ts.notFoundDescription }}</div> - </div> +<div style="align-content: center; height: 100cqh;"> + <MkResult type="notFound" :text="i18n.ts.notFoundDescription"/> </div> </template> @@ -17,7 +14,6 @@ import { computed } from 'vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { pleaseLogin } from '@/utility/please-login.js'; -import { notFoundImageUrl } from '@/instance.js'; const props = defineProps<{ showLoginPopup?: boolean; diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 82e5999406..9d01edb255 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -6,30 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :tabs="headerTabs"> <div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;"> - <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> - <p :class="$style.text"> - <i class="ti ti-alert-triangle"></i> - {{ error }} - </p> - </div> + <MkResult type="error" :text="error"/> </div> <div v-else-if="tab === 'users'" class="_spacer" style="--MI_SPACER-w: 1200px;"> <div class="_gaps_s"> <div v-if="role">{{ role.description }}</div> <MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/> - <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/> </div> </div> <div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;"> <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> - <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/> </div> </PageWithHeader> </template> @@ -37,13 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { instanceName } from '@@/js/config.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { serverErrorImageUrl, infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ roleId: string; @@ -97,24 +83,3 @@ definePage(() => ({ icon: 'ti ti-badge', })); </script> - -<style lang="scss" module> -.root { - padding: 32px; - text-align: center; - align-items: center; -} - -.text { - margin: 0 0 8px 0; -} - -.img { - vertical-align: bottom; - width: 128px; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; -} -</style> - diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index c72179b9a1..33c17e5d7f 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> <FormPagination ref="list" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - </template> + <template #empty><MkResult type="empty"/></template> <template #default="{items}"> <div class="_gaps"> <MkFolder v-for="token in items" :key="token.id" :defaultOpen="true"> @@ -63,7 +58,6 @@ import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { infoImageUrl } from '@/instance.js'; const list = ref<InstanceType<typeof FormPagination>>(); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index fc9cd8f892..7c2376249e 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -69,12 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template> <MkPagination :pagination="renoteMutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div class="_gaps_s"> @@ -105,12 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.mutedUsers }}</template> <MkPagination :pagination="mutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div class="_gaps_s"> @@ -143,12 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.blockedUsers }}</template> <MkPagination :pagination="blockingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template> <template #default="{ items }"> <div class="_gaps_s"> @@ -186,7 +171,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; -import { instance, infoImageUrl } from '@/instance.js'; +import { instance } from '@/instance.js'; import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 57b140f97b..4d718d21b4 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -471,6 +471,15 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['swipe', 'pull', 'refresh']"> + <MkPreferenceContainer k="enablePullToRefresh"> + <MkSwitch v-model="enablePullToRefresh"> + <template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts._settings.enablePullToRefresh_description }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['keep', 'screen', 'display', 'on']"> <MkPreferenceContainer k="keepScreenOn"> <MkSwitch v-model="keepScreenOn"> @@ -800,6 +809,7 @@ const animatedMfm = prefer.model('animatedMfm'); const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); const keepScreenOn = prefer.model('keepScreenOn'); const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); +const enablePullToRefresh = prefer.model('enablePullToRefresh'); const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); const contextMenu = prefer.model('contextMenu'); const menuStyle = prefer.model('menuStyle'); @@ -857,6 +867,8 @@ watch([ fontSize, useSystemFont, makeEveryTextElementsSelectable, + enableHorizontalSwipe, + enablePullToRefresh, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/pages/settings/profiles.vue b/packages/frontend/src/pages/settings/profiles.vue new file mode 100644 index 0000000000..4804c11f7a --- /dev/null +++ b/packages/frontend/src/pages/settings/profiles.vue @@ -0,0 +1,47 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/profiles" :label="i18n.ts._preferencesProfile.manageProfiles" :keywords="['profile', 'settings', 'preferences', 'manage']" icon="ti ti-settings-cog"> + <div class="_gaps"> + <MkFolder v-for="backup in backups"> + <template #label>{{ backup.name }}</template> + <MkButton danger @click="del(backup)">{{ i18n.ts.delete }}</MkButton> + </MkFolder> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import { deleteCloudBackup, listCloudBackups } from '@/preferences/utility.js'; + +const backups = await listCloudBackups(); + +function del(backup) { + deleteCloudBackup(backup.name); +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts._preferencesProfile.manageProfiles, + icon: 'ti ti-settings-cog', +})); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 73c6ff96c9..96f43bb2f6 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -298,6 +298,9 @@ export const PREF_DEF = { default: false, }, enableHorizontalSwipe: { + default: false, + }, + enablePullToRefresh: { default: true, }, useNativeUiForVideoAudioPlayer: { diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index adba908c3c..af5b178df6 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -74,12 +74,17 @@ export function getPreferencesProfileMenu(): MenuItem[] { action: () => { importProfile(); }, + }, { + type: 'divider', + }, { + type: 'link', + text: i18n.ts._preferencesProfile.manageProfiles + '...', + icon: 'ti ti-settings-cog', + to: '/settings/profiles', }]; if (prefer.s.devMode) { menu.push({ - type: 'divider', - }, { text: 'Copy profile as text', icon: 'ti ti-clipboard', action: () => { @@ -145,17 +150,30 @@ export async function cloudBackup() { }); } -export async function restoreFromCloudBackup() { - if ($i == null) return; - - // TODO: 更新日時でソートして取得したい +export async function listCloudBackups() { const keys = await misskeyApi('i/registry/keys', { scope: ['client', 'preferences', 'backups'], }); - if (_DEV_) console.log(keys); + return keys.map(k => ({ + name: k, + })); +} + +export async function deleteCloudBackup(key: string) { + await os.apiWithDialog('i/registry/remove', { + scope: ['client', 'preferences', 'backups'], + key, + }); +} + +export async function restoreFromCloudBackup() { + if ($i == null) return; + + // TODO: 更新日時でソートしたい + const backups = await listCloudBackups(); - if (keys.length === 0) { + if (backups.length === 0) { os.alert({ type: 'warning', title: i18n.ts._preferencesBackup.noBackupsFoundTitle, @@ -166,9 +184,9 @@ export async function restoreFromCloudBackup() { const select = await os.select({ title: i18n.ts._preferencesBackup.selectBackupToRestore, - items: keys.map(k => ({ - text: k, - value: k, + items: backups.map(backup => ({ + text: backup.name, + value: backup.name, })), }); if (select.canceled) return; diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index a0a22b4338..5e0e6f7286 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -9,6 +9,7 @@ import type { RouteDef } from '@/lib/nirax.js'; import { $i, iAmModerator } from '@/i.js'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; +import PageTimeline from '@/pages/timeline.vue'; export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ loader: loader, @@ -21,6 +22,13 @@ function chatPage(...args: Parameters<typeof page>) { } export const ROUTE_DEF = [{ + name: 'index', + path: '/', + component: $i ? PageTimeline : page(() => import('@/pages/welcome.vue')), +}, { + path: '/timeline', + component: PageTimeline, +}, { path: '/@:username/pages/:pageName(*)', component: page(() => import('@/pages/page.vue')), }, { @@ -173,6 +181,10 @@ export const ROUTE_DEF = [{ name: 'preferences', component: page(() => import('@/pages/settings/custom-css.vue')), }, { + path: '/profiles', + name: 'profiles', + component: page(() => import('@/pages/settings/profiles.vue')), + }, { path: '/accounts', name: 'profile', component: page(() => import('@/pages/settings/accounts.vue')), @@ -580,12 +592,9 @@ export const ROUTE_DEF = [{ component: page(() => import('@/pages/reversi/game.vue')), loginRequired: false, }, { - path: '/timeline', - component: page(() => import('@/pages/timeline.vue')), -}, { - name: 'index', - path: '/', - component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')), + path: '/debug', + component: page(() => import('@/pages/debug.vue')), + loginRequired: false, }, { // テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする path: '/redirect-test', diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 32db5cebf9..341f5cb621 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -179,7 +179,12 @@ rt { ._spacer { width: 100%; max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-max, 24px) * 2))); - margin: var(--MI_SPACER-max, 24px) auto; + + /* marginを使って余白を表現すると、margin特有の親突き抜け仕様などが厄介になってくるので上下はpaddingを使う */ + padding: var(--MI_SPACER-max, 24px) 0; + margin: 0 auto; + + box-sizing: border-box; container-type: inline-size; /* 子に継承させない */ @@ -190,13 +195,13 @@ rt { ._forceShrinkSpacer ._spacer { max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-min, 12px) * 2))); - margin: var(--MI_SPACER-min, 12px) auto; + padding: var(--MI_SPACER-min, 12px) 0; } @container (max-width: 450px) { ._spacer { max-width: min(var(--MI_SPACER-w, 100%), calc(100% - (var(--MI_SPACER-min, 12px) * 2))); - margin: var(--MI_SPACER-min, 12px) auto; + padding: var(--MI_SPACER-min, 12px) 0; } } @@ -481,18 +486,6 @@ rt { } } -._fullinfo { - padding: 64px 32px; - text-align: center; - - > img { - vertical-align: bottom; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; - } -} - ._link { color: var(--MI_THEME-link); } diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index fa2343ba27..c6aa37aff9 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -39,6 +39,7 @@ import type { PageMetadata } from '@/page.js'; import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; import XTitlebar from '@/ui/_common_/titlebar.vue'; +import XSidebar from '@/ui/_common_/navbar.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; @@ -51,7 +52,6 @@ import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; import { DI } from '@/di.js'; const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue')); -const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); diff --git a/packages/frontend/src/use/use-form.ts b/packages/frontend/src/use/use-form.ts index 26cca839c3..1c93557413 100644 --- a/packages/frontend/src/use/use-form.ts +++ b/packages/frontend/src/use/use-form.ts @@ -5,6 +5,7 @@ import { computed, reactive, watch } from 'vue'; import type { Reactive } from 'vue'; +import { deepEqual } from '@/utility/deep-equal'; function copy<T>(v: T): T { return JSON.parse(JSON.stringify(v)); @@ -27,7 +28,7 @@ export function useForm<T extends Record<string, any>>(initialState: T, save: (n watch([currentState, previousState], () => { for (const key in modifiedStates) { - modifiedStates[key] = currentState[key] !== previousState[key]; + modifiedStates[key] = !deepEqual(currentState[key], previousState[key]); } }, { deep: true }); diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index 6fe743aed2..4790f143cb 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar> </div> <div v-else :class="$style.bdayFFallback"> - <img :src="infoImageUrl" draggable="false" :class="$style.bdayFFallbackImage"/> - <div>{{ i18n.ts.nothing }}</div> + <MkResult type="empty"/> </div> </div> </MkContainer> @@ -32,7 +31,6 @@ import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; import { $i } from '@/i.js'; const name = i18n.ts._widgets.birthdayFollowings; @@ -134,12 +132,4 @@ defineExpose<WidgetComponentExpose>({ justify-content: center; align-items: center; } - -.bdayFFallbackImage { - height: 96px; - width: auto; - max-width: 90%; - margin-bottom: 8px; - border-radius: var(--MI-radius); -} </style> diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 132eb0a629..2594262df1 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -11,10 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="ekmkgxbj"> <MkLoading v-if="fetching"/> - <div v-else-if="(!items || items.length === 0) && widgetProps.showHeader" class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.nothing }}</div> - </div> + <MkResult v-else-if="(!items || items.length === 0) && widgetProps.showHeader" type="empty"/> <div v-else :class="$style.feed"> <a v-for="item in items" :key="item.link" :class="$style.item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> </div> @@ -32,7 +29,6 @@ import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { i18n } from '@/i18n.js'; -import { infoImageUrl } from '@/instance.js'; const name = 'rss'; diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index ee200d6890..9c7c611e5b 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -46,6 +46,7 @@ }, "compileOnSave": false, "include": [ + "./lib/**/*.ts", "./src/**/*.ts", "./src/**/*.vue", "./test/**/*.ts", diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 7069d32317..79813d3f82 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -31,6 +31,12 @@ declare namespace acct { export { acct } // @public (undocumented) +type Achievement = components['schemas']['Achievement']; + +// @public (undocumented) +type AchievementName = components['schemas']['AchievementName']; + +// @public (undocumented) type Ad = components['schemas']['Ad']; // Warning: (ae-forgotten-export) The symbol "operations" needs to be exported by the entry point index.d.ts @@ -2084,6 +2090,8 @@ declare namespace entities { UserDetailed, User, UserList, + Achievement, + AchievementName, Ad, Announcement, App, diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 63fbebd5b5..ab0f891366 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.4.1", + "version": "2025.5.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 15c3ee7e55..354daf800b 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -8,6 +8,8 @@ export type MeDetailed = components['schemas']['MeDetailed']; export type UserDetailed = components['schemas']['UserDetailed']; export type User = components['schemas']['User']; export type UserList = components['schemas']['UserList']; +export type Achievement = components['schemas']['Achievement']; +export type AchievementName = components['schemas']['AchievementName']; export type Ad = components['schemas']['Ad']; export type Announcement = components['schemas']['Announcement']; export type App = components['schemas']['App']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b9d48f02ae..c54cc571d2 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4303,10 +4303,7 @@ export type components = { }]>; }; emailNotificationTypes: string[]; - achievements: { - name: string; - unlockedAt: number; - }[]; + achievements: components['schemas']['Achievement'][]; loggedInDays: number; policies: components['schemas']['RolePolicies']; /** @default false */ @@ -4344,6 +4341,12 @@ export type components = { userIds?: string[]; isPublic: boolean; }; + Achievement: { + name: components['schemas']['AchievementName']; + unlockedAt: number; + }; + /** @enum {string} */ + AchievementName: 'notes1' | 'notes10' | 'notes100' | 'notes500' | 'notes1000' | 'notes5000' | 'notes10000' | 'notes20000' | 'notes30000' | 'notes40000' | 'notes50000' | 'notes60000' | 'notes70000' | 'notes80000' | 'notes90000' | 'notes100000' | 'login3' | 'login7' | 'login15' | 'login30' | 'login60' | 'login100' | 'login200' | 'login300' | 'login400' | 'login500' | 'login600' | 'login700' | 'login800' | 'login900' | 'login1000' | 'passedSinceAccountCreated1' | 'passedSinceAccountCreated2' | 'passedSinceAccountCreated3' | 'loggedInOnBirthday' | 'loggedInOnNewYearsDay' | 'noteClipped1' | 'noteFavorited1' | 'myNoteFavorited1' | 'profileFilled' | 'markedAsCat' | 'following1' | 'following10' | 'following50' | 'following100' | 'following300' | 'followers1' | 'followers10' | 'followers50' | 'followers100' | 'followers300' | 'followers500' | 'followers1000' | 'collectAchievements30' | 'viewAchievements3min' | 'iLoveMisskey' | 'foundTreasure' | 'client30min' | 'client60min' | 'noteDeletedWithin1min' | 'postedAtLateNight' | 'postedAt0min0sec' | 'selfQuote' | 'htl20npm' | 'viewInstanceChart' | 'outputHelloWorldOnScratchpad' | 'open3windows' | 'driveFolderCircularReference' | 'reactWithoutRead' | 'clickedClickHere' | 'justPlainLucky' | 'setNameToSyuilo' | 'cookieClicked' | 'brainDiver' | 'smashTestNotificationButton' | 'tutorialCompleted' | 'bubbleGameExplodingHead' | 'bubbleGameDoubleExplodingHead'; Ad: { /** * Format: id @@ -4619,16 +4622,15 @@ export type components = { /** @enum {string} */ type: 'chatRoomInvitationReceived'; invitation: components['schemas']['ChatRoomInvitation']; - } | ({ + } | { /** Format: id */ id: string; /** Format: date-time */ createdAt: string; /** @enum {string} */ type: 'achievementEarned'; - /** @enum {string} */ - achievement: 'notes1' | 'notes10' | 'notes100' | 'notes500' | 'notes1000' | 'notes5000' | 'notes10000' | 'notes20000' | 'notes30000' | 'notes40000' | 'notes50000' | 'notes60000' | 'notes70000' | 'notes80000' | 'notes90000' | 'notes100000' | 'login3' | 'login7' | 'login15' | 'login30' | 'login60' | 'login100' | 'login200' | 'login300' | 'login400' | 'login500' | 'login600' | 'login700' | 'login800' | 'login900' | 'login1000' | 'passedSinceAccountCreated1' | 'passedSinceAccountCreated2' | 'passedSinceAccountCreated3' | 'loggedInOnBirthday' | 'loggedInOnNewYearsDay' | 'noteClipped1' | 'noteFavorited1' | 'myNoteFavorited1' | 'profileFilled' | 'markedAsCat' | 'following1' | 'following10' | 'following50' | 'following100' | 'following300' | 'followers1' | 'followers10' | 'followers50' | 'followers100' | 'followers300' | 'followers500' | 'followers1000' | 'collectAchievements30' | 'viewAchievements3min' | 'iLoveMisskey' | 'foundTreasure' | 'client30min' | 'client60min' | 'noteDeletedWithin1min' | 'postedAtLateNight' | 'postedAt0min0sec' | 'selfQuote' | 'htl20npm' | 'viewInstanceChart' | 'outputHelloWorldOnScratchpad' | 'open3windows' | 'driveFolderCircularReference' | 'reactWithoutRead' | 'clickedClickHere' | 'justPlainLucky' | 'setNameToSyuilo' | 'cookieClicked' | 'brainDiver' | 'smashTestNotificationButton' | 'tutorialCompleted' | 'bubbleGameExplodingHead' | 'bubbleGameDoubleExplodingHead'; - }) | ({ + achievement: components['schemas']['AchievementName']; + } | ({ /** Format: id */ id: string; /** Format: date-time */ @@ -4989,7 +4991,7 @@ export type components = { isNotResponding: boolean; isSuspended: boolean; /** @enum {string} */ - suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'; + suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'; isBlocked: boolean; /** @example misskey */ softwareName: string | null; @@ -8765,6 +8767,10 @@ export type operations = { /** @enum {string} */ federation: 'all' | 'specified' | 'none'; federationHosts: string[]; + deliverSuspendedSoftware: { + software: string; + versionRange: string; + }[]; }; }; }; @@ -11431,6 +11437,10 @@ export type operations = { /** @enum {string} */ federation?: 'all' | 'none' | 'specified'; federationHosts?: string[]; + deliverSuspendedSoftware?: { + software: string; + versionRange: string; + }[]; }; }; }; @@ -28523,10 +28533,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': { - name: string; - unlockedAt: number; - }[]; + 'application/json': components['schemas']['Achievement'][]; }; }; /** @description Client error */ |