summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-05-07 02:46:42 +0000
committerGitHub <noreply@github.com>2025-05-07 02:46:42 +0000
commit9ed0d5ccecb53c973cef1e762dd0fae9e04f9a5b (patch)
treec41c3ee20b995c3a74a75d4005ab980d217a3727 /packages
parentMerge pull request #15842 from misskey-dev/develop (diff)
parentRelease: 2025.5.0 (diff)
downloadmisskey-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')
-rw-r--r--packages/backend/eslint.config.js8
-rw-r--r--packages/backend/jest.js20
-rw-r--r--packages/backend/migration/1743403874305-DeliverSuspendedSoftware.js16
-rw-r--r--packages/backend/migration/1745378064470-composite-note-index.js19
-rw-r--r--packages/backend/migration/js/migration-config.js8
-rw-r--r--packages/backend/ormconfig.js2
-rw-r--r--packages/backend/package.json17
-rw-r--r--packages/backend/src/GlobalModule.ts9
-rw-r--r--packages/backend/src/core/AchievementService.ts82
-rw-r--r--packages/backend/src/core/FanoutTimelineEndpointService.ts18
-rw-r--r--packages/backend/src/core/QueryService.ts61
-rw-r--r--packages/backend/src/core/SearchService.ts9
-rw-r--r--packages/backend/src/core/UtilityService.ts20
-rw-r--r--packages/backend/src/core/entities/InstanceEntityService.ts5
-rw-r--r--packages/backend/src/misc/json-schema.ts3
-rw-r--r--packages/backend/src/models/Meta.ts10
-rw-r--r--packages/backend/src/models/Note.ts11
-rw-r--r--packages/backend/src/models/UserProfile.ts83
-rw-r--r--packages/backend/src/models/json-schema/achievement.ts25
-rw-r--r--packages/backend/src/models/json-schema/federation-instance.ts2
-rw-r--r--packages/backend/src/models/json-schema/notification.ts5
-rw-r--r--packages/backend/src/models/json-schema/user.ts13
-rw-r--r--packages/backend/src/queue/processors/DeliverProcessorService.ts13
-rw-r--r--packages/backend/src/server/ServerService.ts16
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts166
-rw-r--r--packages/backend/src/server/api/endpoint-base.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts19
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts15
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/channels/followed.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/clips/notes.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/claim-achievement.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/notes/children.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/featured.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/local-timeline.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/mentions.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/renotes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/replies.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/achievements.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/users/featured-notes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/notes.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/reactions.ts8
-rw-r--r--packages/backend/test-federation/README.md6
-rw-r--r--packages/backend/test-federation/compose.tpl.yml2
-rw-r--r--packages/backend/test-federation/compose.yml8
-rw-r--r--packages/backend/test/e2e/api.ts4
-rw-r--r--packages/backend/test/e2e/clips.ts4
-rw-r--r--packages/backend/test/unit/entities/UserEntityService.ts2
-rw-r--r--packages/backend/test/unit/server/api/drive/files/create.ts102
-rw-r--r--packages/frontend-embed/package.json2
-rw-r--r--packages/frontend-embed/src/pages/not-found.vue4
-rw-r--r--packages/frontend-embed/src/style.scss7
-rw-r--r--packages/frontend-shared/js/const.ts4
-rw-r--r--packages/frontend/package.json2
-rw-r--r--packages/frontend/src/components/MkAnnouncementDialog.vue62
-rw-r--r--packages/frontend/src/components/MkChannelList.vue8
-rw-r--r--packages/frontend/src/components/MkChatHistories.vue4
-rw-r--r--packages/frontend/src/components/MkDialog.vue33
-rw-r--r--packages/frontend/src/components/MkFolder.vue56
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue6
-rw-r--r--packages/frontend/src/components/MkInstanceTicker.vue18
-rw-r--r--packages/frontend/src/components/MkNotes.vue8
-rw-r--r--packages/frontend/src/components/MkNotification.vue2
-rw-r--r--packages/frontend/src/components/MkNotifications.vue48
-rw-r--r--packages/frontend/src/components/MkPagination.vue8
-rw-r--r--packages/frontend/src/components/MkPullToRefresh.vue200
-rw-r--r--packages/frontend/src/components/MkSwiper.vue27
-rw-r--r--packages/frontend/src/components/MkTimeline.vue53
-rw-r--r--packages/frontend/src/components/MkUserList.vue8
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue16
-rw-r--r--packages/frontend/src/components/global/MkError.vue30
-rw-r--r--packages/frontend/src/components/global/MkResult.stories.impl.ts57
-rw-r--r--packages/frontend/src/components/global/MkResult.vue53
-rw-r--r--packages/frontend/src/components/global/MkSystemIcon.vue109
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue3
-rw-r--r--packages/frontend/src/components/index.ts6
-rw-r--r--packages/frontend/src/instance.ts7
-rw-r--r--packages/frontend/src/pages/_error_.vue4
-rw-r--r--packages/frontend/src/pages/admin/modlog.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue8
-rw-r--r--packages/frontend/src/pages/admin/settings.vue76
-rw-r--r--packages/frontend/src/pages/chat/home.invitations.vue4
-rw-r--r--packages/frontend/src/pages/chat/home.joiningRooms.vue4
-rw-r--r--packages/frontend/src/pages/chat/home.ownedRooms.vue4
-rw-r--r--packages/frontend/src/pages/chat/room.search.vue6
-rw-r--r--packages/frontend/src/pages/debug.vue66
-rw-r--r--packages/frontend/src/pages/drive.file.info.vue6
-rw-r--r--packages/frontend/src/pages/favorites.vue8
-rw-r--r--packages/frontend/src/pages/follow-requests.vue8
-rw-r--r--packages/frontend/src/pages/instance-info.vue4
-rw-r--r--packages/frontend/src/pages/invite.vue30
-rw-r--r--packages/frontend/src/pages/list.vue9
-rw-r--r--packages/frontend/src/pages/my-antennas/index.vue8
-rw-r--r--packages/frontend/src/pages/my-lists/index.vue8
-rw-r--r--packages/frontend/src/pages/not-found.vue8
-rw-r--r--packages/frontend/src/pages/role.vue41
-rw-r--r--packages/frontend/src/pages/settings/apps.vue8
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue23
-rw-r--r--packages/frontend/src/pages/settings/preferences.vue12
-rw-r--r--packages/frontend/src/pages/settings/profiles.vue47
-rw-r--r--packages/frontend/src/preferences/def.ts3
-rw-r--r--packages/frontend/src/preferences/utility.ts40
-rw-r--r--packages/frontend/src/router.definition.ts21
-rw-r--r--packages/frontend/src/style.scss23
-rw-r--r--packages/frontend/src/ui/universal.vue2
-rw-r--r--packages/frontend/src/use/use-form.ts3
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.vue12
-rw-r--r--packages/frontend/src/widgets/WidgetRss.vue6
-rw-r--r--packages/frontend/tsconfig.json1
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md8
-rw-r--r--packages/misskey-js/package.json2
-rw-r--r--packages/misskey-js/src/autogen/models.ts2
-rw-r--r--packages/misskey-js/src/autogen/types.ts33
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 */