summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/.eslintrc.cjs2
-rw-r--r--packages/backend/check_connect.js6
-rw-r--r--packages/backend/jest.config.cjs16
-rw-r--r--packages/backend/migration/1677570181236-role-assignment-expires-at.js13
-rw-r--r--packages/backend/package.json24
-rw-r--r--packages/backend/src/GlobalModule.ts9
-rw-r--r--packages/backend/src/boot/common.ts4
-rw-r--r--packages/backend/src/core/AntennaService.ts18
-rw-r--r--packages/backend/src/core/CreateNotificationService.ts37
-rw-r--r--packages/backend/src/core/DownloadService.ts20
-rw-r--r--packages/backend/src/core/DriveService.ts44
-rw-r--r--packages/backend/src/core/NoteCreateService.ts16
-rw-r--r--packages/backend/src/core/NoteReadService.ts47
-rw-r--r--packages/backend/src/core/RoleService.ts77
-rw-r--r--packages/backend/src/core/WebhookService.ts3
-rw-r--r--packages/backend/src/core/chart/ChartManagementService.ts8
-rw-r--r--packages/backend/src/core/chart/charts/per-user-notes.ts4
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts28
-rw-r--r--packages/backend/src/core/entities/GalleryPostEntityService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts24
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts84
-rw-r--r--packages/backend/src/core/entities/RoleEntityService.ts13
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts14
-rw-r--r--packages/backend/src/misc/correct-filename.ts15
-rw-r--r--packages/backend/src/misc/is-mime-image.ts2
-rw-r--r--packages/backend/src/misc/is-not-null.ts5
-rw-r--r--packages/backend/src/misc/schema.ts17
-rw-r--r--packages/backend/src/models/entities/RoleAssignment.ts6
-rw-r--r--packages/backend/src/queue/processors/CleanProcessorService.ts18
-rw-r--r--packages/backend/src/server/FileServerService.ts68
-rw-r--r--packages/backend/src/server/ServerService.ts36
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts12
-rw-r--r--packages/backend/src/server/api/ApiServerService.ts32
-rw-r--r--packages/backend/src/server/api/endpoints.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/drive/show-file.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/assign.ts29
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/unassign.ts22
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/users.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/show.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.test.ts263
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts100
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts29
-rw-r--r--packages/backend/src/server/api/endpoints/pages/show.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/roles/users.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/users/followers.ts27
-rw-r--r--packages/backend/src/server/api/endpoints/users/following.ts27
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts17
-rw-r--r--packages/backend/src/server/api/endpoints/users/show.ts40
-rw-r--r--packages/backend/src/server/api/stream/types.ts9
-rw-r--r--packages/backend/src/server/web/boot.js7
-rw-r--r--packages/backend/test/e2e/api-visibility.ts (renamed from packages/backend/test/_e2e/api-visibility.ts)32
-rw-r--r--packages/backend/test/e2e/api.ts (renamed from packages/backend/test/_e2e/api.ts)52
-rw-r--r--packages/backend/test/e2e/block.ts (renamed from packages/backend/test/_e2e/block.ts)22
-rw-r--r--packages/backend/test/e2e/endpoints.ts (renamed from packages/backend/test/_e2e/endpoints.ts)309
-rw-r--r--packages/backend/test/e2e/fetch-resource.ts (renamed from packages/backend/test/_e2e/fetch-resource.ts)42
-rw-r--r--packages/backend/test/e2e/ff-visibility.ts (renamed from packages/backend/test/_e2e/ff-visibility.ts)64
-rw-r--r--packages/backend/test/e2e/mute.ts (renamed from packages/backend/test/_e2e/mute.ts)30
-rw-r--r--packages/backend/test/e2e/note.ts (renamed from packages/backend/test/_e2e/note.ts)214
-rw-r--r--packages/backend/test/e2e/streaming.ts (renamed from packages/backend/test/_e2e/streaming.ts)88
-rw-r--r--packages/backend/test/e2e/thread-mute.ts (renamed from packages/backend/test/_e2e/thread-mute.ts)30
-rw-r--r--packages/backend/test/e2e/user-notes.ts (renamed from packages/backend/test/_e2e/user-notes.ts)14
-rw-r--r--packages/backend/test/prelude/get-api-validator.ts11
-rw-r--r--packages/backend/test/resources/misskey.svg7
-rw-r--r--packages/backend/test/tsconfig.json3
-rw-r--r--packages/backend/test/unit/RoleService.ts75
-rw-r--r--packages/backend/test/unit/misc/others.ts42
-rw-r--r--packages/backend/test/utils.ts203
-rw-r--r--packages/backend/tsconfig.json7
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-aec.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-cea.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-ea.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/assets/sounds/syuilo/n-eca.mp3bin0 -> 26880 bytes
-rw-r--r--packages/frontend/package.json27
-rw-r--r--packages/frontend/src/components/MkCropperDialog.vue4
-rw-r--r--packages/frontend/src/components/MkFolder.vue94
-rw-r--r--packages/frontend/src/components/MkMediaList.vue40
-rw-r--r--packages/frontend/src/components/MkMenu.vue2
-rw-r--r--packages/frontend/src/components/MkModal.vue2
-rw-r--r--packages/frontend/src/components/MkNote.vue119
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue25
-rw-r--r--packages/frontend/src/components/MkNoteHeader.vue1
-rw-r--r--packages/frontend/src/components/MkPagination.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue19
-rw-r--r--packages/frontend/src/components/MkSignup.vue1
-rw-r--r--packages/frontend/src/components/MkTab.vue2
-rw-r--r--packages/frontend/src/components/MkToast.vue2
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue222
-rw-r--r--packages/frontend/src/components/MkUserPreview.vue199
-rw-r--r--packages/frontend/src/components/MkWindow.vue4
-rw-r--r--packages/frontend/src/components/form/section.vue42
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue2
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.vue42
-rw-r--r--packages/frontend/src/const.ts25
-rw-r--r--packages/frontend/src/custom-emojis.ts23
-rw-r--r--packages/frontend/src/directives/container.ts21
-rw-r--r--packages/frontend/src/directives/index.ts2
-rw-r--r--packages/frontend/src/directives/user-preview.ts2
-rw-r--r--packages/frontend/src/local-storage.ts6
-rw-r--r--packages/frontend/src/navbar.ts8
-rw-r--r--packages/frontend/src/os.ts10
-rw-r--r--packages/frontend/src/pages/about-misskey.vue11
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue31
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue24
-rw-r--r--packages/frontend/src/pages/admin/roles.edit.vue57
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue348
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue94
-rw-r--r--packages/frontend/src/pages/admin/roles.vue16
-rw-r--r--packages/frontend/src/pages/admin/settings.vue24
-rw-r--r--packages/frontend/src/pages/admin/users.vue115
-rw-r--r--packages/frontend/src/pages/channel.vue197
-rw-r--r--packages/frontend/src/pages/clip.vue13
-rw-r--r--packages/frontend/src/pages/explore.roles.vue2
-rw-r--r--packages/frontend/src/pages/flash/flash.vue6
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue127
-rw-r--r--packages/frontend/src/pages/search.vue19
-rw-r--r--packages/frontend/src/pages/settings/email.vue7
-rw-r--r--packages/frontend/src/pages/settings/general.vue5
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue4
-rw-r--r--packages/frontend/src/pages/settings/statusbar.vue1
-rw-r--r--packages/frontend/src/pages/user-info.vue26
-rw-r--r--packages/frontend/src/pages/user/home.vue5
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue8
-rw-r--r--packages/frontend/src/plugin.ts70
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts98
-rw-r--r--packages/frontend/src/scripts/hotkey.ts6
-rw-r--r--packages/frontend/src/scripts/keycode.ts15
-rw-r--r--packages/frontend/src/scripts/media-proxy.ts18
-rw-r--r--packages/frontend/src/scripts/page-metadata.ts1
-rw-r--r--packages/frontend/src/scripts/sound.ts21
-rw-r--r--packages/frontend/src/store.ts31
-rw-r--r--packages/frontend/src/style.scss6
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue1
-rw-r--r--packages/frontend/src/ui/deck/column.vue2
-rw-r--r--packages/frontend/src/ui/universal.vue27
-rw-r--r--packages/sw/src/scripts/create-notification.ts26
156 files changed, 3029 insertions, 2046 deletions
diff --git a/packages/backend/.eslintrc.cjs b/packages/backend/.eslintrc.cjs
index 5a06889dcd..f9fe4814e6 100644
--- a/packages/backend/.eslintrc.cjs
+++ b/packages/backend/.eslintrc.cjs
@@ -1,7 +1,7 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
- project: ['./tsconfig.json'],
+ project: ['./tsconfig.json', './test/tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',
diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js
index 8bf134a105..ed429c0254 100644
--- a/packages/backend/check_connect.js
+++ b/packages/backend/check_connect.js
@@ -1,10 +1,10 @@
-import {loadConfig} from './built/config.js';
-import {createRedisConnection} from "./built/redis.js";
+import { loadConfig } from './built/config.js';
+import { createRedisConnection } from './built/redis.js';
const config = loadConfig();
const redis = createRedisConnection(config);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {
- throw e;
+ throw e;
});
diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs
index 2f11f6a3e9..6b1afec734 100644
--- a/packages/backend/jest.config.cjs
+++ b/packages/backend/jest.config.cjs
@@ -20,7 +20,7 @@ module.exports = {
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
- collectCoverageFrom: ['src/**/*.ts'],
+ collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
@@ -91,7 +91,7 @@ module.exports = {
// See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225
// TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can
// directly import `.ts` files without this hack.
- '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
@@ -159,7 +159,8 @@ module.exports = {
// The glob patterns Jest uses to detect test files
testMatch: [
"<rootDir>/test/unit/**/*.ts",
- //"<rootDir>/test/e2e/**/*.ts"
+ "<rootDir>/src/**/*.test.ts",
+ "<rootDir>/test/e2e/**/*.ts",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
@@ -206,4 +207,13 @@ module.exports = {
// watchman: true,
extensionsToTreatAsEsm: ['.ts'],
+
+ testTimeout: 60000,
+
+ // Let Jest kill the test worker whenever it grows too much
+ // (It seems there's a known memory leak issue in Node.js' vm.Script used by Jest)
+ // https://github.com/facebook/jest/issues/11956
+ maxWorkers: 1, // Make it use worker (that can be killed and restarted)
+ logHeapUsage: true, // To debug when out-of-memory happens on CI
+ workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
};
diff --git a/packages/backend/migration/1677570181236-role-assignment-expires-at.js b/packages/backend/migration/1677570181236-role-assignment-expires-at.js
new file mode 100644
index 0000000000..3ac2edab0a
--- /dev/null
+++ b/packages/backend/migration/1677570181236-role-assignment-expires-at.js
@@ -0,0 +1,13 @@
+export class roleAssignmentExpiresAt1677570181236 {
+ name = 'roleAssignmentExpiresAt1677570181236'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "role_assignment" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`CREATE INDEX "IDX_539b6c08c05067599743bb6389" ON "role_assignment" ("expiresAt") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_539b6c08c05067599743bb6389"`);
+ await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "expiresAt"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 4d1d37efff..35e8dc5c60 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -15,8 +15,8 @@
"typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.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 --runInBand --detectOpenHandles",
- "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand --detectOpenHandles",
+ "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit",
+ "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage"
@@ -80,7 +80,7 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.5.3",
- "happy-dom": "^8.7.0",
+ "happy-dom": "8.9.0",
"hpagent": "1.2.0",
"ioredis": "4.28.5",
"ip-cidr": "3.1.0",
@@ -88,7 +88,7 @@
"js-yaml": "4.1.0",
"jsdom": "21.1.0",
"json5": "2.2.3",
- "jsonld": "8.1.0",
+ "jsonld": "8.1.1",
"jsrsasign": "10.6.1",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
@@ -124,10 +124,11 @@
"seedrandom": "3.0.5",
"semver": "7.3.8",
"sharp": "0.31.3",
+ "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
- "systeminformation": "5.17.9",
+ "systeminformation": "5.17.10",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.2",
@@ -146,7 +147,6 @@
},
"devDependencies": {
"@jest/globals": "29.4.3",
- "@redocly/openapi-core": "1.0.0-beta.123",
"@swc/jest": "0.2.24",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.1",
@@ -156,7 +156,7 @@
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
- "@types/fluent-ffmpeg": "2.1.20",
+ "@types/fluent-ffmpeg": "2.1.21",
"@types/ioredis": "4.28.10",
"@types/jest": "29.4.0",
"@types/js-yaml": "4.0.5",
@@ -164,7 +164,7 @@
"@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.5",
"@types/mime-types": "2.1.1",
- "@types/node": "18.14.0",
+ "@types/node": "18.14.1",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7",
"@types/oauth": "0.9.1",
@@ -183,18 +183,18 @@
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5",
- "@types/uuid": "9.0.0",
+ "@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.52.0",
- "@typescript-eslint/parser": "5.52.0",
+ "@typescript-eslint/parser": "5.53.0",
"cross-env": "7.0.3",
- "eslint": "8.34.0",
+ "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5",
"execa": "6.1.0",
"jest": "29.4.3",
"jest-mock": "29.4.3"
}
-} \ No newline at end of file
+}
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts
index 35416209a0..801f1db741 100644
--- a/packages/backend/src/GlobalModule.ts
+++ b/packages/backend/src/GlobalModule.ts
@@ -1,3 +1,4 @@
+import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
@@ -57,6 +58,14 @@ export class GlobalModule implements OnApplicationShutdown {
) {}
async onApplicationShutdown(signal: string): Promise<void> {
+ if (process.env.NODE_ENV === 'test') {
+ // XXX:
+ // Shutting down the existing connections causes errors on Jest as
+ // Misskey has asynchronous postgres/redis connections that are not
+ // awaited.
+ // Let's wait for some random time for them to finish.
+ await setTimeout(5000);
+ }
await Promise.all([
this.db.destroy(),
this.redisClient.disconnect(),
diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index 04aa26e652..279a1fe59d 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -16,12 +16,14 @@ export async function server() {
app.enableShutdownHooks();
const serverService = app.get(ServerService);
- serverService.launch();
+ await serverService.launch();
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
+
+ return app;
}
export async function jobQueue() {
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 0e72545934..05930350fa 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -171,13 +171,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0);
if (keywords.length > 0) {
- if (note.text == null) return false;
+ if (note.text == null && note.cw == null) return false;
+
+ const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
- ? note.text!.includes(keyword)
- : note.text!.toLowerCase().includes(keyword.toLowerCase()),
+ ? _text.includes(keyword)
+ : _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (!matched) return false;
@@ -189,13 +191,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0);
if (excludeKeywords.length > 0) {
- if (note.text == null) return false;
-
+ if (note.text == null && note.cw == null) return false;
+
+ const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
+
const matched = excludeKeywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
- ? note.text!.includes(keyword)
- : note.text!.toLowerCase().includes(keyword.toLowerCase()),
+ ? _text.includes(keyword)
+ : _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (matched) return false;
diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts
index cd47844a75..eba7171fb6 100644
--- a/packages/backend/src/core/CreateNotificationService.ts
+++ b/packages/backend/src/core/CreateNotificationService.ts
@@ -1,4 +1,5 @@
-import { Inject, Injectable } from '@nestjs/common';
+import { setTimeout } from 'node:timers/promises';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js';
@@ -10,7 +11,9 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.js';
@Injectable()
-export class CreateNotificationService {
+export class CreateNotificationService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -40,11 +43,11 @@ export class CreateNotificationService {
if (data.notifierId && (notifieeId === data.notifierId)) {
return null;
}
-
+
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
-
+
const isMuted = profile?.mutingNotificationTypes.includes(type);
-
+
// Create notification
const notification = await this.notificationsRepository.insert({
id: this.idService.genId(),
@@ -56,18 +59,18 @@ export class CreateNotificationService {
...data,
} as Partial<Notification>)
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
-
+
const packed = await this.notificationEntityService.pack(notification, {});
-
+
// Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
-
+
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
- setTimeout(async () => {
+ setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
if (fresh == null) return; // 既に削除されているかもしれない
if (fresh.isRead) return;
-
+
//#region ただしミュートしているユーザーからの通知なら無視
const mutings = await this.mutingsRepository.findBy({
muterId: notifieeId,
@@ -76,14 +79,14 @@ export class CreateNotificationService {
return;
}
//#endregion
-
+
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
-
+
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
- }, 2000);
-
+ }, () => { /* aborted, ignore it */ });
+
return notification;
}
@@ -103,7 +106,7 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
-
+
@bindThis
private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
/*
@@ -115,4 +118,8 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
+
+ onApplicationShutdown(signal?: string | undefined): void {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts
index 852c1f32e3..bd999c67da 100644
--- a/packages/backend/src/core/DownloadService.ts
+++ b/packages/backend/src/core/DownloadService.ts
@@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import chalk from 'chalk';
import got, * as Got from 'got';
+import { parse } from 'content-disposition';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -32,13 +33,18 @@ export class DownloadService {
}
@bindThis
- public async downloadUrl(url: string, path: string): Promise<void> {
+ public async downloadUrl(url: string, path: string): Promise<{
+ filename: string;
+ }> {
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000;
+ const urlObj = new URL(url);
+ let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
+
const req = got.stream(url, {
headers: {
'User-Agent': this.config.userAgent,
@@ -77,6 +83,14 @@ export class DownloadService {
req.destroy();
}
}
+
+ const contentDisposition = res.headers['content-disposition'];
+ if (contentDisposition != null) {
+ const parsed = parse(contentDisposition);
+ if (parsed.parameters.filename) {
+ filename = parsed.parameters.filename;
+ }
+ }
}).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
@@ -95,6 +109,10 @@ export class DownloadService {
}
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
+
+ return {
+ filename,
+ };
}
@bindThis
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index b15c967c85..f4a06faebb 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type S3 from 'aws-sdk/clients/s3.js';
+import { correctFilename } from '@/misc/correct-filename.js';
type AddFileArgs = {
/** User who wish to add file */
@@ -168,7 +169,7 @@ export class DriveService {
//#region Uploads
this.registerLogger.info(`uploading original: ${key}`);
const uploads = [
- this.upload(key, fs.createReadStream(path), type, name),
+ this.upload(key, fs.createReadStream(path), type, ext, name),
];
if (alts.webpublic) {
@@ -176,7 +177,7 @@ export class DriveService {
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
- uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
+ uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
}
if (alts.thumbnail) {
@@ -184,7 +185,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
- uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
+ uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext));
}
await Promise.all(uploads);
@@ -360,7 +361,7 @@ export class DriveService {
* Upload to ObjectStorage
*/
@bindThis
- private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
+ private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) {
if (type === 'image/apng') type = 'image/png';
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
@@ -374,7 +375,12 @@ export class DriveService {
CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest;
- if (filename) params.ContentDisposition = contentDisposition('inline', filename);
+ if (filename) params.ContentDisposition = contentDisposition(
+ 'inline',
+ // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
+ // 許可されているファイル形式でしか拡張子をつけない
+ ext ? correctFilename(filename, ext) : filename,
+ );
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta);
@@ -466,7 +472,12 @@ export class DriveService {
//}
// detect name
- const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
+ const detectedName = correctFilename(
+ // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
+ // extを付加してデータベースの文字数制限に当たることはまずない
+ (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
+ info.type.ext
+ );
if (user && !force) {
// Check if there is a file with the same hash
@@ -736,24 +747,19 @@ export class DriveService {
requestIp = null,
requestHeaders = null,
}: UploadFromUrlArgs): Promise<DriveFile> {
- let name = new URL(url).pathname.split('/').pop() ?? null;
- if (name == null || !this.driveFileEntityService.validateFileName(name)) {
- name = null;
- }
-
- // If the comment is same as the name, skip comment
- // (image.name is passed in when receiving attachment)
- if (comment !== null && name === comment) {
- comment = null;
- }
-
// Create temp file
const [path, cleanup] = await createTemp();
try {
// write content at URL to temp file
- await this.downloadService.downloadUrl(url, path);
-
+ const { filename: name } = await this.downloadService.downloadUrl(url, path);
+
+ // If the comment is same as the name, skip comment
+ // (image.name is passed in when receiving attachment)
+ if (comment !== null && name === comment) {
+ comment = null;
+ }
+
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
return driveFile!;
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 54c135a7c5..4c4261ba79 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -1,6 +1,7 @@
+import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm';
-import { Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
@@ -137,7 +138,9 @@ type Option = {
};
@Injectable()
-export class NoteCreateService {
+export class NoteCreateService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.config)
private config: Config,
@@ -313,7 +316,10 @@ export class NoteCreateService {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
- setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
+ setImmediate('post created', { signal: this.#shutdownController.signal }).then(
+ () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
+ () => { /* aborted, ignore this */ },
+ );
return note;
}
@@ -756,4 +762,8 @@ export class NoteCreateService {
return mentionedUsers;
}
+
+ onApplicationShutdown(signal?: string | undefined) {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
index 84983d600e..d23fb8238b 100644
--- a/packages/backend/src/core/NoteReadService.ts
+++ b/packages/backend/src/core/NoteReadService.ts
@@ -1,4 +1,5 @@
-import { Inject, Injectable } from '@nestjs/common';
+import { setTimeout } from 'node:timers/promises';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In, IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js';
@@ -15,7 +16,9 @@ import { AntennaService } from './AntennaService.js';
import { PushNotificationService } from './PushNotificationService.js';
@Injectable()
-export class NoteReadService {
+export class NoteReadService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -60,14 +63,14 @@ export class NoteReadService {
});
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
-
+
// スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
userId: userId,
threadId: note.threadId ?? note.id,
});
if (threadMute) return;
-
+
const unread = {
id: this.idService.genId(),
noteId: note.id,
@@ -77,15 +80,15 @@ export class NoteReadService {
noteChannelId: note.channelId,
noteUserId: note.userId,
};
-
+
await this.noteUnreadsRepository.insert(unread);
-
+
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
- setTimeout(async () => {
+ setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
-
+
if (exist == null) return;
-
+
if (params.isMentioned) {
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
}
@@ -95,8 +98,8 @@ export class NoteReadService {
if (note.channelId) {
this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id);
}
- }, 2000);
- }
+ }, () => { /* aborted, ignore it */ });
+ }
@bindThis
public async read(
@@ -113,24 +116,24 @@ export class NoteReadService {
},
select: ['followeeId'],
})).map(x => x.followeeId));
-
+
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const readMentions: (Note | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
-
+
for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note);
}
-
+
if (note.channelId && followingChannels.has(note.channelId)) {
readChannelNotes.push(note);
}
-
+
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) {
if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) {
@@ -139,14 +142,14 @@ export class NoteReadService {
}
}
}
-
+
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
// Remove the record
await this.noteUnreadsRepository.delete({
userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
});
-
+
// TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({
@@ -183,7 +186,7 @@ export class NoteReadService {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
}
-
+
if (readAntennaNotes.length > 0) {
await this.antennaNotesRepository.update({
antennaId: In(myAntennas.map(a => a.id)),
@@ -191,14 +194,14 @@ export class NoteReadService {
}, {
read: true,
});
-
+
// TODO: まとめてクエリしたい
for (const antenna of myAntennas) {
const count = await this.antennaNotesRepository.countBy({
antennaId: antenna.id,
read: false,
});
-
+
if (count === 0) {
this.globalEventService.publishMainStream(userId, 'readAntenna', antenna);
this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id });
@@ -213,4 +216,8 @@ export class NoteReadService {
});
}
}
+
+ onApplicationShutdown(signal?: string | undefined): void {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index b84d5e7585..7149591198 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -11,6 +11,8 @@ import { UserCacheService } from '@/core/UserCacheService.js';
import type { RoleCondFormulaValue } from '@/models/entities/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -56,6 +58,9 @@ export class RoleService implements OnApplicationShutdown {
private rolesCache: Cache<Role[]>;
private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>;
+ public static AlreadyAssignedError = class extends Error {};
+ public static NotAssignedError = class extends Error {};
+
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@@ -72,6 +77,8 @@ export class RoleService implements OnApplicationShutdown {
private metaService: MetaService,
private userCacheService: UserCacheService,
private userEntityService: UserEntityService,
+ private globalEventService: GlobalEventService,
+ private idService: IdService,
) {
//this.onMessage = this.onMessage.bind(this);
@@ -128,6 +135,7 @@ export class RoleService implements OnApplicationShutdown {
cached.push({
...body,
createdAt: new Date(body.createdAt),
+ expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
});
}
break;
@@ -193,7 +201,10 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public async getUserRoles(userId: User['id']) {
- const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ const now = Date.now();
+ let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ // 期限切れのロールを除外
+ assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
@@ -207,7 +218,10 @@ export class RoleService implements OnApplicationShutdown {
*/
@bindThis
public async getUserBadgeRoles(userId: User['id']) {
- const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ const now = Date.now();
+ let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ // 期限切れのロールを除外
+ assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
@@ -317,6 +331,65 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
+ public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise<void> {
+ const now = new Date();
+
+ const existing = await this.roleAssignmentsRepository.findOneBy({
+ roleId: roleId,
+ userId: userId,
+ });
+
+ if (existing) {
+ if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
+ await this.roleAssignmentsRepository.delete({
+ roleId: roleId,
+ userId: userId,
+ });
+ } else {
+ throw new RoleService.AlreadyAssignedError();
+ }
+ }
+
+ const created = await this.roleAssignmentsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: now,
+ expiresAt: expiresAt,
+ roleId: roleId,
+ userId: userId,
+ }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
+
+ this.rolesRepository.update(roleId, {
+ lastUsedAt: new Date(),
+ });
+
+ this.globalEventService.publishInternalEvent('userRoleAssigned', created);
+ }
+
+ @bindThis
+ public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> {
+ const now = new Date();
+
+ const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
+ if (existing == null) {
+ throw new RoleService.NotAssignedError();
+ } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
+ await this.roleAssignmentsRepository.delete({
+ roleId: roleId,
+ userId: userId,
+ });
+ throw new RoleService.NotAssignedError();
+ }
+
+ await this.roleAssignmentsRepository.delete(existing.id);
+
+ this.rolesRepository.update(roleId, {
+ lastUsedAt: now,
+ });
+
+ this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
+ }
+
+ @bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
}
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
index 30caa9682c..ac1e413de6 100644
--- a/packages/backend/src/core/WebhookService.ts
+++ b/packages/backend/src/core/WebhookService.ts
@@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks.push({
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
});
}
break;
@@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks[i] = {
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
};
} else {
this.webhooks.push({
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
});
}
} else {
diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts
index dbde757676..03e3612658 100644
--- a/packages/backend/src/core/chart/ChartManagementService.ts
+++ b/packages/backend/src/core/chart/ChartManagementService.ts
@@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown {
async onApplicationShutdown(signal: string): Promise<void> {
clearInterval(this.saveIntervalId);
- await Promise.all(
- this.charts.map(chart => chart.save()),
- );
+ if (process.env.NODE_ENV !== 'test') {
+ await Promise.all(
+ this.charts.map(chart => chart.save()),
+ );
+ }
}
}
diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts
index 1e2a579dfa..d8966f34c1 100644
--- a/packages/backend/src/core/chart/charts/per-user-notes.ts
+++ b/packages/backend/src/core/chart/charts/per-user-notes.ts
@@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart<typeof schema> {
}
@bindThis
- public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise<void> {
- await this.commit({
+ public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void {
+ this.commit({
'total': isAdditional ? 1 : -1,
'inc': isAdditional ? 1 : 0,
'dec': isAdditional ? 0 : 1,
diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts
index 158fafa9d5..f769ddd5e9 100644
--- a/packages/backend/src/core/entities/DriveFileEntityService.ts
+++ b/packages/backend/src/core/entities/DriveFileEntityService.ts
@@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
-import { DataSource } from 'typeorm';
+import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -21,6 +21,7 @@ type PackOptions = {
};
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
+import { isNotNull } from '@/misc/is-not-null.js';
@Injectable()
export class DriveFileEntityService {
@@ -255,10 +256,33 @@ export class DriveFileEntityService {
@bindThis
public async packMany(
- files: (DriveFile['id'] | DriveFile)[],
+ files: DriveFile[],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null);
}
+
+ @bindThis
+ public async packManyByIdsMap(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
+ const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
+ const packedFiles = await this.packMany(files, options);
+ const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
+ for (const id of fileIds) {
+ if (!map.has(id)) map.set(id, null);
+ }
+ return map;
+ }
+
+ @bindThis
+ public async packManyByIds(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Packed<'DriveFile'>[]> {
+ const filesMap = await this.packManyByIdsMap(fileIds, options);
+ return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
+ }
}
diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts
index ab29e7dba1..fb147ae181 100644
--- a/packages/backend/src/core/entities/GalleryPostEntityService.ts
+++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts
@@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title,
description: post.description,
fileIds: post.fileIds,
- files: this.driveFileEntityService.packMany(post.fileIds),
+ // TODO: packMany causes N+1 queries
+ files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 2ffe5f1c21..4ec10df9a6 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
+import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
@@ -249,6 +250,21 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
+ public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
+ const missingIds = [];
+ for (const id of fileIds) {
+ if (!packedFiles.has(id)) missingIds.push(id);
+ }
+ if (missingIds.length) {
+ const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
+ for (const [k, v] of additionalMap) {
+ packedFiles.set(k, v);
+ }
+ }
+ return fileIds.map(id => packedFiles.get(id)).filter(isNotNull);
+ }
+
+ @bindThis
public async pack(
src: Note['id'] | Note,
me?: { id: User['id'] } | null | undefined,
@@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean;
_hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
+ packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
};
},
): Promise<Packed<'Note'>> {
@@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
+ const packedFiles = options?._hint_?.packedFiles;
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
@@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds,
- files: this.driveFileEntityService.packMany(note.fileIds),
+ files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId ?? undefined,
@@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit {
}
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
+ const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
+ const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
_hint_: {
myReactions: myReactionsMap,
+ packedFiles,
},
})));
}
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index 33c76c6937..be88a213f4 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -1,19 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
-import { In } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js';
-import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { Note } from '@/models/entities/Note.js';
import type { Packed } from '@/misc/schema.js';
import { bindThis } from '@/decorators.js';
+import { isNotNull } from '@/misc/is-not-null.js';
+import { notificationTypes } from '@/types.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
+
@Injectable()
export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit {
public async pack(
src: Notification['id'] | Notification,
options: {
- _hintForEachNotes_?: {
- myReactions: Map<Note['id'], NoteReaction | null>;
+ _hint_?: {
+ packedNotes: Map<Note['id'], Packed<'Note'>>;
};
},
): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src });
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
+ const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
+ options._hint_?.packedNotes != null
+ ? options._hint_.packedNotes.get(notification.noteId)
+ : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
+ detail: true,
+ })
+ ) : undefined;
return await awaitAll({
id: notification.id,
@@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit {
isRead: notification.isRead,
userId: notification.notifierId,
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
- ...(notification.type === 'mention' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'reply' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'renote' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'quote' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
+ ...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
reaction: notification.reaction,
} : {}),
- ...(notification.type === 'pollEnded' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
@@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit {
});
}
+ /**
+ * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
+ */
@bindThis
public async packMany(
notifications: Notification[],
meId: User['id'],
) {
if (notifications.length === 0) return [];
-
- const notes = notifications.filter(x => x.note != null).map(x => x.note!);
- const noteIds = notes.map(n => n.id);
- const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
- const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
- const targets = [...noteIds, ...renoteIds];
- const myReactions = await this.noteReactionsRepository.findBy({
- userId: meId,
- noteId: In(targets),
- });
-
- for (const target of targets) {
- myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null);
+
+ for (const notification of notifications) {
+ if (meId !== notification.notifieeId) {
+ // because we call note packMany with meId, all notifieeId should be same as meId
+ throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
+ }
}
- await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ const notes = notifications.map(x => x.note).filter(isNotNull);
+ const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
+ detail: true,
+ });
+ const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, {
- _hintForEachNotes_: {
- myReactions: myReactionsMap,
+ _hint_: {
+ packedNotes,
},
})));
}
diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index 80ef5ac1fa..2f1d51fa1a 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
@@ -28,9 +29,13 @@ export class RoleEntityService {
) {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
- const assigns = await this.roleAssignmentsRepository.findBy({
- roleId: role.id,
- });
+ const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
+ .where('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
+ .getCount();
const policies = { ...role.policies };
for (const [k, v] of Object.entries(DEFAULT_POLICIES)) {
@@ -57,7 +62,7 @@ export class RoleEntityService {
asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies,
- usersCount: assigns.length,
+ usersCount: assignedCount,
});
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 8c36e47f1b..3635643218 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
- return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
public getAvatarUrlSync(user: User): string {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
- public getIdenticonUrl(userId: User['id']): string {
- return `${this.config.url}/identicon/${userId}`;
+ public getIdenticonUrl(user: User): string {
+ return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
}
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts
new file mode 100644
index 0000000000..3357d8c1bd
--- /dev/null
+++ b/packages/backend/src/misc/correct-filename.ts
@@ -0,0 +1,15 @@
+// 与えられた拡張子とファイル名が一致しているかどうかを確認し、
+// 一致していない場合は拡張子を付与して返す
+export function correctFilename(filename: string, ext: string | null) {
+ const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
+ if (filename.endsWith(dotExt)) {
+ return filename;
+ }
+ if (ext === 'jpg' && filename.endsWith('.jpeg')) {
+ return filename;
+ }
+ if (ext === 'tif' && filename.endsWith('.tiff')) {
+ return filename;
+ }
+ return `${filename}${dotExt}`;
+}
diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts
index acf5c1ede3..0b6d147dc1 100644
--- a/packages/backend/src/misc/is-mime-image.ts
+++ b/packages/backend/src/misc/is-mime-image.ts
@@ -4,6 +4,8 @@ const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
+ 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
+ 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);
diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts
new file mode 100644
index 0000000000..d89a1957be
--- /dev/null
+++ b/packages/backend/src/misc/is-not-null.ts
@@ -0,0 +1,5 @@
+// we are using {} as "any non-nullish value" as expected
+// eslint-disable-next-line @typescript-eslint/ban-types
+export function isNotNull<T extends {}>(input: T | undefined | null): input is T {
+ return input != null;
+}
diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts
index 7fc4a3e654..6a0802f8a4 100644
--- a/packages/backend/src/misc/schema.ts
+++ b/packages/backend/src/misc/schema.ts
@@ -116,10 +116,10 @@ export type Obj = Record<string, Schema>;
// https://github.com/misskey-dev/misskey/issues/8535
// To avoid excessive stack depth error,
// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it).
-export type ObjType<s extends Obj, RequiredProps extends keyof s> =
+export type ObjType<s extends Obj, RequiredProps extends ReadonlyArray<keyof s>> =
UnionToIntersection<
{ -readonly [R in RequiredPropertyNames<s>]-?: SchemaType<s[R]> } &
- { -readonly [R in RequiredProps]-?: SchemaType<s[R]> } &
+ { -readonly [R in RequiredProps[number]]-?: SchemaType<s[R]> } &
{ -readonly [P in keyof s]?: SchemaType<s[P]> }
>;
@@ -136,18 +136,19 @@ type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
// To get union, we use `Foo extends any ? Hoge<Foo> : never`
type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? SchemaType<X> : never;
-type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
+//type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
+type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never;
type ArrayUnion<T> = T extends any ? Array<T> : never;
type ObjectSchemaTypeDef<p extends Schema> =
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
p['properties'] extends NonNullable<Obj> ?
- p['anyOf'] extends ReadonlyArray<Schema> ?
- ObjType<p['properties'], NonNullable<p['required']>[number]> & UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>>
- :
- ObjType<p['properties'], NonNullable<p['required']>[number]>
+ p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
+ UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
+ : never
+ : ObjType<p['properties'], NonNullable<p['required']>>
:
- p['anyOf'] extends ReadonlyArray<Schema> ? UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>> :
+ p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
any
diff --git a/packages/backend/src/models/entities/RoleAssignment.ts b/packages/backend/src/models/entities/RoleAssignment.ts
index e86f2a8999..972810940f 100644
--- a/packages/backend/src/models/entities/RoleAssignment.ts
+++ b/packages/backend/src/models/entities/RoleAssignment.ts
@@ -39,4 +39,10 @@ export class RoleAssignment {
})
@JoinColumn()
public role: Role | null;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public expiresAt: Date | null;
}
diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts
index 406184cbde..7fd2cde9c0 100644
--- a/packages/backend/src/queue/processors/CleanProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanProcessorService.ts
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
-import { LessThan } from 'typeorm';
+import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, UserIpsRepository } from '@/models/index.js';
+import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
@@ -29,6 +29,9 @@ export class CleanProcessorService {
@Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository,
+ @Inject(DI.roleAssignmentsRepository)
+ private roleAssignmentsRepository: RoleAssignmentsRepository,
+
private queueLoggerService: QueueLoggerService,
private idService: IdService,
) {
@@ -56,6 +59,17 @@ export class CleanProcessorService {
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
});
+ const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
+ .where('assign.expiresAt IS NOT NULL')
+ .andWhere('assign.expiresAt < :now', { now: new Date() })
+ .getMany();
+
+ if (expiredRoleAssignments.length > 0) {
+ await this.roleAssignmentsRepository.delete({
+ id: In(expiredRoleAssignments.map(x => x.id)),
+ });
+ }
+
this.logger.succ('Cleaned.');
done();
}
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index c12ae9b824..835657b625 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { isMimeImage } from '@/misc/is-mime-image.js';
import sharp from 'sharp';
+import { sharpBmp } from 'sharp-read-bmp';
+import { correctFilename } from '@/misc/correct-filename.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -52,15 +54,6 @@ export class FileServerService {
}
@bindThis
- public commonReadableHandlerGenerator(reply: FastifyReply) {
- return (err: Error): void => {
- this.logger.error(err);
- reply.code(500);
- reply.header('Cache-Control', 'max-age=300');
- };
- }
-
- @bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
@@ -140,7 +133,7 @@ export class FileServerService {
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') {
- if (isMimeImage(file.mime, 'sharp-convertible-image')) {
+ if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/static.webp`);
@@ -190,13 +183,19 @@ export class FileServerService {
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
}
if (file.fileRole !== 'original') {
- const filename = rename(file.file.name, {
+ const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
- extname: file.ext ? `.${file.ext}` : undefined,
+ extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
@@ -204,12 +203,10 @@ export class FileServerService {
reply.header('Content-Disposition', contentDisposition('inline', filename));
return fs.createReadStream(file.path);
} else {
- const stream = fs.createReadStream(file.path);
- stream.on('error', this.commonReadableHandlerGenerator(reply));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
- reply.header('Content-Disposition', contentDisposition('inline', file.file.name));
- return stream;
+ reply.header('Content-Disposition', contentDisposition('inline', file.filename));
+ return fs.createReadStream(file.path);
}
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -226,7 +223,10 @@ export class FileServerService {
return;
}
- if (this.config.externalMediaProxyEnabled) {
+ // アバタークロップなど、どうしてもオリジンである必要がある場合
+ const mustOrigin = 'origin' in request.query;
+
+ if (this.config.externalMediaProxyEnabled && !mustOrigin) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
@@ -258,8 +258,8 @@ export class FileServerService {
}
try {
- const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
- const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
+ const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
+ const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
if (
'emoji' in request.query ||
@@ -283,7 +283,7 @@ export class FileServerService {
type: file.mime,
};
} else {
- const data = sharp(file.path, { animated: !('static' in request.query) })
+ const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@@ -297,11 +297,11 @@ export class FileServerService {
};
}
} else if ('static' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
} else if ('preview' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
- const mask = sharp(file.path)
+ const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@@ -357,6 +357,12 @@ export class FileServerService {
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -366,8 +372,8 @@ export class FileServerService {
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
- { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -383,11 +389,11 @@ export class FileServerService {
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
- { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
- await this.downloadService.downloadUrl(url, path);
+ const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path);
@@ -395,6 +401,7 @@ export class FileServerService {
state: 'remote',
mime, ext,
path, cleanup,
+ filename,
};
} catch (e) {
cleanup();
@@ -404,8 +411,8 @@ export class FileServerService {
@bindThis
private async getFileFromKey(key: string): Promise<
- { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -429,6 +436,7 @@ export class FileServerService {
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
+ filename: file.name,
};
}
@@ -440,6 +448,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
file,
+ filename: file.name,
mime, ext,
path,
};
@@ -449,6 +458,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: 'original',
file,
+ filename: file.name,
mime: file.type,
ext: null,
path,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 8200b24fd4..e61383468c 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -1,7 +1,7 @@
import cluster from 'node:cluster';
import * as fs from 'node:fs';
-import { Inject, Injectable } from '@nestjs/common';
-import Fastify from 'fastify';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import Fastify, { FastifyInstance } from 'fastify';
import { IsNull } from 'typeorm';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js';
@@ -23,8 +23,9 @@ import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
@Injectable()
-export class ServerService {
+export class ServerService implements OnApplicationShutdown {
private logger: Logger;
+ #fastify: FastifyInstance;
constructor(
@Inject(DI.config)
@@ -54,11 +55,12 @@ export class ServerService {
}
@bindThis
- public launch() {
+ public async launch() {
const fastify = Fastify({
trustProxy: true,
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
});
+ this.#fastify = fastify;
// HSTS
// 6months (15552000sec)
@@ -75,7 +77,7 @@ export class ServerService {
fastify.register(this.nodeinfoServerService.createServer);
fastify.register(this.wellKnownServerService.createServer);
- fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
+ fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path;
reply.header('Cache-Control', 'public, max-age=86400');
@@ -105,11 +107,19 @@ export class ServerService {
}
}
- const url = new URL(`${this.config.mediaProxy}/emoji.webp`);
- // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
- url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
- url.searchParams.set('emoji', '1');
- if ('static' in request.query) url.searchParams.set('static', '1');
+ let url: URL;
+ if ('badge' in request.query) {
+ url = new URL(`${this.config.mediaProxy}/emoji.png`);
+ // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
+ url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
+ url.searchParams.set('badge', '1');
+ } else {
+ url = new URL(`${this.config.mediaProxy}/emoji.webp`);
+ // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
+ url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
+ url.searchParams.set('emoji', '1');
+ if ('static' in request.query) url.searchParams.set('static', '1');
+ }
return await reply.redirect(
301,
@@ -195,5 +205,11 @@ export class ServerService {
});
fastify.listen({ port: this.config.port, host: '0.0.0.0' });
+
+ await fastify.ready();
+ }
+
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.#fastify.close();
}
}
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 347fa59d36..f84a3aa59b 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -2,6 +2,7 @@ import { pipeline } from 'node:stream';
import * as fs from 'node:fs';
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
+import { v4 as uuid } from 'uuid';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { LocalUser, User } from '@/models/entities/User.js';
@@ -99,9 +100,12 @@ export class ApiCallService implements OnApplicationShutdown {
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
) {
- const multipartData = await request.file();
+ const multipartData = await request.file().catch(() => {
+ /* Fastify throws if the remote didn't send multipart data. Return 400 below. */
+ });
if (multipartData == null) {
reply.code(400);
+ reply.send();
return;
}
@@ -320,6 +324,7 @@ export class ApiCallService implements OnApplicationShutdown {
if (err instanceof ApiError) {
throw err;
} else {
+ const errId = uuid();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name,
ps: data,
@@ -327,14 +332,15 @@ export class ApiCallService implements OnApplicationShutdown {
message: err.message,
code: err.name,
stack: err.stack,
+ id: errId,
},
});
- console.error(err);
+ console.error(err, errId);
throw new ApiError(null, {
e: {
message: err.message,
code: err.name,
- stack: err.stack,
+ id: errId,
},
});
}
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 2b99da01b6..115d60986c 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -73,28 +73,32 @@ export class ApiServerService {
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
- }>('/' + endpoint.name, (request, reply) => {
+ }>('/' + endpoint.name, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
-
- this.apiCallService.handleMultipartRequest(ep, request, reply);
+
+ // Await so that any error can automatically be translated to HTTP 500
+ await this.apiCallService.handleMultipartRequest(ep, request, reply);
+ return reply;
});
} else {
fastify.all<{
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
- }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, (request, reply) => {
+ }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
-
- this.apiCallService.handleRequest(ep, request, reply);
+
+ // Await so that any error can automatically be translated to HTTP 500
+ await this.apiCallService.handleRequest(ep, request, reply);
+ return reply;
});
}
}
@@ -160,6 +164,22 @@ export class ApiServerService {
}
});
+ // Make sure any unknown path under /api returns HTTP 404 Not Found,
+ // because otherwise ClientServerService will return the base client HTML
+ // page with HTTP 200.
+ fastify.get('*', (request, reply) => {
+ reply.code(404);
+ // Mock ApiCallService.send's error handling
+ reply.send({
+ error: {
+ message: 'Unknown API endpoint.',
+ code: 'UNKNOWN_API_ENDPOINT',
+ id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1',
+ kind: 'client',
+ },
+ });
+ });
+
done();
}
}
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 4d5ed9fb62..4f521148e0 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -741,8 +741,8 @@ export interface IEndpoint {
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return {
name: name,
- meta: ep.meta ?? {},
- params: ep.paramDef,
+ get meta() { return ep.meta ?? {}; },
+ get params() { return ep.paramDef; },
};
});
diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
index 85b566aabe..1d27ac2137 100644
--- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
+++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
@@ -138,19 +138,13 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ fileId: { type: 'string', format: 'misskey:id' },
+ url: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- fileId: { type: 'string', format: 'misskey:id' },
- },
- required: ['fileId'],
- },
- {
- properties: {
- url: { type: 'string' },
- },
- required: ['url'],
- },
+ { required: ['fileId'] },
+ { required: ['url'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 8889f30269..04c58050ff 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -16,7 +16,7 @@ export const meta = {
errors: {
noSuchFile: {
message: 'No such file.',
- code: 'MO_SUCH_FILE',
+ code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
},
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
index 7bfb2f6625..b80aaba122 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
@@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
+import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
@@ -39,6 +37,10 @@ export const paramDef = {
properties: {
roleId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
+ expiresAt: {
+ type: 'integer',
+ nullable: true,
+ },
},
required: [
'roleId',
@@ -56,12 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
- @Inject(DI.roleAssignmentsRepository)
- private roleAssignmentsRepository: RoleAssignmentsRepository,
-
- private globalEventService: GlobalEventService,
private roleService: RoleService,
- private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@@ -78,19 +75,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser);
}
- const date = new Date();
- const created = await this.roleAssignmentsRepository.insert({
- id: this.idService.genId(),
- createdAt: date,
- roleId: role.id,
- userId: user.id,
- }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
+ if (ps.expiresAt && ps.expiresAt <= Date.now()) {
+ return;
+ }
- this.rolesRepository.update(ps.roleId, {
- lastUsedAt: new Date(),
- });
-
- this.globalEventService.publishInternalEvent('userRoleAssigned', created);
+ await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
index 141cc5ee89..45c4f76943 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
@@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
+import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
@@ -62,12 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
- @Inject(DI.roleAssignmentsRepository)
- private roleAssignmentsRepository: RoleAssignmentsRepository,
-
- private globalEventService: GlobalEventService,
private roleService: RoleService,
- private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@@ -84,18 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser);
}
- const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id });
- if (roleAssignment == null) {
- throw new ApiError(meta.errors.notAssigned);
- }
-
- await this.roleAssignmentsRepository.delete(roleAssignment.id);
-
- this.rolesRepository.update(ps.roleId, {
- lastUsedAt: new Date(),
- });
-
- this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment);
+ await this.roleService.unassign(user.id, role.id);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
index bb016a8425..35edca5460 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
@@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
@@ -64,7 +69,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
+ createdAt: assign.createdAt,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
+ expiresAt: assign.expiresAt,
})));
});
}
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 58f8835279..cdaa400137 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -82,6 +82,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.leftJoinAndSelect('note.channel', 'channel');
+
+ if (me) {
+ this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateMutedNoteQuery(query, me);
+ this.queryService.generateBlockedUserQuery(query, me);
+ }
//#endregion
const timeline = await query.take(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts
index e0a07a3640..271b33ef4b 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/show.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts
@@ -39,19 +39,13 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ fileId: { type: 'string', format: 'misskey:id' },
+ url: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- fileId: { type: 'string', format: 'misskey:id' },
- },
- required: ['fileId'],
- },
- {
- properties: {
- url: { type: 'string' },
- },
- required: ['url'],
- },
+ { required: ['fileId'] },
+ { required: ['url'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts
index b656c5c51d..4f543a6472 100644
--- a/packages/backend/src/server/api/endpoints/i/update-email.ts
+++ b/packages/backend/src/server/api/endpoints/i/update-email.ts
@@ -73,8 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
if (ps.email != null) {
- const available = await this.emailService.validateEmailForAccount(ps.email);
- if (!available) {
+ const res = await this.emailService.validateEmailForAccount(ps.email);
+ if (!res.available) {
throw new ApiError(meta.errors.unavailable);
}
}
diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts
new file mode 100644
index 0000000000..6bff7fc0c9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts
@@ -0,0 +1,263 @@
+process.env.NODE_ENV = 'test';
+
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+import { describe, test, expect } from '@jest/globals';
+import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
+import { paramDef } from './create.js';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+
+const VALID = true;
+const INVALID = false;
+
+describe('api:notes/create', () => {
+ describe('validation', () => {
+ const v = getValidator(paramDef);
+ const tooLong = readFile(_dirname + '/../../../../../test/resources/misskey.svg', 'utf-8');
+
+ test('reject empty', () => {
+ const valid = v({ });
+ expect(valid).toBe(INVALID);
+ });
+
+ describe('text', () => {
+ test('simple post', () => {
+ expect(v({ text: 'Hello, world!' }))
+ .toBe(VALID);
+ });
+
+ test('null post', () => {
+ expect(v({ text: null }))
+ .toBe(INVALID);
+ });
+
+ test('0 characters post', () => {
+ expect(v({ text: '' }))
+ .toBe(INVALID);
+ });
+
+ test('over 3000 characters post', async () => {
+ expect(v({ text: await tooLong }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('cw', () => {
+ test('simple cw', () => {
+ expect(v({ text: 'Hello, world!', cw: 'Hello, world!' }))
+ .toBe(VALID);
+ });
+
+ test('null cw', () => {
+ expect(v({ text: 'Body', cw: null }))
+ .toBe(VALID);
+ });
+
+ test('0 characters cw', () => {
+ expect(v({ text: 'Body', cw: '' }))
+ .toBe(VALID);
+ });
+
+ test('reject only cw', () => {
+ expect(v({ cw: 'Hello, world!' }))
+ .toBe(INVALID);
+ });
+
+ test('over 100 characters cw', async () => {
+ expect(v({ text: 'Body', cw: await tooLong }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('visibility', () => {
+ test('public', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'public' }))
+ .toBe(VALID);
+ });
+
+ test('home', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'home' }))
+ .toBe(VALID);
+ });
+
+ test('followers', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'followers' }))
+ .toBe(VALID);
+ });
+
+ test('reject only visibility', () => {
+ expect(v({ visibility: 'public' }))
+ .toBe(INVALID);
+ });
+
+ test('reject invalid visibility', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'invalid' }))
+ .toBe(INVALID);
+ });
+
+ test('reject null visibility', () => {
+ expect(v({ text: 'Hello, world!', visibility: null }))
+ .toBe(INVALID);
+ });
+
+ describe('visibility:specified', () => {
+ test('specified without visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified' }))
+ .toBe(VALID);
+ });
+
+ test('specified with empty visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: [] }))
+ .toBe(VALID);
+ });
+
+ test('reject specified with non unique visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: ['1', '1', '2'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject specified with null visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: null }))
+ .toBe(INVALID);
+ });
+ });
+ });
+
+ describe('fileIds', () => {
+ test('only fileIds', () => {
+ expect(v({ fileIds: ['1', '2', '3'] }))
+ .toBe(VALID);
+ });
+
+ test('text and fileIds', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'] }))
+ .toBe(VALID);
+ });
+
+ test('reject null fileIds', () => {
+ expect(v({ fileIds: null }))
+ .toBe(INVALID);
+ });
+
+ test('reject text and null fileIds (複合的なanyOfのバリデーションが正しく動作する)', () => {
+ expect(v({ text: 'Hello, world!', fileIds: null }))
+ .toBe(INVALID);
+ });
+
+ test('reject 0 files', () => {
+ expect(v({ fileIds: [] }))
+ .toBe(INVALID);
+ });
+
+ test('reject non unique', () => {
+ expect(v({ fileIds: ['1', '1', '2'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject invalid id', () => {
+ expect(v({ fileIds: ['あ'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject over 17 files', () => {
+ const valid = v({ text: 'Hello, world!', fileIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18'] });
+ expect(valid).toBe(INVALID);
+ });
+ });
+
+ describe('poll', () => {
+ test('note with poll', () => {
+ expect(v({ text: 'Hello, world!', poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('null poll', () => {
+ expect(v({ text: 'Hello, world!', poll: null }))
+ .toBe(VALID);
+ });
+
+ test('allow only poll', () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('poll with expiresAt', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiresAt: 1 } }))
+ .toBe(VALID);
+ });
+
+ test('poll with expiredAfter', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 1 } }))
+ .toBe(VALID);
+ });
+
+ test('reject poll without choices', () => {
+ expect(v({ poll: { } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with empty choices', () => {
+ expect(v({ poll: { choices: [] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with null choices', () => {
+ expect(v({ poll: { choices: null } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with 1 choice', () => {
+ expect(v({ poll: { choices: ['a'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with too long choice', async () => {
+ expect(v({ poll: { choices: [await tooLong, '2'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with too many choices', () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with non unique choices', () => {
+ expect(v({ poll: { choices: ['a', 'a', 'b', 'c'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with expiredAfter 0', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 0 } }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('renote', () => {
+ test('just a renote', () => {
+ expect(v({ renoteId: '1' }))
+ .toBe(VALID);
+ });
+ test('just a quote', () => {
+ expect(v({ text: 'Hello, world!', renoteId: '1' }))
+ .toBe(VALID);
+ });
+ test('reject invalid renoteId', () => {
+ expect(v({ renoteId: 'あ' }))
+ .toBe(INVALID);
+ });
+ });
+
+ test('text, fileIds and poll', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'], poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('text, invalid fileIds and invalid poll', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['あ'], poll: { choices: ['a'] } }))
+ .toBe(INVALID);
+ });
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index f4c5a84a4f..786ad103b0 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -101,74 +101,56 @@ export const paramDef = {
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
+ renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true },
- },
- anyOf: [
- {
- // (re)note with text, files and poll are optional
- properties: {
- text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
- },
- required: ['text'],
+
+ // anyOf内にバリデーションを書いても最初の一つしかチェックされない
+ // See https://github.com/misskey-dev/misskey/pull/10082
+ text: {
+ type: 'string',
+ minLength: 1,
+ maxLength: MAX_NOTE_TEXT_LENGTH,
+ nullable: false
},
- {
- // (re)note with files, text and poll are optional
- properties: {
- fileIds: {
- type: 'array',
- uniqueItems: true,
- minItems: 1,
- maxItems: 16,
- items: { type: 'string', format: 'misskey:id' },
- },
- },
- required: ['fileIds'],
+ fileIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
},
- {
- // (re)note with files, text and poll are optional
+ mediaIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ poll: {
+ type: 'object',
+ nullable: true,
properties: {
- mediaIds: {
- deprecated: true,
- description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
+ choices: {
type: 'array',
uniqueItems: true,
- minItems: 1,
- maxItems: 16,
- items: { type: 'string', format: 'misskey:id' },
- },
- },
- required: ['mediaIds'],
- },
- {
- // (re)note with poll, text and files are optional
- properties: {
- poll: {
- type: 'object',
- nullable: true,
- properties: {
- choices: {
- type: 'array',
- uniqueItems: true,
- minItems: 2,
- maxItems: 10,
- items: { type: 'string', minLength: 1, maxLength: 50 },
- },
- multiple: { type: 'boolean' },
- expiresAt: { type: 'integer', nullable: true },
- expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
- },
- required: ['choices'],
+ minItems: 2,
+ maxItems: 10,
+ items: { type: 'string', minLength: 1, maxLength: 50 },
},
+ multiple: { type: 'boolean' },
+ expiresAt: { type: 'integer', nullable: true },
+ expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
- required: ['poll'],
- },
- {
- // pure renote
- properties: {
- renoteId: { type: 'string', format: 'misskey:id', nullable: true },
- },
- required: ['renoteId'],
+ required: ['choices'],
},
+ },
+ // (re)note with text, files and poll are optional
+ anyOf: [
+ { required: ['text'] },
+ { required: ['renoteId'] },
+ { required: ['fileIds'] },
+ { required: ['mediaIds'] },
+ { required: ['poll'] },
],
} as const;
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 bcd793ac43..da1a4bcc46 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
@@ -36,32 +36,25 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
- },
- anyOf: [
- {
- properties: {
- tag: { type: 'string', minLength: 1 },
- },
- required: ['tag'],
- },
- {
- properties: {
- query: {
- type: 'array',
- description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
- items: {
- type: 'array',
- items: {
- type: 'string',
- minLength: 1,
- },
- minItems: 1,
- },
- minItems: 1,
+
+ tag: { type: 'string', minLength: 1 },
+ query: {
+ type: 'array',
+ description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
+ items: {
+ type: 'array',
+ items: {
+ type: 'string',
+ minLength: 1,
},
+ minItems: 1,
},
- required: ['query'],
+ minItems: 1,
},
+ },
+ anyOf: [
+ { required: ['tag'] },
+ { required: ['query'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index d1c35e36e2..e6de087c4a 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -58,25 +58,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
- const hasFollowing = (await this.followingsRepository.count({
- where: {
- followerId: me.id,
- },
- take: 1,
- })) !== 0;
-
- //#region Construct query
- const followingQuery = this.followingsRepository.createQueryBuilder('following')
+ const followees = await this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
- .where('following.followerId = :followerId', { followerId: me.id });
+ .where('following.followerId = :followerId', { followerId: me.id })
+ .getMany();
+ //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
- .andWhere(new Brackets(qb => { qb
- .where('note.userId = :meId', { meId: me.id });
- if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`);
- }))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
@@ -87,8 +77,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
- .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
- .setParameters(followingQuery.getParameters());
+ .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
+
+ if (followees.length > 0) {
+ const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
+
+ query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
+ } else {
+ query.andWhere('note.userId = :meId', { meId: me.id });
+ }
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts
index 651252afbb..bf2b2a431e 100644
--- a/packages/backend/src/server/api/endpoints/pages/show.ts
+++ b/packages/backend/src/server/api/endpoints/pages/show.ts
@@ -29,20 +29,14 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ pageId: { type: 'string', format: 'misskey:id' },
+ name: { type: 'string' },
+ username: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- pageId: { type: 'string', format: 'misskey:id' },
- },
- required: ['pageId'],
- },
- {
- properties: {
- name: { type: 'string' },
- username: { type: 'string' },
- },
- required: ['name', 'username'],
- },
+ { required: ['pageId'] },
+ { required: ['name', 'username'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts
index 6e221b6c67..607dc24206 100644
--- a/packages/backend/src/server/api/endpoints/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/roles/users.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
@@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts
index 17ce920011..97f1310c36 100644
--- a/packages/backend/src/server/api/endpoints/users/followers.ts
+++ b/packages/backend/src/server/api/endpoints/users/followers.ts
@@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+
+ userId: { type: 'string', format: 'misskey:id' },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
+ },
},
anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username', 'host'],
- },
+ { required: ['userId'] },
+ { required: ['username', 'host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts
index 6dbda0d72f..d406594a2e 100644
--- a/packages/backend/src/server/api/endpoints/users/following.ts
+++ b/packages/backend/src/server/api/endpoints/users/following.ts
@@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+
+ userId: { type: 'string', format: 'misskey:id' },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
+ },
},
anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username', 'host'],
- },
+ { required: ['userId'] },
+ { required: ['username', 'host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
index 1cefcf2707..6c340d8fb2 100644
--- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -31,20 +31,13 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true },
+
+ username: { type: 'string', nullable: true },
+ host: { type: 'string', nullable: true },
},
anyOf: [
- {
- properties: {
- username: { type: 'string', nullable: true },
- },
- required: ['username'],
- },
- {
- properties: {
- host: { type: 'string', nullable: true },
- },
- required: ['host'],
- },
+ { required: ['username'] },
+ { required: ['host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts
index 70258ef009..29f24b045a 100644
--- a/packages/backend/src/server/api/endpoints/users/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/show.ts
@@ -54,32 +54,22 @@ export const meta = {
export const paramDef = {
type: 'object',
- anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- userIds: { type: 'array', uniqueItems: true, items: {
- type: 'string', format: 'misskey:id',
- } },
- },
- required: ['userIds'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username'],
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ userIds: { type: 'array', uniqueItems: true, items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
},
+ },
+ anyOf: [
+ { required: ['userId'] },
+ { required: ['userIds'] },
+ { required: ['username'] },
],
} as const;
diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts
index 9287952cb6..c450773055 100644
--- a/packages/backend/src/server/api/stream/types.ts
+++ b/packages/backend/src/server/api/stream/types.ts
@@ -178,7 +178,14 @@ type EventUnionFromDictionary<
// redis通すとDateのインスタンスはstringに変換されるので
type Serialized<T> = {
- [K in keyof T]: T[K] extends Date ? string : T[K] extends Record<string, any> ? Serialized<T[K]> : T[K];
+ [K in keyof T]:
+ T[K] extends Date
+ ? string
+ : T[K] extends (Date | null)
+ ? (string | null)
+ : T[K] extends Record<string, any>
+ ? Serialized<T[K]>
+ : T[K];
};
type SerializedAll<T> = {
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index c6cb25e43a..fd7f54da54 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -61,6 +61,13 @@
renderError('META_FETCH_V');
return;
}
+
+ // for https://github.com/misskey-dev/misskey/issues/10202
+ if (lang == null || lang.toString == null || lang.toString() === 'null') {
+ console.error('invalid lang value detected!!!', typeof lang, lang);
+ lang = 'en-US';
+ }
+
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
diff --git a/packages/backend/test/_e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts
index d29b9acb3d..4e162f42d0 100644
--- a/packages/backend/test/_e2e/api-visibility.ts
+++ b/packages/backend/test/e2e/api-visibility.ts
@@ -1,18 +1,18 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('API visibility', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
beforeAll(async () => {
p = await startServer();
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Note visibility', () => {
@@ -60,7 +60,7 @@ describe('API visibility', () => {
//#endregion
const show = async (noteId: any, by: any) => {
- return await request('/notes/show', {
+ return await api('/notes/show', {
noteId,
}, by);
};
@@ -75,7 +75,7 @@ describe('API visibility', () => {
target2 = await signup({ username: 'target2' });
// follow alice <= follower
- await request('/following/create', { userId: alice.id }, follower);
+ await api('/following/create', { userId: alice.id }, follower);
// normal posts
pub = await post(alice, { text: 'x', visibility: 'public' });
@@ -413,21 +413,21 @@ describe('API visibility', () => {
//#region HTL
test('[HTL] public-post が 自分が見れる', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, alice);
+ const res = await api('/notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[HTL] public-post が 非フォロワーから見れない', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, other);
+ const res = await api('/notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes.length, 0);
});
test('[HTL] followers-post が フォロワーから見れる', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, follower);
+ const res = await api('/notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
@@ -436,21 +436,21 @@ describe('API visibility', () => {
//#region RTL
test('[replies] followers-reply が フォロワーから見れる', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes.length, 0);
});
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
@@ -459,14 +459,14 @@ describe('API visibility', () => {
//#region MTL
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
- const res = await request('/notes/mentions', { limit: 100 }, target);
+ const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
- const res = await request('/notes/mentions', { limit: 100 }, target);
+ const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folM.id);
assert.strictEqual(notes[0].text, '@target x');
@@ -474,4 +474,4 @@ describe('API visibility', () => {
//#endregion
});
});
-*/
+
diff --git a/packages/backend/test/_e2e/api.ts b/packages/backend/test/e2e/api.ts
index 7542c34db0..6ceccf66eb 100644
--- a/packages/backend/test/_e2e/api.ts
+++ b/packages/backend/test/e2e/api.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js';
+import { signup, api, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('API', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
let carol: any;
@@ -15,69 +15,69 @@ describe('API', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('General validation', () => {
- test('wrong type', async(async () => {
- const res = await request('/test', {
+ test('wrong type', async () => {
+ const res = await api('/test', {
required: true,
string: 42,
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('missing require param', async(async () => {
- const res = await request('/test', {
+ test('missing require param', async () => {
+ const res = await api('/test', {
string: 'a',
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('invalid misskey:id (empty string)', async(async () => {
- const res = await request('/test', {
+ test('invalid misskey:id (empty string)', async () => {
+ const res = await api('/test', {
required: true,
id: '',
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('valid misskey:id', async(async () => {
- const res = await request('/test', {
+ test('valid misskey:id', async () => {
+ const res = await api('/test', {
required: true,
id: '8wvhjghbxu',
});
assert.strictEqual(res.status, 200);
- }));
+ });
- test('default value', async(async () => {
- const res = await request('/test', {
+ test('default value', async () => {
+ const res = await api('/test', {
required: true,
string: 'a',
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.default, 'hello');
- }));
+ });
- test('can set null even if it has default value', async(async () => {
- const res = await request('/test', {
+ test('can set null even if it has default value', async () => {
+ const res = await api('/test', {
required: true,
nullableDefault: null,
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, null);
- }));
+ });
- test('cannot set undefined if it has default value', async(async () => {
- const res = await request('/test', {
+ test('cannot set undefined if it has default value', async () => {
+ const res = await api('/test', {
required: true,
nullableDefault: undefined,
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, 'hello');
- }));
+ });
});
});
diff --git a/packages/backend/test/_e2e/block.ts b/packages/backend/test/e2e/block.ts
index c5f43e153c..4e9030f85d 100644
--- a/packages/backend/test/_e2e/block.ts
+++ b/packages/backend/test/e2e/block.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Block', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
// alice blocks bob
let alice: any;
@@ -17,14 +17,14 @@ describe('Block', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('Block作成', async () => {
- const res = await request('/blocking/create', {
+ const res = await api('/blocking/create', {
userId: bob.id,
}, alice);
@@ -32,7 +32,7 @@ describe('Block', () => {
});
test('ブロックされているユーザーをフォローできない', async () => {
- const res = await request('/following/create', { userId: alice.id }, bob);
+ const res = await api('/following/create', { userId: alice.id }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0');
@@ -41,7 +41,7 @@ describe('Block', () => {
test('ブロックされているユーザーにリアクションできない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
+ const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec');
@@ -50,7 +50,7 @@ describe('Block', () => {
test('ブロックされているユーザーに返信できない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob);
+ const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@@ -59,7 +59,7 @@ describe('Block', () => {
test('ブロックされているユーザーのノートをRenoteできない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob);
+ const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@@ -74,7 +74,7 @@ describe('Block', () => {
const bobNote = await post(bob);
const carolNote = await post(carol);
- const res = await request('/notes/local-timeline', {}, bob);
+ const res = await api('/notes/local-timeline', {}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index aed980d6c8..42bdc5f24d 100644
--- a/packages/backend/test/_e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -1,29 +1,35 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import * as openapi from '@redocly/openapi-core';
-import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js';
+// node-fetch only supports it's own Blob yet
+// https://github.com/node-fetch/node-fetch/pull/1664
+import { Blob } from 'node-fetch';
+import { startServer, signup, post, api, uploadFile } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Endpoints', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
+ let carol: any;
+ let dave: any;
beforeAll(async () => {
p = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- }, 1000 * 30);
+ carol = await signup({ username: 'carol' });
+ dave = await signup({ username: 'dave' });
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('signup', () => {
test('不正なユーザー名でアカウントが作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test.',
password: 'test',
});
@@ -31,7 +37,7 @@ describe('Endpoints', () => {
});
test('空のパスワードでアカウントが作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test',
password: '',
});
@@ -44,7 +50,7 @@ describe('Endpoints', () => {
password: 'test1',
};
- const res = await request('api/signup', me);
+ const res = await api('signup', me);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -52,7 +58,7 @@ describe('Endpoints', () => {
});
test('同じユーザー名のアカウントは作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test1',
password: 'test1',
});
@@ -63,7 +69,7 @@ describe('Endpoints', () => {
describe('signin', () => {
test('間違ったパスワードでサインインできない', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: 'bar',
});
@@ -72,7 +78,7 @@ describe('Endpoints', () => {
});
test('クエリをインジェクションできない', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: {
$gt: '',
@@ -83,7 +89,7 @@ describe('Endpoints', () => {
});
test('正しい情報でサインインできる', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: 'test1',
});
@@ -111,11 +117,12 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.birthday, myBirthday);
});
- test('名前を空白にできない', async () => {
+ test('名前を空白にできる', async () => {
const res = await api('/i/update', {
name: ' ',
}, alice);
- assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.name, ' ');
});
test('誕生日の設定を削除できる', async () => {
@@ -201,7 +208,6 @@ describe('Endpoints', () => {
test('リアクションできる', async () => {
const bobPost = await post(bob);
- const alice = await signup({ username: 'alice' });
const res = await api('/notes/reactions/create', {
noteId: bobPost.id,
reaction: '🚀',
@@ -214,7 +220,7 @@ describe('Endpoints', () => {
}, alice);
assert.strictEqual(resNote.status, 200);
- assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]);
+ assert.strictEqual(resNote.body.reactions['🚀'], 1);
});
test('自分の投稿にもリアクションできる', async () => {
@@ -228,7 +234,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 204);
});
- test('二重にリアクションできない', async () => {
+ test('二重にリアクションすると上書きされる', async () => {
const bobPost = await post(bob);
await api('/notes/reactions/create', {
@@ -241,7 +247,14 @@ describe('Endpoints', () => {
reaction: '🚀',
}, alice);
- assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.status, 204);
+
+ const resNote = await api('/notes/show', {
+ noteId: bobPost.id,
+ }, alice);
+
+ assert.strictEqual(resNote.status, 200);
+ assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 });
});
test('存在しない投稿にはリアクションできない', async () => {
@@ -369,57 +382,22 @@ describe('Endpoints', () => {
});
});
- /*
- describe('/i', () => {
- test('', async () => {
- });
- });
- */
-});
-
-/*
-process.env.NODE_ENV = 'test';
-
-import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js';
-
-describe('API: Endpoints', () => {
- let p: childProcess.ChildProcess;
- let alice: any;
- let bob: any;
- let carol: any;
-
- before(async () => {
- p = await startServer();
- alice = await signup({ username: 'alice' });
- bob = await signup({ username: 'bob' });
- carol = await signup({ username: 'carol' });
- });
-
- after(async () => {
- await shutdownServer(p);
- });
-
describe('drive', () => {
test('ドライブ情報を取得できる', async () => {
- await uploadFile({
- userId: alice.id,
- size: 256
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(256)]),
});
- await uploadFile({
- userId: alice.id,
- size: 512
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(512)]),
});
- await uploadFile({
- userId: alice.id,
- size: 1024
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(1024)]),
});
const res = await api('/drive', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- expect(res.body).have.property('usage').eql(1792);
- }));
+ expect(res.body).toHaveProperty('usage', 1792);
+ });
});
describe('drive/files/create', () => {
@@ -428,397 +406,400 @@ describe('API: Endpoints', () => {
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.strictEqual(res.body.name, 'Lenna.png');
- }));
+ assert.strictEqual(res.body.name, 'Lenna.jpg');
+ });
test('ファイルに名前を付けられる', async () => {
- const res = await assert.request(server)
- .post('/drive/files/create')
- .field('i', alice.token)
- .field('name', 'Belmond.png')
- .attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png');
+ const res = await uploadFile(alice, { name: 'Belmond.jpg' });
- expect(res).have.status(200);
- expect(res.body).be.a('object');
- expect(res.body).have.property('name').eql('Belmond.png');
- }));
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.jpg');
+ });
+
+ test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => {
+ const res = await uploadFile(alice, { name: 'Belmond.png' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.png.jpg');
+ });
test('ファイル無しで怒られる', async () => {
const res = await api('/drive/files/create', {}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('SVGファイルを作成できる', async () => {
- const res = await uploadFile(alice, __dirname + '/resources/image.svg');
+ const res = await uploadFile(alice, { path: 'image.svg' });
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'image.svg');
assert.strictEqual(res.body.type, 'image/svg+xml');
- }));
+ });
});
describe('drive/files/update', () => {
test('名前を更新できる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const newName = 'いちごパスタ.png';
const res = await api('/drive/files/update', {
fileId: file.id,
- name: newName
+ name: newName,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, newName);
- }));
+ });
test('他人のファイルは更新できない', async () => {
- const file = await uploadFile(bob);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- name: 'いちごパスタ.png'
- }, alice);
+ name: 'いちごパスタ.png',
+ }, bob);
assert.strictEqual(res.status, 400);
- }));
+ });
test('親フォルダを更新できる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, folder.id);
- }));
+ });
test('親フォルダを無しにできる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: null
+ folderId: null,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, null);
- }));
+ });
test('他人のフォルダには入れられない', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, bob)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しないフォルダで怒られる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: '000000000000000000000000'
+ folderId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正なフォルダIDで怒られる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: 'foo'
+ folderId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('ファイルが存在しなかったら怒る', async () => {
const res = await api('/drive/files/update', {
fileId: '000000000000000000000000',
- name: 'いちごパスタ.png'
+ name: 'いちごパスタ.png',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('間違ったIDで怒られる', async () => {
const res = await api('/drive/files/update', {
fileId: 'kyoppie',
- name: 'いちごパスタ.png'
+ name: 'いちごパスタ.png',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
});
describe('drive/folders/create', () => {
test('フォルダを作成できる', async () => {
const res = await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'test');
- }));
+ });
});
describe('drive/folders/update', () => {
test('名前を更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- name: 'new name'
+ name: 'new name',
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'new name');
- }));
+ });
test('他人のフォルダを更新できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, bob)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- name: 'new name'
+ name: 'new name',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('親フォルダを更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, parentFolder.id);
- }));
+ });
test('親フォルダを無しに更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: null
+ parentId: null,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, null);
- }));
+ });
test('他人のフォルダを親フォルダに設定できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, bob)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
await api('/drive/folders/update', {
folderId: parentFolder.id,
- parentId: folder.id
+ parentId: folder.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない(再帰的)', async () => {
const folderA = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const folderB = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const folderC = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
await api('/drive/folders/update', {
folderId: folderB.id,
- parentId: folderA.id
+ parentId: folderA.id,
}, alice);
await api('/drive/folders/update', {
folderId: folderC.id,
- parentId: folderB.id
+ parentId: folderB.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folderA.id,
- parentId: folderC.id
+ parentId: folderC.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない(自身)', async () => {
const folderA = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folderA.id,
- parentId: folderA.id
+ parentId: folderA.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しない親フォルダを設定できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: '000000000000000000000000'
+ parentId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正な親フォルダIDで怒られる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: 'foo'
+ parentId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しないフォルダを更新できない', async () => {
const res = await api('/drive/folders/update', {
- folderId: '000000000000000000000000'
+ folderId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正なフォルダIDで怒られる', async () => {
const res = await api('/drive/folders/update', {
- folderId: 'foo'
+ folderId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
});
describe('notes/replies', () => {
test('自分に閲覧権限のない投稿は含まれない', async () => {
const alicePost = await post(alice, {
- text: 'foo'
+ text: 'foo',
});
await post(bob, {
replyId: alicePost.id,
text: 'bar',
visibility: 'specified',
- visibleUserIds: [alice.id]
+ visibleUserIds: [alice.id],
});
const res = await api('/notes/replies', {
- noteId: alicePost.id
+ noteId: alicePost.id,
}, carol);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 0);
- }));
+ });
});
describe('notes/timeline', () => {
test('フォロワー限定投稿が含まれる', async () => {
await api('/following/create', {
- userId: alice.id
- }, bob);
+ userId: carol.id,
+ }, dave);
- const alicePost = await post(alice, {
+ const carolPost = await post(carol, {
text: 'foo',
- visibility: 'followers'
+ visibility: 'followers',
});
- const res = await api('/notes/timeline', {}, bob);
+ const res = await api('/notes/timeline', {}, dave);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
- assert.strictEqual(res.body[0].id, alicePost.id);
- }));
+ assert.strictEqual(res.body[0].id, carolPost.id);
+ });
});
});
-*/
diff --git a/packages/backend/test/_e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts
index b8ba3f2477..6b3c795235 100644
--- a/packages/backend/test/_e2e/fetch-resource.ts
+++ b/packages/backend/test/e2e/fetch-resource.ts
@@ -1,9 +1,8 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import * as openapi from '@redocly/openapi-core';
-import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js';
+import { startServer, signup, post, api, simpleGet } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
// Request Accept
const ONLY_AP = 'application/activity+json';
@@ -13,11 +12,10 @@ const UNSPECIFIED = '*/*';
// Response Content-Type
const AP = 'application/activity+json; charset=utf-8';
-const JSON = 'application/json; charset=utf-8';
const HTML = 'text/html; charset=utf-8';
describe('Fetch resource', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let alicesPost: any;
@@ -28,15 +26,15 @@ describe('Fetch resource', () => {
alicesPost = await post(alice, {
text: 'test',
});
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Common', () => {
test('meta', async () => {
- const res = await request('/meta', {
+ const res = await api('/meta', {
});
assert.strictEqual(res.status, 200);
@@ -54,36 +52,26 @@ describe('Fetch resource', () => {
assert.strictEqual(res.type, HTML);
});
- test('GET api-doc', async () => {
+ test('GET api-doc (廃止)', async () => {
const res = await simpleGet('/api-doc');
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, HTML);
+ assert.strictEqual(res.status, 404);
});
- test('GET api.json', async () => {
+ test('GET api.json (廃止)', async () => {
const res = await simpleGet('/api.json');
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, JSON);
+ assert.strictEqual(res.status, 404);
});
- test('Validate api.json', async () => {
- const config = await openapi.loadConfig();
- const result = await openapi.bundle({
- config,
- ref: `http://localhost:${port}/api.json`,
- });
-
- for (const problem of result.problems) {
- console.log(`${problem.message} - ${problem.location[0]?.pointer}`);
- }
-
- assert.strictEqual(result.problems.length, 0);
+ test('GET api/foo (存在しない)', async () => {
+ const res = await simpleGet('/api/foo');
+ assert.strictEqual(res.status, 404);
+ assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT');
});
test('GET favicon.ico', async () => {
const res = await simpleGet('/favicon.ico');
assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, 'image/x-icon');
+ assert.strictEqual(res.type, 'image/vnd.microsoft.icon');
});
test('GET apple-touch-icon.png', async () => {
diff --git a/packages/backend/test/_e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts
index 84a5b5ef28..d53919ca1e 100644
--- a/packages/backend/test/_e2e/ff-visibility.ts
+++ b/packages/backend/test/e2e/ff-visibility.ts
@@ -1,36 +1,34 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js';
+import { signup, api, startServer, simpleGet } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('FF visibility', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
- let carol: any;
beforeAll(async () => {
p = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'public',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -41,14 +39,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, alice);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, alice);
@@ -59,14 +57,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -75,18 +73,18 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- await request('/following/create', {
+ await api('/following/create', {
userId: alice.id,
}, bob);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -97,14 +95,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, alice);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, alice);
@@ -115,14 +113,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -133,7 +131,7 @@ describe('FF visibility', () => {
describe('AP', () => {
test('ffVisibility が public 以外ならばAPからは取得できない', async () => {
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'public',
}, alice);
@@ -143,22 +141,22 @@ describe('FF visibility', () => {
assert.strictEqual(followersRes.status, 200);
}
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
- const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
+ const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
+ const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403);
}
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
- const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
+ const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
+ const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403);
}
diff --git a/packages/backend/test/_e2e/mute.ts b/packages/backend/test/e2e/mute.ts
index 8f7f72bb97..6654a290be 100644
--- a/packages/backend/test/_e2e/mute.ts
+++ b/packages/backend/test/e2e/mute.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js';
+import { signup, api, post, react, startServer, waitFire } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Mute', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
// alice mutes carol
let alice: any;
@@ -17,14 +17,14 @@ describe('Mute', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('ミュート作成', async () => {
- const res = await request('/mute/create', {
+ const res = await api('/mute/create', {
userId: carol.id,
}, alice);
@@ -35,7 +35,7 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: '@alice hi' });
const carolNote = await post(carol, { text: '@alice hi' });
- const res = await request('/notes/mentions', {}, alice);
+ const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -45,11 +45,11 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
await post(carol, { text: '@alice hi' });
- const res = await request('/i', {}, alice);
+ const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false);
@@ -57,7 +57,7 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
@@ -66,8 +66,8 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
- await request('/notifications/mark-all-as-read', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
+ await api('/notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
@@ -80,7 +80,7 @@ describe('Mute', () => {
const bobNote = await post(bob);
const carolNote = await post(carol);
- const res = await request('/notes/local-timeline', {}, alice);
+ const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -96,7 +96,7 @@ describe('Mute', () => {
renoteId: carolNote.id,
});
- const res = await request('/notes/local-timeline', {}, alice);
+ const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -112,7 +112,7 @@ describe('Mute', () => {
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
- const res = await request('/i/notifications', {}, alice);
+ const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/note.ts b/packages/backend/test/e2e/note.ts
index 47af6808f6..1b5f9580d5 100644
--- a/packages/backend/test/_e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { Note } from '../../src/models/entities/note.js';
-import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js';
+import { Note } from '@/models/entities/Note.js';
+import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let Notes: any;
let alice: any;
@@ -18,10 +18,10 @@ describe('Note', () => {
Notes = connection.getRepository(Note);
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('投稿できる', async () => {
@@ -29,7 +29,7 @@ describe('Note', () => {
text: 'test',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -39,7 +39,7 @@ describe('Note', () => {
test('ファイルを添付できる', async () => {
const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
fileIds: [file.id],
}, alice);
@@ -48,37 +48,37 @@ describe('Note', () => {
assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]);
}, 1000 * 10);
- test('他人のファイルは無視', async () => {
+ test('他人のファイルで怒られる', async () => {
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
text: 'test',
fileIds: [file.id],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}, 1000 * 10);
- test('存在しないファイルは無視', async () => {
- const res = await request('/notes/create', {
+ test('存在しないファイルで怒られる', async () => {
+ const res = await api('/notes/create', {
text: 'test',
fileIds: ['000000000000000000000000'],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
});
- test('不正なファイルIDは無視', async () => {
- const res = await request('/notes/create', {
+ test('不正なファイルIDで怒られる', async () => {
+ const res = await api('/notes/create', {
fileIds: ['kyoppie'],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
});
test('返信できる', async () => {
@@ -91,7 +91,7 @@ describe('Note', () => {
replyId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -109,7 +109,7 @@ describe('Note', () => {
renoteId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -127,7 +127,7 @@ describe('Note', () => {
renoteId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -140,7 +140,7 @@ describe('Note', () => {
const post = {
text: '!'.repeat(3000),
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
});
@@ -148,7 +148,7 @@ describe('Note', () => {
const post = {
text: '!'.repeat(3001),
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -157,7 +157,7 @@ describe('Note', () => {
text: 'test',
replyId: '000000000000000000000000',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -165,7 +165,7 @@ describe('Note', () => {
const post = {
renoteId: '000000000000000000000000',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -174,7 +174,7 @@ describe('Note', () => {
text: 'test',
replyId: 'foo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -182,7 +182,7 @@ describe('Note', () => {
const post = {
renoteId: 'foo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -191,7 +191,7 @@ describe('Note', () => {
text: '@ghost yo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -203,7 +203,7 @@ describe('Note', () => {
text: '@bob @bob @bob yo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -213,9 +213,125 @@ describe('Note', () => {
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
});
+ describe('添付ファイル情報', () => {
+ test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const res = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.createdNote.files.length, 1);
+ assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const res = await api('/notes', {
+ withFiles: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.files.length, 1);
+ assert.strictEqual(myNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: createdNote.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.files.length, 1);
+ assert.strictEqual(myNote.renote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const res = await api('/notes', {
+ reply: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.reply.files.length, 1);
+ assert.strictEqual(myNote.reply.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: reply.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.reply.files.length, 1);
+ assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
+ });
+ });
+
describe('notes/create', () => {
test('投票を添付できる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
text: 'test',
poll: {
choices: ['foo', 'bar'],
@@ -228,14 +344,14 @@ describe('Note', () => {
});
test('投票の選択肢が無くて怒られる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {},
}, alice);
assert.strictEqual(res.status, 400);
});
test('投票の選択肢が無くて怒られる (空の配列)', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {
choices: [],
},
@@ -244,7 +360,7 @@ describe('Note', () => {
});
test('投票の選択肢が1つで怒られる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {
choices: ['Strawberry Pasta'],
},
@@ -253,14 +369,14 @@ describe('Note', () => {
});
test('投票できる', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
@@ -269,19 +385,19 @@ describe('Note', () => {
});
test('複数投票できない', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0,
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2,
}, alice);
@@ -290,7 +406,7 @@ describe('Note', () => {
});
test('許可されている場合は複数投票できる', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
@@ -298,17 +414,17 @@ describe('Note', () => {
},
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0,
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2,
}, alice);
@@ -317,7 +433,7 @@ describe('Note', () => {
});
test('締め切られている場合は投票できない', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
@@ -327,7 +443,7 @@ describe('Note', () => {
await new Promise(x => setTimeout(x, 2));
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
diff --git a/packages/backend/test/_e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts
index 790451d9b4..23c431f2e7 100644
--- a/packages/backend/test/_e2e/streaming.ts
+++ b/packages/backend/test/e2e/streaming.ts
@@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { Following } from '../../src/models/entities/following.js';
-import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js';
+import { Following } from '@/models/entities/Following.js';
+import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Streaming', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let Followings: any;
const follow = async (follower: any, followee: any) => {
@@ -71,10 +71,10 @@ describe('Streaming', () => {
listId: list.id,
userId: kyoko.id,
}, chitose);
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Events', () => {
@@ -404,43 +404,45 @@ describe('Streaming', () => {
});
}));
- test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
- let fooCount = 0;
- let barCount = 0;
- let fooBarCount = 0;
-
- const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
- if (type === 'note') {
- if (body.text === '#foo') fooCount++;
- if (body.text === '#bar') barCount++;
- if (body.text === '#foo #bar') fooBarCount++;
- }
- }, {
- q: [
- ['foo', 'bar'],
- ],
- });
-
- post(chitose, {
- text: '#foo',
- });
-
- post(chitose, {
- text: '#bar',
- });
-
- post(chitose, {
- text: '#foo #bar',
- });
-
- setTimeout(() => {
- assert.strictEqual(fooCount, 0);
- assert.strictEqual(barCount, 0);
- assert.strictEqual(fooBarCount, 1);
- ws.close();
- done();
- }, 3000);
- }));
+ // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
+
+ // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
+ // let fooCount = 0;
+ // let barCount = 0;
+ // let fooBarCount = 0;
+
+ // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
+ // if (type === 'note') {
+ // if (body.text === '#foo') fooCount++;
+ // if (body.text === '#bar') barCount++;
+ // if (body.text === '#foo #bar') fooBarCount++;
+ // }
+ // }, {
+ // q: [
+ // ['foo', 'bar'],
+ // ],
+ // });
+
+ // post(chitose, {
+ // text: '#foo',
+ // });
+
+ // post(chitose, {
+ // text: '#bar',
+ // });
+
+ // post(chitose, {
+ // text: '#foo #bar',
+ // });
+
+ // setTimeout(() => {
+ // assert.strictEqual(fooCount, 0);
+ // assert.strictEqual(barCount, 0);
+ // assert.strictEqual(fooBarCount, 1);
+ // ws.close();
+ // done();
+ // }, 3000);
+ // }));
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0;
diff --git a/packages/backend/test/_e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts
index 890b52a8c1..792436d88f 100644
--- a/packages/backend/test/_e2e/thread-mute.ts
+++ b/packages/backend/test/e2e/thread-mute.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, connectStream, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Note thread mute', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
@@ -16,22 +16,22 @@ describe('Note thread mute', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => {
const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
- const res = await request('/notes/mentions', {}, alice);
+ const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -42,27 +42,27 @@ describe('Note thread mute', () => {
test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
- const res = await request('/i', {}, alice);
+ const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false);
});
- test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => {
+ test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise<void>(async done => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
let fired = false;
@@ -86,12 +86,12 @@ describe('Note thread mute', () => {
const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
- const res = await request('/i/notifications', {}, alice);
+ const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts
index a6cc1057f9..690cba1746 100644
--- a/packages/backend/test/_e2e/user-notes.ts
+++ b/packages/backend/test/e2e/user-notes.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, uploadUrl, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('users/notes', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let jpgNote: any;
@@ -26,14 +26,14 @@ describe('users/notes', () => {
jpgPngNote = await post(alice, {
fileIds: [jpg.id, png.id],
});
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async() => {
- await shutdownServer(p);
+ await p.close();
});
test('ファイルタイプ指定 (jpg)', async () => {
- const res = await request('/users/notes', {
+ const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg'],
}, alice);
@@ -46,7 +46,7 @@ describe('users/notes', () => {
});
test('ファイルタイプ指定 (jpg or png)', async () => {
- const res = await request('/users/notes', {
+ const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png'],
}, alice);
diff --git a/packages/backend/test/prelude/get-api-validator.ts b/packages/backend/test/prelude/get-api-validator.ts
new file mode 100644
index 0000000000..1f4a2dbc95
--- /dev/null
+++ b/packages/backend/test/prelude/get-api-validator.ts
@@ -0,0 +1,11 @@
+import { Schema } from '@/misc/schema';
+import Ajv from 'ajv';
+
+export const getValidator = (paramDef: Schema) => {
+ const ajv = new Ajv({
+ useDefaults: true,
+ });
+ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
+
+ return ajv.compile(paramDef);
+}
diff --git a/packages/backend/test/resources/misskey.svg b/packages/backend/test/resources/misskey.svg
new file mode 100644
index 0000000000..3fcb2d3ecb
--- /dev/null
+++ b/packages/backend/test/resources/misskey.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="515px" height="136px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+ <g id="path2" transform="matrix(0.264585,0,0,0.264585,-50.0005,-50.0001)">
+ <path d="M256.418,188.976C248.558,188.944 240.758,190.308 233.379,193.013C220.308,197.613 209.533,205.888 201.091,217.802C193.02,229.329 188.977,242.195 188.977,256.409L188.977,508.89C188.977,527.332 195.52,543.29 208.576,556.732C222.032,569.803 237.99,576.331 256.418,576.331C275.259,576.331 291.204,569.803 304.274,556.747C317.73,543.291 324.441,527.332 324.441,508.89L324.441,462.983C324.584,453.04 334.824,455.655 340.01,462.983C349.695,479.767 370.251,494.19 394.193,494.105L394.193,494.119C418.119,494.019 438.005,482.196 448.375,462.983C452.304,458.354 463.377,450.455 464.52,462.983L464.52,508.89C464.52,527.332 471.047,543.29 484.104,556.732C497.574,569.803 513.511,576.331 531.953,576.331C550.78,576.331 566.739,569.803 579.809,556.747C593.265,543.291 599.977,527.332 599.977,508.89L599.977,256.409C599.977,242.195 595.752,229.329 587.309,217.802C579.224,205.874 568.653,197.613 555.597,193.013C547.912,190.314 540.228,188.976 532.543,188.976C511.788,188.976 494.301,197.046 480.073,213.188L411.636,293.281C410.107,294.438 405.006,303.247 394.178,303.247C383.379,303.247 378.868,294.439 377.325,293.296L308.297,213.188C294.47,197.046 277.173,188.976 256.418,188.976ZM682.904,188.983C666.763,188.983 652.926,194.748 641.404,206.271C630.261,217.413 624.691,231.054 624.691,247.196C624.691,263.338 630.261,277.174 641.404,288.697C652.926,299.839 666.763,305.41 682.904,305.41C699.046,305.41 712.88,299.839 724.412,288.697C735.935,277.174 741.693,263.338 741.693,247.196C741.693,231.054 735.935,217.413 724.412,206.271C712.88,194.748 699.046,188.983 682.904,188.983ZM1327.58,193.013C1311.45,193.013 1297.62,198.779 1286.1,210.302C1274.96,221.444 1269.38,235.08 1269.38,251.212L1269.38,519.72C1269.38,535.861 1274.96,549.697 1286.1,561.22C1297.62,572.362 1311.45,577.933 1327.58,577.933C1343.73,577.933 1357.55,572.362 1369.08,561.22C1380.6,549.697 1386.37,535.867 1386.37,519.734C1386.37,508.211 1387.9,502.453 1390.96,502.453C1392.51,502.453 1394.05,503.023 1395.58,504.166L1453.2,560.061C1464.72,571.203 1478.36,576.774 1494.11,576.774C1510.62,576.774 1524.45,571.009 1535.6,559.486C1546.74,547.572 1552.31,533.936 1552.31,518.575C1552.31,502.053 1546.36,488.029 1534.45,476.506C1508.32,450.765 1494.69,437.517 1493.54,436.755C1488.54,431.755 1488.73,427.53 1494.11,424.073L1495.27,423.497L1495.27,422.929L1531.57,399.875C1548.85,388.352 1557.5,372.026 1557.5,350.904C1557.5,339.381 1554.42,328.622 1548.28,318.623C1536.76,301.339 1520.43,292.691 1499.3,292.691C1487.78,292.691 1477.02,295.768 1467.04,301.911C1422.48,331.499 1399.42,346.678 1397.88,347.449C1395.2,349.363 1392.7,349.738 1390.4,348.586C1387.7,347.434 1386.35,344.939 1386.35,341.101L1386.35,251.212C1386.35,235.08 1380.59,221.444 1369.07,210.302C1357.55,198.779 1343.72,193.013 1327.58,193.013ZM1716.37,291.738C1676.42,291.738 1642.24,305.949 1613.81,334.376C1585.76,362.422 1571.74,396.227 1571.74,435.795C1571.74,475.745 1585.76,509.932 1613.81,538.359C1642.24,566.404 1676.42,580.428 1716.37,580.428C1755.94,580.428 1789.94,566.404 1818.37,538.359C1827.2,529.521 1831.62,518.773 1831.62,506.107C1831.62,493.423 1827.2,482.664 1818.37,473.827C1809.53,464.999 1798.77,460.584 1786.11,460.584C1773.42,460.584 1762.66,464.999 1753.83,473.827C1743.46,484.588 1730.97,489.963 1716.37,489.963C1701.4,489.963 1688.53,484.78 1677.77,474.41C1667.39,463.649 1662.2,450.775 1662.2,435.795C1662.2,421.206 1667.59,408.72 1678.35,398.34C1683.73,392.578 1690.26,388.74 1697.93,386.817C1699.87,386.436 1701.4,386.623 1702.55,387.385C1703.32,388.547 1702.93,389.702 1701.39,390.854L1689.87,402.953C1681.03,411.791 1676.61,422.359 1676.61,434.644C1676.61,447.319 1680.45,457.497 1688.13,465.182C1695.81,472.868 1706.57,476.705 1720.41,476.705C1730.01,476.705 1739.61,471.91 1749.21,462.311L1816.06,396.044C1824.9,387.197 1829.32,376.436 1829.32,363.77C1829.32,351.086 1824.9,340.332 1816.06,331.504C1789.17,304.992 1755.94,291.738 1716.37,291.738ZM877.977,292.668C841.947,292.194 813.839,301.679 793.662,321.133C775.996,338.036 767.168,359.358 767.168,385.089C767.549,417.363 780.035,441.565 804.624,457.697C811.918,462.687 820.941,466.72 831.693,469.796C837.083,471.72 846.111,474.02 858.777,476.705C869.919,479.391 882.023,481.886 895.088,484.191C897.774,484.962 898.924,486.312 898.543,488.236C898.543,490.541 897.58,491.691 895.657,491.691C890.667,492.072 886.059,492.266 881.831,492.266C850.328,488.81 829.001,485.927 817.859,483.622C814.793,482.851 811.535,482.463 808.078,482.463C796.165,482.463 785.787,486.884 776.949,495.721C768.502,504.178 764.282,514.551 764.282,526.836C764.282,536.825 767.352,545.854 773.494,553.92C780.027,561.986 788.486,567.169 798.866,569.473C831.13,576.778 860.317,580.428 886.429,580.428C922.16,580.428 950.013,570.825 969.992,551.617C987.277,535.094 995.925,513.775 995.925,487.653C995.925,455.388 983.626,431.187 959.037,415.045C945.972,406.598 927.915,400.45 904.869,396.612C891.042,393.927 879.518,391.427 870.3,389.112L870.3,389.127C867.605,388.356 866.067,386.818 865.686,384.513C865.686,382.59 867.224,381.44 870.3,381.059C873.757,380.678 877.415,380.678 881.262,381.059C913.146,384.135 934.652,386.823 945.794,389.127C948.861,389.889 951.931,390.271 955.007,390.271C967.301,390.271 977.674,386.051 986.121,377.604C994.959,368.767 999.379,358.393 999.379,346.49C999.379,336.109 996.109,326.894 989.576,318.837C983.043,310.761 974.79,305.566 964.81,303.261C938.298,297.5 911.788,294.042 885.285,292.89C882.813,292.77 880.379,292.7 877.977,292.668ZM1128.73,292.668C1092.7,292.194 1064.59,301.679 1044.42,321.133C1026.75,338.036 1017.92,359.358 1017.92,385.089C1018.3,417.363 1030.79,441.565 1055.38,457.697C1062.67,462.687 1071.7,466.72 1082.46,469.796C1087.84,471.72 1096.86,474.02 1109.54,476.705C1120.68,479.391 1132.79,481.886 1145.84,484.191C1148.53,484.962 1149.68,486.312 1149.3,488.236C1149.3,490.541 1148.34,491.691 1146.41,491.691C1141.42,492.072 1136.81,492.266 1132.59,492.266C1101.09,488.81 1079.77,485.927 1068.63,483.622C1065.55,482.851 1062.29,482.463 1058.83,482.463C1046.92,482.463 1036.55,486.884 1027.72,495.721C1019.26,504.178 1015.04,514.551 1015.04,526.836C1015.04,536.825 1018.11,545.854 1024.26,553.92C1030.79,561.986 1039.24,567.169 1049.62,569.473C1081.88,576.778 1111.08,580.428 1137.2,580.428C1172.92,580.428 1200.77,570.825 1220.75,551.617C1238.03,535.094 1246.68,513.775 1246.68,487.653C1246.68,455.388 1234.39,431.187 1209.81,415.045C1196.74,406.598 1178.68,400.45 1155.64,396.612C1141.81,393.927 1130.29,391.427 1121.07,389.112L1121.05,389.127C1118.37,388.356 1116.84,386.818 1116.45,384.513C1116.45,382.59 1117.99,381.44 1121.05,381.059C1124.52,380.678 1128.17,380.678 1132.01,381.059C1163.89,384.135 1185.41,386.823 1196.55,389.127C1199.62,389.889 1202.69,390.271 1205.76,390.271C1218.06,390.271 1228.43,386.051 1236.89,377.604C1245.72,368.767 1250.13,358.393 1250.13,346.49C1250.13,336.109 1246.87,326.894 1240.35,318.837C1233.81,310.761 1225.55,305.566 1215.56,303.261C1189.06,297.5 1162.55,294.042 1136.04,292.89C1133.57,292.77 1131.13,292.7 1128.73,292.668ZM1910.17,296.736C1894.04,296.736 1880.21,302.501 1868.69,314.024C1857.55,325.157 1851.98,338.793 1851.98,354.934L1851.98,435.028C1851.98,473.825 1865.8,507.05 1893.45,534.705C1921.12,562.36 1954.36,576.191 1993.15,576.191C2000.84,576.191 2007.95,575.614 2014.48,574.471C2018.32,573.699 2021,574.469 2022.53,576.774C2023.69,578.307 2023.3,580.42 2021.39,583.115C2016.39,590.029 2005.82,593.486 1989.69,593.486C1983.55,593.486 1975.68,591.949 1966.07,588.873C1956.47,585.797 1948.98,584.259 1943.6,584.259C1920.93,584.259 1904.99,594.638 1895.77,615.388C1892.32,622.302 1890.58,629.598 1890.58,637.283C1890.58,659.948 1900.77,675.892 1921.13,685.11C1941.49,694.709 1964.34,699.505 1989.69,699.505C2033.49,699.505 2068.25,686.639 2093.98,660.898C2120.11,635.166 2133.18,600.784 2133.18,557.758L2133.18,452.308C2133.94,446.157 2134.32,440.399 2134.32,435.028L2134.32,354.934C2134.32,338.802 2128.57,325.166 2117.04,314.024C2105.9,302.501 2092.27,296.736 2076.13,296.736C2059.99,296.736 2046.16,302.501 2034.63,314.024C2023.11,325.157 2017.35,338.793 2017.35,354.934L2017.35,435.028C2017.35,441.551 2015.04,447.309 2010.43,452.308C2005.83,456.918 2000.07,459.225 1993.15,459.225C1986.62,459.225 1980.86,456.918 1975.87,452.308C1971.26,447.309 1968.95,441.551 1968.95,435.028L1968.95,354.934C1968.95,338.802 1963.19,325.166 1951.67,314.024C1940.14,302.501 1926.3,296.736 1910.17,296.736ZM683.473,316.947C667.331,316.947 653.495,322.713 641.972,334.236C630.449,345.768 624.691,359.602 624.691,375.744L624.691,518.118C624.691,534.259 630.449,548.095 641.972,559.618C653.504,570.761 667.341,576.331 683.473,576.331C699.624,576.331 713.27,570.761 724.412,559.618C735.935,548.095 741.693,534.259 741.693,518.118L741.693,375.744C741.693,359.593 735.935,345.759 724.412,334.236C713.261,322.713 699.614,316.947 683.473,316.947Z" style="fill:white;fill-rule:nonzero;"/>
+ </g>
+</svg>
diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json
index da82ddc4a1..8a024a678b 100644
--- a/packages/backend/test/tsconfig.json
+++ b/packages/backend/test/tsconfig.json
@@ -33,11 +33,12 @@
"lib": [
"esnext"
],
- "types": ["jest"]
+ "types": ["jest", "node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts",
+ "../src/**/*.test.ts",
"../src/@types/**/*.ts",
]
}
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 221f743d3a..6fe04274e6 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -3,16 +3,18 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
-import { DataSource } from 'typeorm';
+import * as lolex from '@sinonjs/fake-timers';
import rndstr from 'rndstr';
import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js';
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
-import { CoreModule } from '@/core/CoreModule.js';
import { MetaService } from '@/core/MetaService.js';
import { genAid } from '@/misc/id/aid.js';
import { UserCacheService } from '@/core/UserCacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { sleep } from '../utils.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@@ -25,6 +27,7 @@ describe('RoleService', () => {
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let metaService: jest.Mocked<MetaService>;
+ let clock: lolex.InstalledClock;
function createUser(data: Partial<User> = {}) {
const un = rndstr('a-z0-9', 16);
@@ -50,16 +53,12 @@ describe('RoleService', () => {
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
}
- async function assign(roleId: Role['id'], userId: User['id']) {
- await roleAssignmentsRepository.insert({
- id: genAid(new Date()),
- createdAt: new Date(),
- roleId,
- userId,
+ beforeEach(async () => {
+ clock = lolex.install({
+ now: new Date(),
+ shouldClearNativeTimers: true,
});
- }
- beforeEach(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
@@ -67,6 +66,8 @@ describe('RoleService', () => {
providers: [
RoleService,
UserCacheService,
+ IdService,
+ GlobalEventService,
],
})
.useMocker((token) => {
@@ -92,12 +93,15 @@ describe('RoleService', () => {
});
afterEach(async () => {
+ clock.uninstall();
+
await Promise.all([
app.get(DI.metasRepository).delete({}),
usersRepository.delete({}),
rolesRepository.delete({}),
roleAssignmentsRepository.delete({}),
]);
+
await app.close();
});
@@ -115,7 +119,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(false);
});
- test('instance default policies 2', async () => {
+ test('instance default policies 2', async () => {
const user = await createUser();
metaService.fetch.mockResolvedValue({
policies: {
@@ -128,7 +132,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true);
});
- test('with role', async () => {
+ test('with role', async () => {
const user = await createUser();
const role = await createRole({
name: 'a',
@@ -140,7 +144,7 @@ describe('RoleService', () => {
},
},
});
- await assign(role.id, user.id);
+ await roleService.assign(user.id, role.id);
metaService.fetch.mockResolvedValue({
policies: {
canManageCustomEmojis: false,
@@ -152,7 +156,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true);
});
- test('priority', async () => {
+ test('priority', async () => {
const user = await createUser();
const role1 = await createRole({
name: 'role1',
@@ -174,8 +178,8 @@ describe('RoleService', () => {
},
},
});
- await assign(role1.id, user.id);
- await assign(role2.id, user.id);
+ await roleService.assign(user.id, role1.id);
+ await roleService.assign(user.id, role2.id);
metaService.fetch.mockResolvedValue({
policies: {
driveCapacityMb: 50,
@@ -187,7 +191,7 @@ describe('RoleService', () => {
expect(result.driveCapacityMb).toBe(100);
});
- test('conditional role', async () => {
+ test('conditional role', async () => {
const user1 = await createUser({
createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)),
});
@@ -228,5 +232,42 @@ describe('RoleService', () => {
expect(user1Policies.canManageCustomEmojis).toBe(false);
expect(user2Policies.canManageCustomEmojis).toBe(true);
});
+
+ test('expired role', async () => {
+ const user = await createUser();
+ const role = await createRole({
+ name: 'a',
+ policies: {
+ canManageCustomEmojis: {
+ useDefault: false,
+ priority: 0,
+ value: true,
+ },
+ },
+ });
+ await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24)));
+ metaService.fetch.mockResolvedValue({
+ policies: {
+ canManageCustomEmojis: false,
+ },
+ } as any);
+
+ const result = await roleService.getUserPolicies(user.id);
+ expect(result.canManageCustomEmojis).toBe(true);
+
+ clock.tick('25:00:00');
+
+ const resultAfter25h = await roleService.getUserPolicies(user.id);
+ expect(resultAfter25h.canManageCustomEmojis).toBe(false);
+
+ await roleService.assign(user.id, role.id);
+
+ // ストリーミング経由で反映されるまでちょっと待つ
+ clock.uninstall();
+ await sleep(100);
+
+ const resultAfter25hAgain = await roleService.getUserPolicies(user.id);
+ expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true);
+ });
});
});
diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts
new file mode 100644
index 0000000000..c476aef33b
--- /dev/null
+++ b/packages/backend/test/unit/misc/others.ts
@@ -0,0 +1,42 @@
+import { describe, test, expect } from '@jest/globals';
+import { contentDisposition } from '@/misc/content-disposition.js';
+import { correctFilename } from '@/misc/correct-filename.js';
+
+describe('misc:content-disposition', () => {
+ test('inline', () => {
+ expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('attachment', () => {
+ expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('non ascii', () => {
+ expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
+ });
+});
+
+describe('misc:correct-filename', () => {
+ test('simple', () => {
+ expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
+ });
+ test('with same ext', () => {
+ expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
+ });
+ test('.ext', () => {
+ expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
+ });
+ test('with different ext', () => {
+ expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
+ });
+ test('non ascii with space', () => {
+ expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
+ });
+ test('jpeg', () => {
+ expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
+ });
+ test('tiff', () => {
+ expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
+ });
+ test('null ext', () => {
+ expect(correctFilename('filename', null)).toBe('filename.unknown');
+ });
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 50988939aa..8203e49359 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -1,87 +1,50 @@
-import * as fs from 'node:fs';
-import * as path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
-import * as childProcess from 'child_process';
-import * as http from 'node:http';
-import { SIGKILL } from 'constants';
+import { readFile } from 'node:fs/promises';
+import { isAbsolute, basename } from 'node:path';
import WebSocket from 'ws';
-import fetch from 'node-fetch';
-import FormData from 'form-data';
+import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm';
-import got, { RequestError } from 'got';
-import loadConfig from '../src/config/load.js';
-import { entities } from '@/postgres.js';
+import { entities } from '../src/postgres.js';
+import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js';
-const _filename = fileURLToPath(import.meta.url);
-const _dirname = dirname(_filename);
+export { server as startServer } from '@/boot/common.js';
const config = loadConfig();
export const port = config.port;
export const api = async (endpoint: string, params: any, me?: any) => {
- endpoint = endpoint.replace(/^\//, '');
-
- const auth = me ? {
- i: me.token,
- } : {};
-
- try {
- const res = await got<string>(`http://localhost:${port}/api/${endpoint}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(Object.assign(auth, params)),
- retry: {
- limit: 0,
- },
- });
-
- const status = res.statusCode;
- const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
-
- return {
- status,
- body,
- };
- } catch (err: unknown) {
- if (err instanceof RequestError && err.response) {
- const status = err.response.statusCode;
- const body = await JSON.parse(err.response.body as string);
-
- return {
- status,
- body,
- };
- } else {
- throw err;
- }
- }
+ const normalized = endpoint.replace(/^\//, '');
+ return await request(`api/${normalized}`, params, me);
};
-export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
+const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? {
i: me.token,
} : {};
- const res = await fetch(`http://localhost:${port}/${path}`, {
+ const res = await relativeFetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(Object.assign(auth, params)),
+ redirect: 'manual',
});
const status = res.status;
- const body = res.status === 200 ? await res.json().catch() : null;
+ const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
+ ? await res.json()
+ : null;
return {
body, status,
};
};
+const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
+ return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
+};
+
export const signup = async (params?: any): Promise<any> => {
const q = Object.assign({
username: 'test',
@@ -110,30 +73,46 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
}, user);
};
+interface UploadOptions {
+ /** Optional, absolute path or relative from ./resources/ */
+ path?: string | URL;
+ /** The name to be used for the file upload */
+ name?: string;
+ /** A Blob can be provided instead of path */
+ blob?: Blob;
+}
+
/**
* Upload file
* @param user User
- * @param _path Optional, absolute path or relative from ./resources/
*/
-export const uploadFile = async (user: any, _path?: string): Promise<any> => {
- const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`;
+export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise<any> => {
+ const absPath = path == null
+ ? new URL('resources/Lenna.jpg', import.meta.url)
+ : isAbsolute(path.toString())
+ ? new URL(path)
+ : new URL(path, new URL('resources/', import.meta.url));
- const formData = new FormData() as any;
+ const formData = new FormData();
formData.append('i', user.token);
- formData.append('file', fs.createReadStream(absPath));
+ formData.append('file', blob ??
+ new File([await readFile(absPath)], basename(absPath.toString())));
formData.append('force', 'true');
+ if (name) {
+ formData.append('name', name);
+ }
- const res = await got<string>(`http://localhost:${port}/api/drive/files/create`, {
+ const res = await relativeFetch('api/drive/files/create', {
method: 'POST',
body: formData,
- retry: {
- limit: 0,
- },
});
- const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
+ const body = res.status !== 204 ? await res.json() : null;
- return body;
+ return {
+ status: res.status,
+ body,
+ };
};
export const uploadUrl = async (user: any, url: string) => {
@@ -160,7 +139,7 @@ export const uploadUrl = async (user: any, url: string) => {
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
return new Promise((res, rej) => {
- const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`);
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`);
ws.on('open', () => {
ws.on('message', data => {
@@ -187,7 +166,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
return new Promise<boolean>(async (res, rej) => {
- let timer: NodeJS.Timeout;
+ let timer: NodeJS.Timeout | null = null;
let ws: WebSocket;
try {
@@ -219,41 +198,25 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
});
};
-export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => {
- // node-fetchだと3xxを取れない
- return await new Promise((resolve, reject) => {
- const req = http.request(`http://localhost:${port}${path}`, {
- headers: {
- Accept: accept,
- },
- }, res => {
- if (res.statusCode! >= 400) {
- reject(res);
- } else {
- resolve({
- status: res.statusCode,
- type: res.headers['content-type'],
- location: res.headers.location,
- });
- }
- });
-
- req.end();
+export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => {
+ const res = await relativeFetch(path, {
+ headers: {
+ Accept: accept,
+ },
+ redirect: 'manual',
});
-};
-export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise<void> = async () => {}) {
- return (done: (err?: Error) => any) => {
- const p = childProcess.spawn('node', [_dirname + '/../index.js'], {
- stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
- env: { NODE_ENV: 'test', PATH: process.env.PATH },
- });
- callbackSpawnedProcess(p);
- p.on('message', message => {
- if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e));
- });
+ const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
+ ? await res.json()
+ : null;
+
+ return {
+ status: res.status,
+ body,
+ type: res.headers.get('content-type'),
+ location: res.headers.get('location'),
};
-}
+};
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
@@ -275,46 +238,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db;
}
-export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProcess> {
- return new Promise((res, rej) => {
- const t = setTimeout(() => {
- p.kill(SIGKILL);
- rej('timeout to start');
- }, timeout);
-
- const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], {
- stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
- env: { NODE_ENV: 'test', PATH: process.env.PATH },
- });
-
- p.on('error', e => rej(e));
-
- p.on('message', message => {
- if (message === 'ok') {
- clearTimeout(t);
- res(p);
- }
- });
- });
-}
-
-export function shutdownServer(p: childProcess.ChildProcess | undefined, timeout = 20 * 1000) {
- if (p == null) return Promise.resolve('nop');
- return new Promise((res, rej) => {
- const t = setTimeout(() => {
- p.kill(SIGKILL);
- res('force exit');
- }, timeout);
-
- p.once('exit', () => {
- clearTimeout(t);
- res('exited');
- });
-
- p.kill();
- });
-}
-
export function sleep(msec: number) {
return new Promise<void>(res => {
setTimeout(() => {
diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json
index 6f335a2442..faadbcdfc6 100644
--- a/packages/backend/tsconfig.json
+++ b/packages/backend/tsconfig.json
@@ -26,9 +26,7 @@
"rootDir": "./src",
"baseUrl": "./",
"paths": {
- "@/*": [
- "./src/*"
- ]
+ "@/*": ["./src/*"]
},
"outDir": "./built",
"types": [
@@ -46,4 +44,7 @@
"include": [
"./src/**/*.ts"
],
+ "exclude": [
+ "./src/**/*.test.ts"
+ ]
}
diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3
new file mode 100644
index 0000000000..2ef7024beb
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3
new file mode 100644
index 0000000000..a8ad11287e
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3
new file mode 100644
index 0000000000..8cc2ead028
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3
new file mode 100644
index 0000000000..59cbcaf917
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-aec.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec.mp3
new file mode 100644
index 0000000000..7aec0516e7
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-aec.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3
new file mode 100644
index 0000000000..98fe354d67
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3
new file mode 100644
index 0000000000..7b69b3410d
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3
new file mode 100644
index 0000000000..44f2deee3d
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3
new file mode 100644
index 0000000000..1342a56f85
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-cea.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea.mp3
new file mode 100644
index 0000000000..88d641fd64
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-cea.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3
new file mode 100644
index 0000000000..468b82bc2c
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3
new file mode 100644
index 0000000000..3869e894d2
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3
new file mode 100644
index 0000000000..f268b7ee8b
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3
new file mode 100644
index 0000000000..d6e895e67b
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3
new file mode 100644
index 0000000000..8e055db91d
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-ea.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea.mp3
new file mode 100644
index 0000000000..c13d13247b
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-ea.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3
new file mode 100644
index 0000000000..06577d5431
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3
new file mode 100644
index 0000000000..1622033d6a
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3
new file mode 100644
index 0000000000..a3a9f3a9fb
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3
new file mode 100644
index 0000000000..4efa848407
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3
Binary files differ
diff --git a/packages/frontend/assets/sounds/syuilo/n-eca.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca.mp3
new file mode 100644
index 0000000000..1a3979cc61
--- /dev/null
+++ b/packages/frontend/assets/sounds/syuilo/n-eca.mp3
Binary files differ
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 24f8d9b6a6..e4c04f5937 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -41,12 +41,12 @@
"matter-js": "0.19.0",
"mfm-js": "0.23.3",
"misskey-js": "0.0.15",
- "photoswipe": "5.3.5",
+ "photoswipe": "5.3.6",
"prismjs": "1.29.0",
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
- "rollup": "3.17.2",
+ "rollup": "3.17.3",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"sass": "1.58.3",
@@ -54,7 +54,7 @@
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
- "three": "0.149.0",
+ "three": "0.150.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.2",
@@ -63,7 +63,7 @@
"typescript": "4.9.5",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
- "vite": "4.1.2",
+ "vite": "4.1.4",
"vue": "3.2.47",
"vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
@@ -71,29 +71,28 @@
},
"devDependencies": {
"@types/escape-regexp": "0.0.1",
- "@types/glob": "8.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
- "@types/node": "18.14.0",
+ "@types/node": "18.14.1",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.8.0",
- "@types/seedrandom": "3.0.4",
+ "@types/seedrandom": "3.0.5",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
- "@types/uuid": "9.0.0",
+ "@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
- "@typescript-eslint/eslint-plugin": "5.52.0",
- "@typescript-eslint/parser": "5.52.0",
+ "@typescript-eslint/eslint-plugin": "5.53.0",
+ "@typescript-eslint/parser": "5.53.0",
"@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3",
- "cypress": "12.6.0",
- "eslint": "8.34.0",
+ "cypress": "12.7.0",
+ "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0",
"start-server-and-test": "1.15.4",
"vue-eslint-parser": "9.1.0",
- "vue-tsc": "1.1.4"
+ "vue-tsc": "1.2.0"
}
-} \ No newline at end of file
+}
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index d1b5cc5118..043a614e46 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -18,7 +18,7 @@
</div>
</Transition>
<div class="container">
- <img ref="imgEl" :src="imgUrl" style="display: none;" crossorigin="anonymous" @load="onImageLoad">
+ <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div>
</div>
</template>
@@ -49,7 +49,7 @@ const props = defineProps<{
aspectRatio: number;
}>();
-const imgUrl = getProxiedImageUrl(props.file.url);
+const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>();
let imgEl = $shallowRef<HTMLImageElement>();
let cropper: Cropper | null = null;
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index b97e36cd5f..2748a9e491 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -1,41 +1,46 @@
<template>
-<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
- <div :class="$style.header" class="_button" @click="toggle">
- <div :class="$style.headerIcon"><slot name="icon"></slot></div>
- <div :class="$style.headerText">
- <div :class="$style.headerTextMain">
- <slot name="label"></slot>
- </div>
- <div :class="$style.headerTextSub">
- <slot name="caption"></slot>
+<div ref="rootEl" :class="$style.root">
+ <MkStickyContainer>
+ <template #header>
+ <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
+ <div :class="$style.headerIcon"><slot name="icon"></slot></div>
+ <div :class="$style.headerText">
+ <div :class="$style.headerTextMain">
+ <slot name="label"></slot>
+ </div>
+ <div :class="$style.headerTextSub">
+ <slot name="caption"></slot>
+ </div>
+ </div>
+ <div :class="$style.headerRight">
+ <span :class="$style.headerRightText"><slot name="suffix"></slot></span>
+ <i v-if="opened" class="ti ti-chevron-up icon"></i>
+ <i v-else class="ti ti-chevron-down icon"></i>
+ </div>
</div>
+ </template>
+
+ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
+ <Transition
+ :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
+ :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
+ :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
+ :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <KeepAlive>
+ <div v-show="opened">
+ <MkSpacer :margin-min="14" :margin-max="22">
+ <slot></slot>
+ </MkSpacer>
+ </div>
+ </KeepAlive>
+ </Transition>
</div>
- <div :class="$style.headerRight">
- <span :class="$style.headerRightText"><slot name="suffix"></slot></span>
- <i v-if="opened" class="ti ti-chevron-up icon"></i>
- <i v-else class="ti ti-chevron-down icon"></i>
- </div>
- </div>
- <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
- <Transition
- :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
- :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
- :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
- :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
- @enter="enter"
- @after-enter="afterEnter"
- @leave="leave"
- @after-leave="afterLeave"
- >
- <KeepAlive>
- <div v-show="opened">
- <MkSpacer :margin-min="14" :margin-max="22">
- <slot></slot>
- </MkSpacer>
- </div>
- </KeepAlive>
- </Transition>
- </div>
+ </MkStickyContainer>
</div>
</template>
@@ -43,8 +48,8 @@
import { nextTick, onMounted } from 'vue';
const props = withDefaults(defineProps<{
- defaultOpen: boolean;
- maxHeight: number | null;
+ defaultOpen?: boolean;
+ maxHeight?: number | null;
}>(), {
defaultOpen: false,
maxHeight: null,
@@ -117,12 +122,6 @@ onMounted(() => {
.root {
display: block;
-
- &.opened {
- > .header {
- border-radius: 6px 6px 0 0;
- }
- }
}
.header {
@@ -132,6 +131,8 @@ onMounted(() => {
box-sizing: border-box;
padding: 9px 12px 9px 12px;
background: var(--buttonBg);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
border-radius: 6px;
transition: border-radius 0.3s;
@@ -144,6 +145,10 @@ onMounted(() => {
color: var(--accent);
background: var(--buttonHoverBg);
}
+
+ &.opened {
+ border-radius: 6px 6px 0 0;
+ }
}
.headerUpper {
@@ -153,7 +158,7 @@ onMounted(() => {
.headerLower {
color: var(--fgTransparentWeak);
- font-size: .85em;
+ font-size: .85em;
padding-left: 4px;
}
@@ -202,7 +207,6 @@ onMounted(() => {
background: var(--panel);
border-radius: 0 0 6px 6px;
container-type: inline-size;
- overflow: auto;
&.bgSame {
background: var(--bg);
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index a12bb78e35..c768a086cd 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -13,7 +13,7 @@
</template>
<script lang="ts" setup>
-import { onMounted, ref } from 'vue';
+import { onMounted, ref, useCssModule } from 'vue';
import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -29,8 +29,11 @@ const props = defineProps<{
raw?: boolean;
}>();
+const $style = useCssModule();
+
const gallery = ref(null);
const pswpZIndex = os.claimZIndex('middle');
+document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
onMounted(() => {
@@ -54,17 +57,18 @@ onMounted(() => {
return item;
}),
gallery: gallery.value,
+ mainClass: $style.pswp,
children: '.image',
thumbSelector: '.image',
loop: false,
padding: window.innerWidth > 500 ? {
top: 32,
- bottom: 32,
+ bottom: 90,
left: 32,
right: 32,
} : {
top: 0,
- bottom: 0,
+ bottom: 78,
left: 0,
right: 0,
},
@@ -82,6 +86,7 @@ onMounted(() => {
const id = element.dataset.id;
const file = props.mediaList.find(media => media.id === id);
+ if (!file) return;
itemData.src = file.url;
itemData.w = Number(file.properties.width);
@@ -113,6 +118,23 @@ onMounted(() => {
});
lightbox.init();
+
+ window.addEventListener('popstate', () => {
+ if (lightbox.pswp && lightbox.pswp.isOpen === true) {
+ lightbox.pswp.close();
+ return;
+ }
+ });
+
+ lightbox.on('beforeOpen', () => {
+ history.pushState(null, '', '#pswp');
+ });
+
+ lightbox.on('close', () => {
+ if (window.location.hash === '#pswp') {
+ history.back();
+ }
+ });
});
const previewable = (file: misskey.entities.DriveFile): boolean => {
@@ -181,16 +203,14 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
overflow: hidden; // clipにするとバグる
border-radius: 8px;
}
-</style>
-<style lang="scss">
.pswp {
- // なぜか機能しない
- //z-index: v-bind(pswpZIndex);
- z-index: 2000000;
- --pswp-bg: var(--modalBg);
+ --pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
+ --pswp-bg: var(--modalBg) !important;
}
+</style>
+<style lang="scss">
.pswp__bg {
background: var(--modalBg);
backdrop-filter: var(--modalBgFilter);
@@ -202,7 +222,7 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
align-items: center;
position: absolute;
- bottom: 30px;
+ bottom: 20px;
left: 50%;
transform: translateX(-50%);
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index 09d530c4ea..9e3022896c 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -36,7 +36,7 @@
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<span>{{ item.text }}</span>
- <span :class="$style.caret"><i class="ti ti-caret-right ti-fw"></i></span>
+ <span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
</button>
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index dbad02fb7e..4529d61c2f 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -125,7 +125,7 @@ function onBgClick() {
}
if (type === 'drawer') {
- maxHeight = (window.innerHeight - SCROLLBAR_THICKNESS) / 1.5;
+ maxHeight = window.innerHeight / 1.5;
}
const keymap = {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 1040dac12e..bb1269562d 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -4,7 +4,7 @@
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
- :class="$style.root"
+ :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
@@ -32,6 +32,7 @@
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
+ <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div>
</div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
@@ -76,14 +77,14 @@
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
+ <MkReactionsViewer :note="appearNote" :max-number="16">
+ <template #more>
+ <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
+ {{ i18n.ts.more }}
+ </button>
+ </template>
+ </MkReactionsViewer>
<footer :class="$style.footer">
- <MkReactionsViewer :note="appearNote" :max-number="16">
- <template #more>
- <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
- {{ i18n.ts.more }}
- </button>
- </template>
- </MkReactionsViewer>
<button :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p>
@@ -156,6 +157,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu';
+import MkRippleEffect from '@/components/MkRippleEffect.vue';
const props = defineProps<{
note: misskey.entities.Note;
@@ -255,9 +257,19 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
- os.apiWithDialog('notes/create', {
+ const el = renoteButton.value as HTMLElement | null | undefined;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
});
},
}, {
@@ -276,8 +288,18 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
- os.apiWithDialog('notes/create', {
+ const el = renoteButton.value as HTMLElement | null | undefined;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ os.api('notes/create', {
renoteId: appearNote.id,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
});
},
}, {
@@ -443,6 +465,34 @@ function showReactions(): void {
&:hover > .article > .main > .footer > .footerButton {
opacity: 1;
}
+
+ &.showActionsOnlyHover {
+ .footer {
+ visibility: hidden;
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ padding: 0 4px;
+ margin-bottom: 0 !important;
+ background: var(--popup);
+ border-radius: 8px;
+ box-shadow: 0px 4px 32px var(--shadow);
+ }
+
+ .footerButton {
+ font-size: 90%;
+
+ &:not(:last-child) {
+ margin-right: 0;
+ }
+ }
+ }
+
+ &.showActionsOnlyHover:hover {
+ .footer {
+ visibility: visible;
+ }
+ }
}
.tip {
@@ -541,14 +591,15 @@ function showReactions(): void {
}
.article {
+ position: relative;
display: flex;
- padding: 28px 32px 18px;
+ padding: 28px 32px;
}
.avatar {
flex-shrink: 0;
display: block !important;
- margin: 0 14px 8px 0;
+ margin: 0 14px 0 0;
width: 58px;
height: 58px;
position: sticky !important;
@@ -571,9 +622,9 @@ function showReactions(): void {
.showLess {
width: 100%;
- margin-top: 1em;
+ margin-top: 14px;
position: sticky;
- bottom: 1em;
+ bottom: calc(var(--stickyBottom, 0px) + 14px);
}
.showLessLabel {
@@ -653,6 +704,10 @@ function showReactions(): void {
font-size: 80%;
}
+.footer {
+ margin-bottom: -14px;
+}
+
.footerButton {
margin: 0;
padding: 8px;
@@ -683,7 +738,7 @@ function showReactions(): void {
}
.article {
- padding: 24px 26px 14px;
+ padding: 24px 26px;
}
.avatar {
@@ -702,7 +757,11 @@ function showReactions(): void {
}
.article {
- padding: 20px 22px 12px;
+ padding: 20px 22px;
+ }
+
+ .footer {
+ margin-bottom: -8px;
}
}
@@ -721,13 +780,13 @@ function showReactions(): void {
}
.article {
- padding: 14px 16px 9px;
+ padding: 14px 16px;
}
}
@container (max-width: 450px) {
.avatar {
- margin: 0 10px 8px 0;
+ margin: 0 10px 0 0;
width: 46px;
height: 46px;
top: calc(14px + var(--stickyTop, 0px));
@@ -735,17 +794,21 @@ function showReactions(): void {
}
@container (max-width: 400px) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 18px;
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
}
}
}
@container (max-width: 350px) {
- .footerButton {
- &:not(:last-child) {
- margin-right: 12px;
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
}
}
}
@@ -756,9 +819,11 @@ function showReactions(): void {
height: 44px;
}
- .footerButton {
- &:not(:last-child) {
- margin-right: 8px;
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 8px;
+ }
}
}
}
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 2eebe999a5..f5f4a2afc1 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -161,6 +161,7 @@ import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu';
+import MkRippleEffect from '@/components/MkRippleEffect.vue';
const props = defineProps<{
note: misskey.entities.Note;
@@ -250,9 +251,19 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
- os.apiWithDialog('notes/create', {
+ const el = renoteButton.value as HTMLElement | null | undefined;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
});
},
}, {
@@ -271,8 +282,18 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
- os.apiWithDialog('notes/create', {
+ const el = renoteButton.value as HTMLElement | null | undefined;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ os.api('notes/create', {
renoteId: appearNote.id,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
});
},
}, {
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index ffd9a20ef7..15d7ea2e14 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -18,6 +18,7 @@
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span>
+ <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div>
</header>
</template>
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 378d0ac020..a1a61a6fd6 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -21,14 +21,14 @@
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index f73eab5b86..09f672be7b 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -437,8 +437,8 @@ function clear() {
}
function onKeydown(ev: KeyboardEvent) {
- if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post();
- if (ev.which === 27) emit('esc');
+ if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost) post();
+ if (ev.key === 'Escape') emit('esc');
}
function onCompositionUpdate(ev: CompositionEvent) {
@@ -489,9 +489,9 @@ function onDragover(ev) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
- case 'copy':
- case 'copyLink':
- case 'copyMove':
+ case 'copy':
+ case 'copyLink':
+ case 'copyMove':
ev.dataTransfer.dropEffect = 'copy';
break;
case 'linkMove':
@@ -658,7 +658,14 @@ async function post(ev?: MouseEvent) {
if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
claimAchievement('iLoveMisskey');
}
- if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
+ if (
+ text.includes('https://youtu.be/Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://www.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://m.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://youtu.be/XVCwzwxdHuA'.toLowerCase()) ||
+ text.includes('https://www.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) ||
+ text.includes('https://m.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase())
+ ) {
claimAchievement('brainDiver');
}
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
index d8703a0b1b..62ada6b736 100644
--- a/packages/frontend/src/components/MkSignup.vue
+++ b/packages/frontend/src/components/MkSignup.vue
@@ -9,6 +9,7 @@
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
+ <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue
index 81cbde0ac7..6f819bbbd7 100644
--- a/packages/frontend/src/components/MkTab.vue
+++ b/packages/frontend/src/components/MkTab.vue
@@ -34,7 +34,7 @@ export default defineComponent({
> button {
flex: 1;
padding: 10px 8px;
- border-radius: var(--radius);
+ border-radius: 999px;
&:disabled {
opacity: 1 !important;
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
index 1aa48f88e6..6d59702569 100644
--- a/packages/frontend/src/components/MkToast.vue
+++ b/packages/frontend/src/components/MkToast.vue
@@ -53,7 +53,7 @@ onMounted(() => {
position: fixed;
left: 0;
right: 0;
- top: 0;
+ top: 50px;
margin: 0 auto;
margin-top: 16px;
min-width: 300px;
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
new file mode 100644
index 0000000000..93e914f6dd
--- /dev/null
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -0,0 +1,222 @@
+<template>
+<Transition
+ :enter-active-class="$store.state.animation ? $style.transition_popup_enterActive : ''"
+ :leave-active-class="$store.state.animation ? $style.transition_popup_leaveActive : ''"
+ :enter-from-class="$store.state.animation ? $style.transition_popup_enterFrom : ''"
+ :leave-to-class="$store.state.animation ? $style.transition_popup_leaveTo : ''"
+ appear @after-leave="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">
+ <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
+ <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ $ts.followsYou }}</span>
+ </div>
+ <svg viewBox="0 0 128 128" :class="$style.avatarBack">
+ <g transform="matrix(1.6,0,0,1.6,-38.4,-51.2)">
+ <path d="M64,32C81.661,32 96,46.339 96,64C95.891,72.184 104,72 104,72C104,72 74.096,80 64,80C52.755,80 24,72 24,72C24,72 31.854,72.018 32,64C32,46.339 46.339,32 64,32Z" style="fill: var(--popup);"/>
+ </g>
+ </svg>
+ <MkAvatar :class="$style.avatar" :user="user" indicator/>
+ <div :class="$style.title">
+ <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <div :class="$style.username"><MkAcct :user="user"/></div>
+ </div>
+ <div :class="$style.description">
+ <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i"/>
+ <div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
+ </div>
+ <div :class="$style.status">
+ <div :class="$style.statusItem">
+ <div :class="$style.statusItemLabel">{{ $ts.notes }}</div>
+ <div>{{ number(user.notesCount) }}</div>
+ </div>
+ <div :class="$style.statusItem">
+ <div :class="$style.statusItemLabel">{{ $ts.following }}</div>
+ <div>{{ number(user.followingCount) }}</div>
+ </div>
+ <div :class="$style.statusItem">
+ <div :class="$style.statusItemLabel">{{ $ts.followers }}</div>
+ <div>{{ number(user.followersCount) }}</div>
+ </div>
+ </div>
+ <button class="_button" :class="$style.menu" @click="showMenu"><i class="ti ti-dots"></i></button>
+ <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+ </div>
+</Transition>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import MkFollowButton from '@/components/MkFollowButton.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { getUserMenu } from '@/scripts/get-user-menu';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ showing: boolean;
+ q: string;
+ source: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+ (ev: 'mouseover'): void;
+ (ev: 'mouseleave'): void;
+}>();
+
+const zIndex = os.claimZIndex('middle');
+let user = $ref<misskey.entities.UserDetailed | null>(null);
+let top = $ref(0);
+let left = $ref(0);
+
+function showMenu(ev: MouseEvent) {
+ os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
+}
+
+onMounted(() => {
+ if (typeof props.q === 'object') {
+ user = props.q;
+ } else {
+ const query = props.q.startsWith('@') ?
+ Acct.parse(props.q.substr(1)) :
+ { userId: props.q };
+
+ os.api('users/show', query).then(res => {
+ if (!props.showing) return;
+ user = res;
+ });
+ }
+
+ const rect = props.source.getBoundingClientRect();
+ const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
+ const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+
+ top = y;
+ left = x;
+});
+</script>
+
+<style lang="scss" module>
+.transition_popup_enterActive,
+.transition_popup_leaveActive {
+ transition: opacity 0.15s, transform 0.15s !important;
+}
+.transition_popup_enterFrom,
+.transition_popup_leaveTo {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.root {
+ position: absolute;
+ width: 300px;
+ overflow: clip;
+ transform-origin: center top;
+}
+
+.banner {
+ height: 78px;
+ background-color: rgba(0, 0, 0, 0.1);
+ background-size: cover;
+ background-position: center;
+}
+
+.followed {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ padding: 4px 8px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ font-size: 0.7em;
+ border-radius: 6px;
+}
+
+.avatarBack {
+ width: 100px;
+ position: absolute;
+ top: 28px;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+}
+
+.avatar {
+ display: block;
+ position: absolute;
+ top: 38px;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ z-index: 2;
+ width: 58px;
+ height: 58px;
+}
+
+.title {
+ position: relative;
+ z-index: 3;
+ display: block;
+ padding: 8px 26px 16px 26px;
+ margin-top: 16px;
+ text-align: center;
+}
+
+.name {
+ display: inline-block;
+ font-weight: bold;
+ word-break: break-all;
+}
+
+.username {
+ display: block;
+ font-size: 0.8em;
+ opacity: 0.7;
+}
+
+.description {
+ padding: 16px 26px;
+ font-size: 0.8em;
+ text-align: center;
+ border-top: solid 1px var(--divider);
+ border-bottom: solid 1px var(--divider);
+}
+
+.status {
+ padding: 16px 26px 16px 26px;
+}
+
+.statusItem {
+ display: inline-block;
+ width: 33%;
+ text-align: center;
+}
+
+.statusItemLabel {
+ font-size: 0.7em;
+ color: var(--fgTransparentWeak);
+}
+
+.menu {
+ position: absolute;
+ top: 8px;
+ right: 44px;
+ padding: 6px;
+ background: var(--panel);
+ border-radius: 999px;
+}
+
+.follow {
+ position: absolute !important;
+ top: 8px;
+ right: 8px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue
deleted file mode 100644
index 1086a2c651..0000000000
--- a/packages/frontend/src/components/MkUserPreview.vue
+++ /dev/null
@@ -1,199 +0,0 @@
-<template>
-<Transition :name="$store.state.animation ? 'popup' : ''" appear @after-leave="emit('closed')">
- <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
- <div v-if="user != null" class="info">
- <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''">
- <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
- </div>
- <MkAvatar class="avatar" :user="user" indicator/>
- <div class="title">
- <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
- <p class="username"><MkAcct :user="user"/></p>
- </div>
- <div class="description">
- <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i"/>
- </div>
- <div class="status">
- <div>
- <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
- </div>
- <div>
- <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
- </div>
- <div>
- <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
- </div>
- </div>
- <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
- <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
- </div>
- <div v-else>
- <MkLoading/>
- </div>
- </div>
-</Transition>
-</template>
-
-<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as Acct from 'misskey-js/built/acct';
-import * as misskey from 'misskey-js';
-import MkFollowButton from '@/components/MkFollowButton.vue';
-import { userPage } from '@/filters/user';
-import * as os from '@/os';
-import { getUserMenu } from '@/scripts/get-user-menu';
-
-const props = defineProps<{
- showing: boolean;
- q: string;
- source: HTMLElement;
-}>();
-
-const emit = defineEmits<{
- (ev: 'closed'): void;
- (ev: 'mouseover'): void;
- (ev: 'mouseleave'): void;
-}>();
-
-const zIndex = os.claimZIndex('middle');
-let user = $ref<misskey.entities.UserDetailed | null>(null);
-let top = $ref(0);
-let left = $ref(0);
-
-function showMenu(ev: MouseEvent) {
- os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
-}
-
-onMounted(() => {
- if (typeof props.q === 'object') {
- user = props.q;
- } else {
- const query = props.q.startsWith('@') ?
- Acct.parse(props.q.substr(1)) :
- { userId: props.q };
-
- os.api('users/show', query).then(res => {
- if (!props.showing) return;
- user = res;
- });
- }
-
- const rect = props.source.getBoundingClientRect();
- const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
- const y = rect.top + props.source.offsetHeight + window.pageYOffset;
-
- top = y;
- left = x;
-});
-</script>
-
-<style lang="scss" scoped>
-.popup-enter-active, .popup-leave-active {
- transition: opacity 0.15s, transform 0.15s !important;
-}
-.popup-enter-from, .popup-leave-to {
- opacity: 0;
- transform: scale(0.9);
-}
-
-.fxxzrfni {
- position: absolute;
- width: 300px;
- overflow: hidden;
- transform-origin: center top;
-
- > .info {
- > .banner {
- height: 84px;
- background-color: rgba(0, 0, 0, 0.1);
- background-size: cover;
- background-position: center;
- > .followed {
- position: absolute;
- top: 12px;
- left: 12px;
- padding: 4px 8px;
- color: #fff;
- background: rgba(0, 0, 0, 0.7);
- font-size: 0.7em;
- border-radius: 6px;
- }
- }
-
- > .avatar {
- display: block;
- position: absolute;
- top: 62px;
- left: 13px;
- z-index: 2;
- width: 58px;
- height: 58px;
- border: solid 3px var(--face);
- border-radius: 8px;
- }
-
- > .title {
- display: block;
- padding: 8px 0 8px 82px;
-
- > .name {
- display: inline-block;
- margin: 0;
- font-weight: bold;
- line-height: 16px;
- word-break: break-all;
- }
-
- > .username {
- display: block;
- margin: 0;
- line-height: 16px;
- font-size: 0.8em;
- color: var(--fg);
- opacity: 0.7;
- }
- }
-
- > .description {
- padding: 0 16px;
- font-size: 0.8em;
- color: var(--fg);
- }
-
- > .status {
- padding: 8px 16px;
-
- > div {
- display: inline-block;
- width: 33%;
-
- > p {
- margin: 0;
- font-size: 0.7em;
- color: var(--fg);
- }
-
- > span {
- font-size: 1em;
- color: var(--accent);
- }
- }
- }
-
- > .menu {
- position: absolute;
- top: 8px;
- right: 44px;
- padding: 6px;
- background: var(--panel);
- border-radius: 999px;
- }
-
- > .koudoku-button {
- position: absolute;
- top: 8px;
- right: 8px;
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 7e53507f2e..e7ad2b9a43 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -23,7 +23,7 @@
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
</span>
</div>
- <div :class="$style.content">
+ <div v-container :class="$style.content">
<slot></slot>
</div>
</div>
@@ -465,7 +465,7 @@ defineExpose({
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
//border-bottom: solid 1px var(--divider);
- font-size: 95%;
+ font-size: 90%;
font-weight: bold;
}
diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue
index a838164978..55308b9c80 100644
--- a/packages/frontend/src/components/form/section.vue
+++ b/packages/frontend/src/components/form/section.vue
@@ -1,7 +1,7 @@
<template>
-<div class="vrtktovh" :class="{ first }">
- <div class="label"><slot name="label"></slot></div>
- <div class="main">
+<div :class="[$style.root, { [$style.rootFirst]: first }]">
+ <div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
+ <div :class="$style.main">
<slot></slot>
</div>
</div>
@@ -13,31 +13,31 @@ defineProps<{
}>();
</script>
-<style lang="scss" scoped>
-.vrtktovh {
+<style lang="scss" module>
+.root {
border-top: solid 0.5px var(--divider);
//border-bottom: solid 0.5px var(--divider);
+}
- > .label {
- font-weight: bold;
- padding: 1.5em 0 0 0;
- margin: 0 0 16px 0;
+.rootFirst {
+ border-top: none;
+}
- &:empty {
- display: none;
- }
- }
+.label {
+ font-weight: bold;
+ padding: 1.5em 0 0 0;
+ margin: 0 0 16px 0;
- > .main {
- margin: 1.5em 0 0 0;
+ &:empty {
+ display: none;
}
+}
- &.first {
- border-top: none;
+.labelFirst {
+ padding-top: 0;
+}
- > .label {
- padding-top: 0;
- }
- }
+.main {
+ margin: 1.5em 0 0 0;
}
</style>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 589ca92d75..4d968db6a3 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -96,7 +96,7 @@ function onTabClick(): void {
}
const calcBg = () => {
- const rawBg = metadata?.bg ?? 'var(--bg)';
+ const rawBg = 'var(--bg)';
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
tinyBg.setAlpha(0.85);
bg.value = tinyBg.toRgbString();
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index a3fee91a36..44c02088da 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -6,20 +6,19 @@
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
<slot></slot>
</div>
+ <div ref="footerEl">
+ <slot name="footer"></slot>
+ </div>
</div>
</template>
-<script lang="ts">
-// なんか動かない
-//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
-const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
-</script>
-
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
+import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const';
const rootEl = $shallowRef<HTMLElement>();
const headerEl = $shallowRef<HTMLElement>();
+const footerEl = $shallowRef<HTMLElement>();
const bodyEl = $shallowRef<HTMLElement>();
let headerHeight = $ref<string | undefined>();
@@ -27,9 +26,23 @@ let childStickyTop = $ref(0);
const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0));
provide(CURRENT_STICKY_TOP, $$(childStickyTop));
+let footerHeight = $ref<string | undefined>();
+let childStickyBottom = $ref(0);
+const parentStickyBottom = inject<Ref<number>>(CURRENT_STICKY_BOTTOM, ref(0));
+provide(CURRENT_STICKY_BOTTOM, $$(childStickyBottom));
+
const calc = () => {
- childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
- headerHeight = headerEl.offsetHeight.toString();
+ // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる
+ if (headerEl != null) {
+ childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
+ headerHeight = headerEl.offsetHeight.toString();
+ }
+
+ // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる
+ if (footerEl != null) {
+ childStickyBottom = parentStickyBottom.value + footerEl.offsetHeight;
+ footerHeight = footerEl.offsetHeight.toString();
+ }
};
const observer = new ResizeObserver(() => {
@@ -41,7 +54,7 @@ const observer = new ResizeObserver(() => {
onMounted(() => {
calc();
- watch(parentStickyTop, calc);
+ watch([parentStickyTop, parentStickyBottom], calc);
watch($$(childStickyTop), () => {
bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`);
@@ -49,11 +62,22 @@ onMounted(() => {
immediate: true,
});
+ watch($$(childStickyBottom), () => {
+ bodyEl.style.setProperty('--stickyBottom', `${childStickyBottom}px`);
+ }, {
+ immediate: true,
+ });
+
headerEl.style.position = 'sticky';
headerEl.style.top = 'var(--stickyTop, 0)';
headerEl.style.zIndex = '1000';
+ footerEl.style.position = 'sticky';
+ footerEl.style.bottom = 'var(--stickyBottom, 0)';
+ footerEl.style.zIndex = '1000';
+
observer.observe(headerEl);
+ observer.observe(footerEl);
});
onUnmounted(() => {
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 1d44786a63..46ebc7d6a3 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -46,3 +46,28 @@ https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
+
+export const ROLE_POLICIES = [
+ 'gtlAvailable',
+ 'ltlAvailable',
+ 'canPublicNote',
+ 'canInvite',
+ 'canManageCustomEmojis',
+ 'canHideAds',
+ 'driveCapacityMb',
+ 'pinLimit',
+ 'antennaLimit',
+ 'wordMuteLimit',
+ 'webhookLimit',
+ 'clipLimit',
+ 'noteEachClipsLimit',
+ 'userListLimit',
+ 'userEachUserListsLimit',
+ 'rateLimitFactor',
+] as const;
+
+// なんか動かない
+//export const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
+//export const CURRENT_STICKY_BOTTOM = Symbol('CURRENT_STICKY_BOTTOM');
+export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
+export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM';
diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts
index 9ce370e7e8..a89a420d77 100644
--- a/packages/frontend/src/custom-emojis.ts
+++ b/packages/frontend/src/custom-emojis.ts
@@ -3,9 +3,10 @@ import * as Misskey from 'misskey-js';
import { api, apiGet } from './os';
import { miLocalStorage } from './local-storage';
import { stream } from '@/stream';
+import { get, set } from '@/scripts/idb-proxy';
-const storageCache = miLocalStorage.getItem('emojis');
-export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(storageCache ? JSON.parse(storageCache) : []);
+const storageCache = await get('emojis');
+export const customEmojis = shallowRef<Misskey.entities.CustomEmoji[]>(Array.isArray(storageCache) ? storageCache : []);
export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
const categories = new Set<string>();
for (const emoji of customEmojis.value) {
@@ -18,31 +19,39 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
stream.on('emojiAdded', emojiData => {
customEmojis.value = [emojiData.emoji, ...customEmojis.value];
+ set('emojis', customEmojis.value);
});
stream.on('emojiUpdated', emojiData => {
customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.CustomEmoji ?? item);
+ set('emojis', customEmojis.value);
});
stream.on('emojiDeleted', emojiData => {
customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name));
+ set('emojis', customEmojis.value);
});
export async function fetchCustomEmojis(force = false) {
const now = Date.now();
+ const needsMigration = miLocalStorage.getItem('emojis') != null;
let res;
- if (force) {
+ if (force || needsMigration) {
res = await api('emojis', {});
} else {
- const lastFetchedAt = miLocalStorage.getItem('lastEmojisFetchedAt');
- if (lastFetchedAt && (now - parseInt(lastFetchedAt)) < 1000 * 60 * 60) return;
+ const lastFetchedAt = await get('lastEmojisFetchedAt');
+ if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return;
res = await apiGet('emojis', {});
}
customEmojis.value = res.emojis;
- miLocalStorage.setItem('emojis', JSON.stringify(res.emojis));
- miLocalStorage.setItem('lastEmojisFetchedAt', now.toString());
+ set('emojis', res.emojis);
+ set('lastEmojisFetchedAt', now);
+ if (needsMigration) {
+ miLocalStorage.removeItem('emojis');
+ miLocalStorage.removeItem('lastEmojisFetchedAt');
+ }
}
let cachedTags;
diff --git a/packages/frontend/src/directives/container.ts b/packages/frontend/src/directives/container.ts
new file mode 100644
index 0000000000..a8a93eb9be
--- /dev/null
+++ b/packages/frontend/src/directives/container.ts
@@ -0,0 +1,21 @@
+import { Directive } from 'vue';
+
+const map = new WeakMap<HTMLElement, ResizeObserver>();
+
+export default {
+ mounted(el: HTMLElement, binding, vn) {
+ const ro = new ResizeObserver((entries, observer) => {
+ el.style.setProperty('--containerHeight', el.offsetHeight + 'px');
+ });
+ ro.observe(el);
+ map.set(el, ro);
+ },
+
+ unmounted(el, binding, vn) {
+ const ro = map.get(el);
+ if (ro) {
+ ro.disconnect();
+ map.delete(el);
+ }
+ },
+} as Directive;
diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts
index a690fd6c42..854f0a544e 100644
--- a/packages/frontend/src/directives/index.ts
+++ b/packages/frontend/src/directives/index.ts
@@ -11,6 +11,7 @@ import clickAnime from './click-anime';
import panel from './panel';
import adaptiveBorder from './adaptive-border';
import adaptiveBg from './adaptive-bg';
+import container from './container';
export default function(app: App) {
app.directive('userPreview', userPreview);
@@ -25,4 +26,5 @@ export default function(app: App) {
app.directive('panel', panel);
app.directive('adaptive-border', adaptiveBorder);
app.directive('adaptive-bg', adaptiveBg);
+ app.directive('container', container);
}
diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts
index ed5f00ca65..2f5936de3d 100644
--- a/packages/frontend/src/directives/user-preview.ts
+++ b/packages/frontend/src/directives/user-preview.ts
@@ -24,7 +24,7 @@ export class UserPreview {
const showing = ref(true);
- popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), {
+ popup(defineAsyncComponent(() => import('@/components/MkUserPopup.vue')), {
showing,
q: this.user,
source: this.el,
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index e6b828696c..38462c8a65 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -2,8 +2,6 @@ type Keys =
'v' |
'lastVersion' |
'instance' |
- 'emojis' | // TODO: indexed db
- 'lastEmojisFetchedAt' |
'account' |
'accounts' |
'latestDonationInfoShownAt' |
@@ -28,7 +26,9 @@ type Keys =
`miux:${string}` |
`ui:folder:${string}` |
`themes:${string}` |
- `aiscript:${string}`;
+ `aiscript:${string}` |
+ 'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
+ 'emojis' // DEPRECATED, stored in indexeddb (13.9.0~);
export const miLocalStorage = {
getItem: (key: Keys) => window.localStorage.getItem(key),
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 48bece05fa..efc0e8c920 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -1,6 +1,7 @@
import { computed, reactive } from 'vue';
import { $i } from './account';
import { miLocalStorage } from './local-storage';
+import { openInstanceMenu } from './ui/_common_/common';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { ui } from '@/config';
@@ -121,6 +122,13 @@ export const navbarItemDef = reactive({
}], ev.currentTarget ?? ev.target);
},
},
+ about: {
+ title: i18n.ts.about,
+ icon: 'ti ti-info-circle',
+ action: (ev) => {
+ openInstanceMenu(ev);
+ },
+ },
reload: {
title: i18n.ts.reload,
icon: 'ti ti-refresh',
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 7e38fe5f6d..f0af9f081b 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -362,7 +362,7 @@ export function select<C = any>(props: {
});
}
-export function success() {
+export function success(): Promise<void> {
return new Promise((resolve, reject) => {
const showing = ref(true);
window.setTimeout(() => {
@@ -377,7 +377,7 @@ export function success() {
});
}
-export function waiting() {
+export function waiting(): Promise<void> {
return new Promise((resolve, reject) => {
const showing = ref(true);
popup(MkWaitingDialog, {
@@ -528,7 +528,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
width?: number;
viaKeyboard?: boolean;
onClosing?: () => void;
-}) {
+}): Promise<void> {
return new Promise((resolve, reject) => {
let dispose;
popup(MkPopupMenu, {
@@ -551,7 +551,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
});
}
-export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
+export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> {
ev.preventDefault();
return new Promise((resolve, reject) => {
let dispose;
@@ -569,7 +569,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
});
}
-export function post(props: Record<string, any> = {}) {
+export function post(props: Record<string, any> = {}): Promise<void> {
return new Promise((resolve, reject) => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 782fe9fdb2..3c073fc7c4 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -85,8 +85,10 @@
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
</FormSection>
<FormSection>
- <template #label>Credits</template>
- <p>Misskeyで使われる画像の一部は、許可を得て「あの子がこっちを見てるメーカー」で作成したものが含まれます。</p>
+ <template #label>Special thanks</template>
+ <div style="text-align: center;">
+ <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
+ </div>
</FormSection>
</div>
</MkSpacer>
@@ -121,6 +123,9 @@ const patronsWithIcon = [{
}, {
name: 'ひとぅ',
icon: 'https://misskey-hub.net/patrons/8cc0d0a0a6d84c88bca1aedabf6ed5ab.jpg',
+}, {
+ name: 'ぱーこ',
+ icon: 'https://misskey-hub.net/patrons/79c6602ffade489e8df2fcf2c2bc5d9d.jpg',
}];
const patrons = [
@@ -203,6 +208,8 @@ const patrons = [
'ThatOneCalculator',
'pixeldesu',
'あめ玉',
+ '氷月氷華里',
+ 'Ebise Lutica',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
index 4ef6a5a19e..b742132af6 100644
--- a/packages/frontend/src/pages/admin/email-settings.vue
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -1,6 +1,6 @@
<template>
<MkStickyContainer>
- <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
@@ -45,6 +45,16 @@
</div>
</FormSuspense>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <div class="_buttons">
+ <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton rounded @click="testEmail"><i class="ti ti-send"></i> {{ i18n.ts.testEmail }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
@@ -61,6 +71,7 @@ import * as os from '@/os';
import { fetchInstance, instance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import MkButton from '@/components/MkButton.vue';
let enableEmail: boolean = $ref(false);
let email: any = $ref(null);
@@ -109,17 +120,6 @@ function save() {
});
}
-const headerActions = $computed(() => [{
- asFullButton: true,
- text: i18n.ts.testEmail,
- handler: testEmail,
-}, {
- asFullButton: true,
- icon: 'ti ti-check',
- text: i18n.ts.save,
- handler: save,
-}]);
-
const headerTabs = $computed(() => []);
definePageMetadata({
@@ -127,3 +127,10 @@ definePageMetadata({
icon: 'ti ti-mail',
});
</script>
+
+<style lang="scss" module>
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
index bd7c203512..cbe38b2d81 100644
--- a/packages/frontend/src/pages/admin/object-storage.vue
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -1,6 +1,6 @@
<template>
<MkStickyContainer>
- <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
@@ -65,6 +65,13 @@
</div>
</FormSuspense>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
@@ -79,6 +86,7 @@ import * as os from '@/os';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import MkButton from '@/components/MkButton.vue';
let useObjectStorage: boolean = $ref(false);
let objectStorageBaseUrl: string | null = $ref(null);
@@ -131,13 +139,6 @@ function save() {
});
}
-const headerActions = $computed(() => [{
- asFullButton: true,
- icon: 'ti ti-check',
- text: i18n.ts.save,
- handler: save,
-}]);
-
const headerTabs = $computed(() => []);
definePageMetadata({
@@ -145,3 +146,10 @@ definePageMetadata({
icon: 'ti ti-cloud',
});
</script>
+
+<style lang="scss" module>
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index ae884c0111..ac6cca84c1 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -1,22 +1,31 @@
<template>
<div>
<MkStickyContainer>
- <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600">
- <XEditor :role="role" @created="created" @updated="updated"/>
+ <template #header><XHeader :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+ <XEditor v-if="data" v-model="data"/>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="600" :margin-min="16" :margin-max="16">
+ <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
+import { v4 as uuid } from 'uuid';
import XHeader from './_header_.vue';
import XEditor from './roles.editor.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { useRouter } from '@/router';
+import MkButton from '@/components/MkButton.vue';
const router = useRouter();
@@ -25,23 +34,46 @@ const props = defineProps<{
}>();
let role = $ref(null);
+let data = $ref(null);
if (props.id) {
role = await os.api('admin/roles/show', {
roleId: props.id,
});
-}
-function created(r) {
- router.push('/admin/roles/' + r.id);
+ data = role;
+} else {
+ data = {
+ name: 'New Role',
+ description: '',
+ isAdministrator: false,
+ isModerator: false,
+ color: null,
+ iconUrl: null,
+ target: 'manual',
+ condFormula: { id: uuid(), type: 'isRemote' },
+ isPublic: false,
+ asBadge: false,
+ canEditMembersByModerator: false,
+ policies: {},
+ };
}
-function updated() {
- router.push('/admin/roles/' + role.id);
+async function save() {
+ if (role) {
+ os.apiWithDialog('admin/roles/update', {
+ roleId: role.id,
+ ...data,
+ });
+ router.push('/admin/roles/' + role.id);
+ } else {
+ const created = await os.apiWithDialog('admin/roles/create', {
+ ...data,
+ });
+ router.push('/admin/roles/' + created.id);
+ }
}
-const headerActions = $computed(() => []);
-
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => role ? {
@@ -54,5 +86,8 @@ definePageMetadata(computed(() => role ? {
</script>
<style lang="scss" module>
-
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
</style>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 4eea827de7..2fb605f8c0 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -1,19 +1,19 @@
<template>
<div class="_gaps">
- <MkInput v-model="name" :readonly="readonly">
+ <MkInput v-model="role.name" :readonly="readonly">
<template #label>{{ i18n.ts._role.name }}</template>
</MkInput>
- <MkTextarea v-model="description" :readonly="readonly">
+ <MkTextarea v-model="role.description" :readonly="readonly">
<template #label>{{ i18n.ts._role.description }}</template>
</MkTextarea>
- <MkInput v-model="color">
+ <MkInput v-model="role.color">
<template #label>{{ i18n.ts.color }}</template>
<template #caption>#RRGGBB</template>
</MkInput>
- <MkInput v-model="iconUrl">
+ <MkInput v-model="role.iconUrl">
<template #label>{{ i18n.ts._role.iconUrl }}</template>
</MkInput>
@@ -25,31 +25,31 @@
<option value="administrator">{{ i18n.ts.administrator }}</option>
</MkSelect>
- <MkSelect v-model="target" :readonly="readonly">
+ <MkSelect v-model="role.target" :readonly="readonly">
<template #label><i class="ti ti-users"></i> {{ i18n.ts._role.assignTarget }}</template>
<template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template>
<option value="manual">{{ i18n.ts._role.manual }}</option>
<option value="conditional">{{ i18n.ts._role.conditional }}</option>
</MkSelect>
- <MkFolder v-if="target === 'conditional'" default-open>
+ <MkFolder v-if="role.target === 'conditional'" default-open>
<template #label>{{ i18n.ts._role.condition }}</template>
<div class="_gaps">
- <RolesEditorFormula v-model="condFormula"/>
+ <RolesEditorFormula v-model="role.condFormula"/>
</div>
</MkFolder>
- <MkSwitch v-model="canEditMembersByModerator" :readonly="readonly">
+ <MkSwitch v-model="role.canEditMembersByModerator" :readonly="readonly">
<template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template>
</MkSwitch>
- <MkSwitch v-model="isPublic" :readonly="readonly">
+ <MkSwitch v-model="role.isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublic }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
- <MkSwitch v-model="asBadge" :readonly="readonly">
+ <MkSwitch v-model="role.asBadge" :readonly="readonly">
<template #label>{{ i18n.ts._role.asBadge }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch>
@@ -64,19 +64,19 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #suffix>
- <span v-if="policies.rateLimitFactor.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ `${Math.floor(policies.rateLimitFactor.value * 100)}%` }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.rateLimitFactor)"></i></span>
+ <span v-if="role.policies.rateLimitFactor.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ `${Math.floor(role.policies.rateLimitFactor.value * 100)}%` }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.rateLimitFactor)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkRange :model-value="policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor.value = (v / 100)">
+ <MkRange :model-value="role.policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => role.policies.rateLimitFactor.value = (v / 100)">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange>
- <MkRange v-model="policies.rateLimitFactor.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.rateLimitFactor.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -85,18 +85,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.gtlAvailable, 'gtlAvailable'])">
<template #label>{{ i18n.ts._role._options.gtlAvailable }}</template>
<template #suffix>
- <span v-if="policies.gtlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.gtlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.gtlAvailable)"></i></span>
+ <span v-if="role.policies.gtlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.gtlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.gtlAvailable)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.gtlAvailable.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.gtlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.gtlAvailable.value" :disabled="policies.gtlAvailable.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.gtlAvailable.value" :disabled="role.policies.gtlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.gtlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.gtlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -105,18 +105,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])">
<template #label>{{ i18n.ts._role._options.ltlAvailable }}</template>
<template #suffix>
- <span v-if="policies.ltlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.ltlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.ltlAvailable)"></i></span>
+ <span v-if="role.policies.ltlAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.ltlAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.ltlAvailable)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.ltlAvailable.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.ltlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.ltlAvailable.value" :disabled="policies.ltlAvailable.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.ltlAvailable.value" :disabled="role.policies.ltlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.ltlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.ltlAvailable.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -125,18 +125,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])">
<template #label>{{ i18n.ts._role._options.canPublicNote }}</template>
<template #suffix>
- <span v-if="policies.canPublicNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.canPublicNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canPublicNote)"></i></span>
+ <span v-if="role.policies.canPublicNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.canPublicNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canPublicNote)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.canPublicNote.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canPublicNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.canPublicNote.value" :disabled="policies.canPublicNote.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canPublicNote.value" :disabled="role.policies.canPublicNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.canPublicNote.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.canPublicNote.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -145,18 +145,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>
- <span v-if="policies.canInvite.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.canInvite.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canInvite)"></i></span>
+ <span v-if="role.policies.canInvite.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.canInvite.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canInvite)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.canInvite.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canInvite.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.canInvite.value" :disabled="policies.canInvite.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canInvite.value" :disabled="role.policies.canInvite.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.canInvite.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.canInvite.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -165,18 +165,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>
- <span v-if="policies.canManageCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.canManageCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canManageCustomEmojis)"></i></span>
+ <span v-if="role.policies.canManageCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.canManageCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canManageCustomEmojis)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.canManageCustomEmojis.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canManageCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.canManageCustomEmojis.value" :disabled="policies.canManageCustomEmojis.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canManageCustomEmojis.value" :disabled="role.policies.canManageCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.canManageCustomEmojis.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.canManageCustomEmojis.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -185,18 +185,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>
- <span v-if="policies.driveCapacityMb.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.driveCapacityMb.value + 'MB' }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.driveCapacityMb)"></i></span>
+ <span v-if="role.policies.driveCapacityMb.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.driveCapacityMb.value + 'MB' }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.driveCapacityMb)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.driveCapacityMb.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.driveCapacityMb.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.driveCapacityMb.value" :disabled="policies.driveCapacityMb.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.driveCapacityMb.value" :disabled="role.policies.driveCapacityMb.useDefault" type="number" :readonly="readonly">
<template #suffix>MB</template>
</MkInput>
- <MkRange v-model="policies.driveCapacityMb.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.driveCapacityMb.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -205,17 +205,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>
- <span v-if="policies.pinLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.pinLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.pinLimit)"></i></span>
+ <span v-if="role.policies.pinLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.pinLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.pinLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.pinLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.pinLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.pinLimit.value" :disabled="policies.pinLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.pinLimit.value" :disabled="role.policies.pinLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.pinLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.pinLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -224,17 +224,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])">
<template #label>{{ i18n.ts._role._options.antennaMax }}</template>
<template #suffix>
- <span v-if="policies.antennaLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.antennaLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.antennaLimit)"></i></span>
+ <span v-if="role.policies.antennaLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.antennaLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.antennaLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.antennaLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.antennaLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.antennaLimit.value" :disabled="policies.antennaLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.antennaLimit.value" :disabled="role.policies.antennaLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.antennaLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.antennaLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -243,18 +243,18 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.wordMuteMax, 'wordMuteLimit'])">
<template #label>{{ i18n.ts._role._options.wordMuteMax }}</template>
<template #suffix>
- <span v-if="policies.wordMuteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.wordMuteLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.wordMuteLimit)"></i></span>
+ <span v-if="role.policies.wordMuteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.wordMuteLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.wordMuteLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.wordMuteLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.wordMuteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.wordMuteLimit.value" :disabled="policies.wordMuteLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.wordMuteLimit.value" :disabled="role.policies.wordMuteLimit.useDefault" type="number" :readonly="readonly">
<template #suffix>chars</template>
</MkInput>
- <MkRange v-model="policies.wordMuteLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.wordMuteLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -263,17 +263,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.webhookMax, 'webhookLimit'])">
<template #label>{{ i18n.ts._role._options.webhookMax }}</template>
<template #suffix>
- <span v-if="policies.webhookLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.webhookLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.webhookLimit)"></i></span>
+ <span v-if="role.policies.webhookLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.webhookLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.webhookLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.webhookLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.webhookLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.webhookLimit.value" :disabled="policies.webhookLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.webhookLimit.value" :disabled="role.policies.webhookLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.webhookLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.webhookLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -282,17 +282,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.clipMax, 'clipLimit'])">
<template #label>{{ i18n.ts._role._options.clipMax }}</template>
<template #suffix>
- <span v-if="policies.clipLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.clipLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.clipLimit)"></i></span>
+ <span v-if="role.policies.clipLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.clipLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.clipLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.clipLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.clipLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.clipLimit.value" :disabled="policies.clipLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.clipLimit.value" :disabled="role.policies.clipLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.clipLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.clipLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -301,17 +301,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.noteEachClipsMax, 'noteEachClipsLimit'])">
<template #label>{{ i18n.ts._role._options.noteEachClipsMax }}</template>
<template #suffix>
- <span v-if="policies.noteEachClipsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.noteEachClipsLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.noteEachClipsLimit)"></i></span>
+ <span v-if="role.policies.noteEachClipsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.noteEachClipsLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.noteEachClipsLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.noteEachClipsLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.noteEachClipsLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.noteEachClipsLimit.value" :disabled="policies.noteEachClipsLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.noteEachClipsLimit.value" :disabled="role.policies.noteEachClipsLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.noteEachClipsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.noteEachClipsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -320,17 +320,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.userListMax, 'userListLimit'])">
<template #label>{{ i18n.ts._role._options.userListMax }}</template>
<template #suffix>
- <span v-if="policies.userListLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.userListLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.userListLimit)"></i></span>
+ <span v-if="role.policies.userListLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.userListLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.userListLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.userListLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.userListLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.userListLimit.value" :disabled="policies.userListLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.userListLimit.value" :disabled="role.policies.userListLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.userListLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.userListLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -339,17 +339,17 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.userEachUserListsMax, 'userEachUserListsLimit'])">
<template #label>{{ i18n.ts._role._options.userEachUserListsMax }}</template>
<template #suffix>
- <span v-if="policies.userEachUserListsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.userEachUserListsLimit.value }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.userEachUserListsLimit)"></i></span>
+ <span v-if="role.policies.userEachUserListsLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.userEachUserListsLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.userEachUserListsLimit)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.userEachUserListsLimit.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.userEachUserListsLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkInput v-model="policies.userEachUserListsLimit.value" :disabled="policies.userEachUserListsLimit.useDefault" type="number" :readonly="readonly">
+ <MkInput v-model="role.policies.userEachUserListsLimit.value" :disabled="role.policies.userEachUserListsLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="policies.userEachUserListsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.userEachUserListsLimit.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -358,105 +358,74 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.canHideAds, 'canHideAds'])">
<template #label>{{ i18n.ts._role._options.canHideAds }}</template>
<template #suffix>
- <span v-if="policies.canHideAds.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
- <span v-else>{{ policies.canHideAds.value ? i18n.ts.yes : i18n.ts.no }}</span>
- <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(policies.canHideAds)"></i></span>
+ <span v-if="role.policies.canHideAds.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.canHideAds.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canHideAds)"></i></span>
</template>
<div class="_gaps">
- <MkSwitch v-model="policies.canHideAds.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canHideAds.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkSwitch v-model="policies.canHideAds.value" :disabled="policies.canHideAds.useDefault" :readonly="readonly">
+ <MkSwitch v-model="role.policies.canHideAds.value" :disabled="role.policies.canHideAds.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <MkRange v-model="role.policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
-
- <div v-if="!readonly" class="_buttons">
- <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ role ? i18n.ts.save : i18n.ts.create }}</MkButton>
- </div>
</div>
</template>
<script lang="ts" setup>
-import { reactive, watch } from 'vue';
-import { v4 as uuid } from 'uuid';
+import { watch } from 'vue';
+import { throttle } from 'throttle-debounce';
import RolesEditorFormula from './RolesEditorFormula.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
-import MkButton from '@/components/MkButton.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
-import * as os from '@/os';
import { i18n } from '@/i18n';
+import { ROLE_POLICIES } from '@/const';
import { instance } from '@/instance';
-
-const ROLE_POLICIES = [
- 'gtlAvailable',
- 'ltlAvailable',
- 'canPublicNote',
- 'canInvite',
- 'canManageCustomEmojis',
- 'canHideAds',
- 'driveCapacityMb',
- 'pinLimit',
- 'antennaLimit',
- 'wordMuteLimit',
- 'webhookLimit',
- 'clipLimit',
- 'noteEachClipsLimit',
- 'userListLimit',
- 'userEachUserListsLimit',
- 'rateLimitFactor',
-] as const;
+import { deepClone } from '@/scripts/clone';
const emit = defineEmits<{
- (ev: 'created', payload: any): void;
- (ev: 'updated'): void;
+ (ev: 'update:modelValue', v: any): void;
}>();
const props = defineProps<{
- role?: any;
+ modelValue: any;
readonly?: boolean;
}>();
-const role = props.role;
-let q = $ref('');
-
-let name = $ref(role?.name ?? 'New Role');
-let description = $ref(role?.description ?? '');
-let rolePermission = $ref(role?.isAdministrator ? 'administrator' : role?.isModerator ? 'moderator' : 'normal');
-let color = $ref(role?.color ?? null);
-let iconUrl = $ref(role?.iconUrl ?? null);
-let target = $ref(role?.target ?? 'manual');
-let condFormula = $ref(role?.condFormula ?? { id: uuid(), type: 'isRemote' });
-let isPublic = $ref(role?.isPublic ?? false);
-let asBadge = $ref(role?.asBadge ?? false);
-let canEditMembersByModerator = $ref(role?.canEditMembersByModerator ?? false);
+let role = $ref(deepClone(props.modelValue));
-const policies = reactive<Record<typeof ROLE_POLICIES[number], { useDefault: boolean; priority: number; value: any; }>>({});
+// fill missing policy
for (const ROLE_POLICY of ROLE_POLICIES) {
- const _policies = role?.policies ?? {};
- policies[ROLE_POLICY] = {
- useDefault: _policies[ROLE_POLICY]?.useDefault ?? true,
- priority: _policies[ROLE_POLICY]?.priority ?? 0,
- value: _policies[ROLE_POLICY]?.value ?? instance.policies[ROLE_POLICY],
- };
+ if (role.policies[ROLE_POLICY] == null) {
+ role.policies[ROLE_POLICY] = {
+ useDefault: true,
+ priority: 0,
+ value: instance.policies[ROLE_POLICY],
+ };
+ }
}
-if (_DEV_) {
- watch($$(condFormula), () => {
- console.log(JSON.parse(JSON.stringify(condFormula)));
- }, { deep: true });
-}
+let rolePermission = $computed({
+ get: () => role.isAdministrator ? 'administrator' : role.isModerator ? 'moderator' : 'normal',
+ set: (val) => {
+ role.isAdministrator = val === 'administrator';
+ role.isModerator = val === 'moderator';
+ },
+});
+
+let q = $ref('');
function getPriorityIcon(option) {
if (option.priority === 2) return 'ti ti-arrows-up';
@@ -469,43 +438,26 @@ function matchQuery(keywords: string[]): boolean {
return keywords.some(keyword => keyword.toLowerCase().includes(q.toLowerCase()));
}
-async function save() {
- if (props.readonly) return;
- if (role) {
- os.apiWithDialog('admin/roles/update', {
- roleId: role.id,
- name,
- description,
- color: color === '' ? null : color,
- iconUrl: iconUrl === '' ? null : iconUrl,
- target,
- condFormula,
- isAdministrator: rolePermission === 'administrator',
- isModerator: rolePermission === 'moderator',
- isPublic,
- asBadge,
- canEditMembersByModerator,
- policies,
- });
- emit('updated');
- } else {
- const created = await os.apiWithDialog('admin/roles/create', {
- name,
- description,
- color: color === '' ? null : color,
- iconUrl: iconUrl === '' ? null : iconUrl,
- target,
- condFormula,
- isAdministrator: rolePermission === 'administrator',
- isModerator: rolePermission === 'moderator',
- isPublic,
- asBadge,
- canEditMembersByModerator,
- policies,
- });
- emit('created', created);
- }
-}
+const save = throttle(100, () => {
+ const data = {
+ name: role.name,
+ description: role.description,
+ color: role.color === '' ? null : role.color,
+ iconUrl: role.iconUrl === '' ? null : role.iconUrl,
+ target: role.target,
+ condFormula: role.condFormula,
+ isAdministrator: role.isAdministrator,
+ isModerator: role.isModerator,
+ isPublic: role.isPublic,
+ asBadge: role.asBadge,
+ canEditMembersByModerator: role.canEditMembersByModerator,
+ policies: role.policies,
+ };
+
+ emit('update:modelValue', data);
+});
+
+watch($$(role), save, { deep: true });
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index e7d57ad4f0..6eac902577 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -11,7 +11,7 @@
<MkFolder>
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
- <XEditor :role="role" readonly/>
+ <XEditor :model-value="role" readonly/>
</MkFolder>
<MkFolder v-if="role.target === 'manual'" default-open>
<template #icon><i class="ti ti-users"></i></template>
@@ -30,11 +30,19 @@
<template #default="{ items }">
<div class="_gaps_s">
- <div v-for="item in items" :key="item.user.id" :class="$style.userItem">
- <MkA :class="$style.user" :to="`/user-info/${item.user.id}`">
- <MkUserCardMini :user="item.user"/>
- </MkA>
- <button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button>
+ <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedItems.includes(item.id) }]">
+ <div :class="$style.userItemMain">
+ <MkA :class="$style.userItemMainBody" :to="`/user-info/${item.user.id}`">
+ <MkUserCardMini :user="item.user"/>
+ </MkA>
+ <button class="_button" :class="$style.userToggle" @click="toggleItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button>
+ <button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button>
+ </div>
+ <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub">
+ <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div>
+ <div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div>
+ <div v-else>Period: {{ i18n.ts.indefinitely }}</div>
+ </div>
</div>
</div>
</template>
@@ -76,6 +84,8 @@ const usersPagination = {
})),
};
+let expandedItems = $ref([]);
+
const role = reactive(await os.api('admin/roles/show', {
roleId: props.id,
}));
@@ -98,13 +108,37 @@ async function del() {
router.push('/admin/roles');
}
-function assign() {
- os.selectUser({
+async function assign() {
+ const user = await os.selectUser({
includeSelf: true,
- }).then(async (user) => {
- await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id });
- role.users.push(user);
});
+
+ const { canceled: canceled2, result: period } = await os.select({
+ title: i18n.ts.period,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }, {
+ value: 'oneMonth', text: i18n.ts.oneMonth,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled2) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+ : null;
+
+ await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id, expiresAt });
+ //role.users.push(user);
}
async function unassign(user, ev) {
@@ -114,11 +148,19 @@ async function unassign(user, ev) {
danger: true,
action: async () => {
await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id });
- role.users = role.users.filter(u => u.id !== user.id);
+ //role.users = role.users.filter(u => u.id !== user.id);
},
}], ev.currentTarget ?? ev.target);
}
+async function toggleItem(item) {
+ if (expandedItems.includes(item.id)) {
+ expandedItems = expandedItems.filter(x => x !== item.id);
+ } else {
+ expandedItems.push(item.id);
+ }
+}
+
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
@@ -130,19 +172,41 @@ definePageMetadata(computed(() => ({
</script>
<style lang="scss" module>
-.userItem {
+.userItemMain {
display: flex;
}
-.user {
+.userItemSub {
+ padding: 6px 12px;
+ font-size: 85%;
+ color: var(--fgTransparentWeak);
+}
+
+.userItemMainBody {
flex: 1;
min-width: 0;
+ margin-right: 8px;
+
+ &:hover {
+ text-decoration: none;
+ }
}
+.userToggle,
.unassign {
width: 32px;
height: 32px;
- margin-left: 8px;
align-self: center;
}
+
+.chevron {
+ display: block;
+ transition: transform 0.1s ease-out;
+}
+
+.userItem.userItemOpend {
+ .chevron {
+ transform: rotateX(180deg);
+ }
+}
</style>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index d89f0d2a7d..25d8f3ad6e 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -4,7 +4,6 @@
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div class="_gaps">
- <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template>
<div class="_gaps_s">
@@ -132,8 +131,20 @@
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
+ <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<div class="_gaps_s">
- <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/>
+ <MkFoldableSection>
+ <template #header>Manual roles</template>
+ <div class="_gaps_s">
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/>
+ </div>
+ </MkFoldableSection>
+ <MkFoldableSection>
+ <template #header>Conditional roles</template>
+ <div class="_gaps_s">
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/>
+ </div>
+ </MkFoldableSection>
</div>
</div>
</MkSpacer>
@@ -155,6 +166,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { instance } from '@/instance';
import { useRouter } from '@/router';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
const ROLE_POLICIES = [
'gtlAvailable',
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 7840c55ee4..12f341c01d 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -1,7 +1,7 @@
<template>
<div>
<MkStickyContainer>
- <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
@@ -133,6 +133,13 @@
</div>
</FormSuspense>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</div>
</template>
@@ -150,6 +157,7 @@ import * as os from '@/os';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import MkButton from '@/components/MkButton.vue';
let name: string | null = $ref(null);
let description: string | null = $ref(null);
@@ -223,13 +231,6 @@ function save() {
});
}
-const headerActions = $computed(() => [{
- asFullButton: true,
- icon: 'ti ti-check',
- text: i18n.ts.save,
- handler: save,
-}]);
-
const headerTabs = $computed(() => []);
definePageMetadata({
@@ -237,3 +238,10 @@ definePageMetadata({
icon: 'ti ti-settings',
});
</script>
+
+<style lang="scss" module>
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index fc1c1c1dc5..819ced826d 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -3,48 +3,48 @@
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="900">
- <div class="lknzcolw">
- <div class="users">
- <div class="inputs">
- <MkSelect v-model="sort" style="flex: 1;">
- <template #label>{{ i18n.ts.sort }}</template>
- <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
- <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
- <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
- <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
- </MkSelect>
- <MkSelect v-model="state" style="flex: 1;">
- <template #label>{{ i18n.ts.state }}</template>
- <option value="all">{{ i18n.ts.all }}</option>
- <option value="available">{{ i18n.ts.normal }}</option>
- <option value="admin">{{ i18n.ts.administrator }}</option>
- <option value="moderator">{{ i18n.ts.moderator }}</option>
- <option value="suspended">{{ i18n.ts.suspend }}</option>
- </MkSelect>
- <MkSelect v-model="origin" style="flex: 1;">
- <template #label>{{ i18n.ts.instance }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- </div>
- <div class="inputs">
- <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
- <template #prefix>@</template>
- <template #label>{{ i18n.ts.username }}</template>
- </MkInput>
- <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
- <template #prefix>@</template>
- <template #label>{{ i18n.ts.host }}</template>
- </MkInput>
- </div>
+ <div class="_gaps">
+ <div :class="$style.inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ i18n.ts.sort }}</template>
+ <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="available">{{ i18n.ts.normal }}</option>
+ <option value="admin">{{ i18n.ts.administrator }}</option>
+ <option value="moderator">{{ i18n.ts.moderator }}</option>
+ <option value="suspended">{{ i18n.ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ i18n.ts.instance }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <div :class="$style.inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ i18n.ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ i18n.ts.host }}</template>
+ </MkInput>
+ </div>
- <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
- <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/user-info/${user.id}`">
+ <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination">
+ <div :class="$style.users">
+ <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/user-info/${user.id}`">
<MkUserCardMini :user="user"/>
</MkA>
- </MkPagination>
- </div>
+ </div>
+ </MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -138,33 +138,20 @@ definePageMetadata(computed(() => ({
})));
</script>
-<style lang="scss" scoped>
-.lknzcolw {
- > .users {
-
- > .inputs {
- display: flex;
- margin-bottom: 16px;
-
- > * {
- margin-right: 16px;
+<style lang="scss" module>
+.inputs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
- &:last-child {
- margin-right: 0;
- }
- }
- }
-
- > .users {
- margin-top: var(--margin);
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
- grid-gap: 12px;
+.users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
- > .user:hover {
- text-decoration: none;
- }
- }
+ > .user:hover {
+ text-decoration: none;
}
}
</style>
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 6b4fcb32f8..65edb97e83 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -1,30 +1,25 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div v-if="channel && tab === 'timeline'" class="_gaps">
- <div class="wpgynlbz _panel" :class="{ hide: !showBanner }">
- <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
- <button class="_button toggle" @click="() => showBanner = !showBanner">
- <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
- <template v-else><i class="ti ti-chevron-down"></i></template>
- </button>
- <div v-if="!showBanner" class="hideOverlay">
- </div>
- <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
- <div class="status">
+ <MkSpacer :content-max="700" :class="$style.main">
+ <div v-if="channel && tab === 'overview'" class="_gaps">
+ <div class="_panel" :class="$style.bannerContainer">
+ <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
+ <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner">
+ <div :class="$style.bannerStatus">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div>
- <div class="fade"></div>
+ <div :class="$style.bannerFade"></div>
</div>
- <div v-if="channel.description" class="description">
+ <div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :is-note="false" :i="$i"/>
</div>
</div>
-
+ </div>
+ <div v-if="channel && tab === 'timeline'" class="_gaps">
<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
- <MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
+ <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
</div>
@@ -32,6 +27,15 @@
<MkNotes :pagination="featuredPagination"/>
</div>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <div class="_buttonsCenter">
+ <MkButton inline rounded primary gradate @click="openPostForm()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
@@ -47,6 +51,9 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
+import { url } from '@/config';
+import MkButton from '@/components/MkButton.vue';
+import { defaultStore } from '@/store';
const router = useRouter();
@@ -56,7 +63,6 @@ const props = defineProps<{
let tab = $ref('timeline');
let channel = $ref(null);
-let showBanner = $ref(true);
const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
@@ -76,13 +82,35 @@ function edit() {
router.push(`/channels/${channel.id}/edit`);
}
+function openPostForm() {
+ os.post({
+ channel: {
+ id: channel.id,
+ },
+ });
+}
+
const headerActions = $computed(() => channel && channel.userId ? [{
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise<void> => {
+ navigator.share({
+ title: channel.name,
+ text: channel.description,
+ url: `${url}/channels/${channel.id}`,
+ });
+ },
+}, {
icon: 'ti ti-settings',
text: i18n.ts.edit,
handler: edit,
}] : null);
const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ti ti-info-circle',
+}, {
key: 'timeline',
title: i18n.ts.timeline,
icon: 'ti ti-home',
@@ -98,102 +126,57 @@ definePageMetadata(computed(() => channel ? {
} : null));
</script>
-<style lang="scss" scoped>
-.wpgynlbz {
- position: relative;
-
- > .subscribe {
- position: absolute;
- z-index: 1;
- top: 16px;
- left: 16px;
- }
-
- > .toggle {
- position: absolute;
- z-index: 2;
- top: 8px;
- right: 8px;
- font-size: 1.2em;
- width: 48px;
- height: 48px;
- color: #fff;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 100%;
-
- > i {
- vertical-align: middle;
- }
- }
-
- > .banner {
- position: relative;
- height: 200px;
- background-position: center;
- background-size: cover;
-
- > .fade {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 64px;
- background: linear-gradient(0deg, var(--panel), var(--X15));
- }
-
- > .status {
- position: absolute;
- z-index: 1;
- bottom: 16px;
- right: 16px;
- padding: 8px 12px;
- font-size: 80%;
- background: rgba(0, 0, 0, 0.7);
- border-radius: 6px;
- color: #fff;
- }
- }
+<style lang="scss" module>
+.main {
+ min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+}
- > .description {
- padding: 16px;
- }
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-top: solid 0.5px var(--divider);
+}
- > .hideOverlay {
- position: absolute;
- z-index: 1;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- -webkit-backdrop-filter: var(--blur, blur(16px));
- backdrop-filter: var(--blur, blur(16px));
- background: rgba(0, 0, 0, 0.3);
- }
+.bannerContainer {
+ position: relative;
+}
- &.hide {
- > .subscribe {
- display: none;
- }
+.subscribe {
+ position: absolute;
+ z-index: 1;
+ top: 16px;
+ left: 16px;
+}
- > .toggle {
- top: 0;
- right: 0;
- height: 100%;
- background: transparent;
- }
+.banner {
+ position: relative;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+}
- > .banner {
- height: 42px;
- filter: blur(8px);
+.bannerFade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+}
- > * {
- display: none;
- }
- }
+.bannerStatus {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+}
- > .description {
- display: none;
- }
- }
+.description {
+ padding: 16px;
}
</style>
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index d4e8f27005..d66088d33a 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -26,6 +26,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { url } from '@/config';
const props = defineProps<{
clipId: string,
@@ -82,7 +83,17 @@ const headerActions = $computed(() => clip && isOwned ? [{
...result,
});
},
-}, {
+}, ...(clip.isPublic ? [{
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise<void> => {
+ navigator.share({
+ title: clip.name,
+ text: clip.description,
+ url: `${url}/clips/${clip.id}`,
+ });
+ },
+}] : []), {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
index 8be11008c2..51177d079c 100644
--- a/packages/frontend/src/pages/explore.roles.vue
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -16,7 +16,7 @@ let roles = $ref();
os.api('roles/list', {
limit: 30,
}).then(res => {
- roles = res;
+ roles = res.filter(x => x.target === 'manual');
});
</script>
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 3528e7e145..76201aa85f 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -27,11 +27,11 @@
</div>
</div>
</Transition>
- <MkFolder class="_margin">
+ <MkFolder :default-open="false" :max-height="280" class="_margin">
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._play.viewSource }}</template>
- <MkTextarea :model-value="flash.script" readonly tall class="_monospace" spellcheck="false"></MkTextarea>
+ <MkCode :code="flash.script" :inline="false" class="_monospace"/>
</MkFolder>
<div :class="$style.footer">
<Mfm :text="`By @${flash.user.username}`"/>
@@ -62,7 +62,7 @@ import MkAsUi from '@/components/MkAsUi.vue';
import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
import MkFolder from '@/components/MkFolder.vue';
-import MkTextarea from '@/components/MkTextarea.vue';
+import MkCode from '@/components/MkCode.vue';
const props = defineProps<{
id: string;
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index f47b4bf90f..205434971d 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -1,39 +1,30 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div class="mk-list-page">
- <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
- <div v-if="list" class="">
- <div class="">
- <MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
- <MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton>
- <MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
- </div>
+ <MkSpacer :content-max="700" :class="$style.main">
+ <div v-if="list" class="members _margin">
+ <div class="">{{ i18n.ts.members }}</div>
+ <div class="_gaps_s">
+ <div v-for="user in users" :key="user.id" :class="$style.userItem">
+ <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
</div>
- </Transition>
-
- <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in">
- <div v-if="list" class="members _margin">
- <div class="">{{ i18n.ts.members }}</div>
- <div class="">
- <div class="users">
- <div v-for="user in users" :key="user.id" class="user _panel">
- <MkAvatar :user="user" class="avatar" indicator link preview/>
- <div class="body">
- <MkUserName :user="user" class="name"/>
- <MkAcct :user="user" class="acct"/>
- </div>
- <div class="action">
- <button class="_button" @click="removeUser(user)"><i class="ti ti-x"></i></button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </Transition>
+ </div>
</div>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <div class="_buttons">
+ <MkButton inline rounded primary @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
+ <MkButton inline rounded @click="renameList()">{{ i18n.ts.rename }}</MkButton>
+ <MkButton inline rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
@@ -44,6 +35,8 @@ import * as os from '@/os';
import { mainRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
+import { userPage } from '@/filters/user';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
const props = defineProps<{
listId: string;
@@ -76,13 +69,20 @@ function addUser() {
});
}
-function removeUser(user) {
- os.api('users/lists/pull', {
- listId: list.id,
- userId: user.id,
- }).then(() => {
- users = users.filter(x => x.id !== user.id);
- });
+async function removeUser(user, ev) {
+ os.popupMenu([{
+ text: i18n.ts.remove,
+ icon: 'ti ti-x',
+ danger: true,
+ action: async () => {
+ os.api('users/lists/pull', {
+ listId: list.id,
+ userId: user.id,
+ }).then(() => {
+ users = users.filter(x => x.id !== user.id);
+ });
+ },
+ }], ev.currentTarget ?? ev.target);
}
async function renameList() {
@@ -126,37 +126,34 @@ definePageMetadata(computed(() => list ? {
} : null));
</script>
-<style lang="scss" scoped>
-.mk-list-page {
- > .members {
- > ._content {
- > .users {
- > .user {
- display: flex;
- align-items: center;
- padding: 16px;
-
- > .avatar {
- width: 50px;
- height: 50px;
- }
+<style lang="scss" module>
+.main {
+ min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+}
- > .body {
- flex: 1;
- padding: 8px;
+.userItem {
+ display: flex;
+}
- > .name {
- display: block;
- font-weight: bold;
- }
+.userItemBody {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
- > .acct {
- opacity: 0.5;
- }
- }
- }
- }
- }
+ &:hover {
+ text-decoration: none;
}
}
+
+.remove {
+ width: 32px;
+ height: 32px;
+ align-self: center;
+}
+
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-top: solid 0.5px var(--divider);
+}
</style>
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index d32bdcd78e..7e81cd2c0d 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -37,7 +37,6 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import * as os from '@/os';
import { useRouter, mainRouter } from '@/router';
-import { $i } from '@/account';
const router = useRouter();
@@ -116,24 +115,6 @@ const search = async () => {
return;
}
- if ($i != null) {
- if (query.startsWith('https://') || (query.startsWith('@') && !query.includes(' '))) {
- const promise = os.api('ap/show', {
- uri: query,
- });
-
- os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
-
- const res = await promise;
-
- if (res.type === 'User') {
- router.replace(`/@${res.object.username}@${res.object.host}`);
- } else if (res.type === 'Note') {
- router.replace(`/notes/${res.object.id}`);
- }
- }
- }
-
window.history.replaceState('', '', `/search?q=${encodeURIComponent(query)}&type=${searchType}${searchType === 'user' ? `&origin=${searchOrigin}` : ''}`);
};
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index 1734dcfe42..b1e6f223b6 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -1,5 +1,5 @@
<template>
-<div class="_gaps_m">
+<div v-if="instance.enableEmail" class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts.emailAddress }}</template>
<MkInput v-model="emailAddress" type="email" manual-save>
@@ -37,17 +37,22 @@
</div>
</FormSection>
</div>
+<div v-if="!instance.enableEmail" class="_gaps_m">
+ <MkInfo>{{ i18n.ts.emailNotSupported }}</MkInfo>
+</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
+import MkInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { instance } from '@/instance';
const emailAddress = ref($i!.email);
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 1b492b15cf..2e2c456c07 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -21,6 +21,7 @@
</MkRadios>
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
+ <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<FormSection>
<template #label>{{ i18n.ts.behavior }}</template>
@@ -45,6 +46,7 @@
<div class="_gaps_m">
<div class="_gaps_s">
+ <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
<MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
@@ -140,6 +142,7 @@ async function reloadAsk() {
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
+const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover'));
const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
@@ -154,6 +157,7 @@ const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
+const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
@@ -189,6 +193,7 @@ watch([
enableInfiniteScroll,
squareAvatars,
aiChanMode,
+ showNoteActionsOnlyHover,
showGapBetweenNotesInTimeline,
instanceTicker,
overridedDeviceKind,
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 0512a8d0c9..28e39236f7 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -59,6 +59,8 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'tl',
'overridedDeviceKind',
'serverDisconnectedBehavior',
+ 'collapseRenotes',
+ 'showNoteActionsOnlyHover',
'nsfw',
'animation',
'animatedMfm',
@@ -71,6 +73,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useBlurEffectForModal',
'useBlurEffect',
'showFixedPostForm',
+ 'showFixedPostFormInChannel',
'enableInfiniteScroll',
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
@@ -420,7 +423,6 @@ onUnmounted(() => {
definePageMetadata(computed(() => ({
title: ts.preferencesBackups,
icon: 'ti ti-device-floppy',
- bg: 'var(--bg)',
})));
</script>
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
index cb46858c5a..f5a090a63b 100644
--- a/packages/frontend/src/pages/settings/statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -47,6 +47,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.statusbar,
icon: 'ti ti-list',
- bg: 'var(--bg)',
});
</script>
diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue
index 13a06286f6..373af193d7 100644
--- a/packages/frontend/src/pages/user-info.vue
+++ b/packages/frontend/src/pages/user-info.vue
@@ -337,7 +337,31 @@ async function assignRole() {
});
if (canceled) return;
- await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id });
+ const { canceled: canceled2, result: period } = await os.select({
+ title: i18n.ts.period,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }, {
+ value: 'oneMonth', text: i18n.ts.oneMonth,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled2) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+ : null;
+
+ await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id, expiresAt });
refreshUser();
}
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 441b19440c..02794175ae 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -352,6 +352,9 @@ onUnmounted(() => {
> .roles {
padding: 24px 24px 0 154px;
font-size: 0.95em;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
> .role {
border: solid 1px var(--color, var(--divider));
@@ -493,7 +496,7 @@ onUnmounted(() => {
> .roles {
padding: 16px 16px 0 16px;
- text-align: center;
+ justify-content: center;
}
> .description {
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index f62a6461c5..b6f9b3eb23 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -26,6 +26,9 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
</div>
+ <div v-if="instance.disableRegistration" class="warn">
+ <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ </div>
<div class="action _gaps_s">
<MkButton full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
<MkButton full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
@@ -62,6 +65,7 @@ import XSignupDialog from '@/components/MkSignupDialog.vue';
import MkButton from '@/components/MkButton.vue';
import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
import MkTimeline from '@/components/MkTimeline.vue';
+import MkInfo from '@/components/MkInfo.vue';
import { instanceName } from '@/config';
import * as os from '@/os';
import { i18n } from '@/i18n';
@@ -249,6 +253,10 @@ function exploreOtherServers() {
padding: 0 32px;
}
+ > .warn {
+ padding: 32px 32px 0 32px;
+ }
+
> .action {
padding: 32px;
diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts
index 17eb99be22..a1a36480fd 100644
--- a/packages/frontend/src/plugin.ts
+++ b/packages/frontend/src/plugin.ts
@@ -1,12 +1,12 @@
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
import { inputText } from '@/os';
-import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store';
+import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store';
const parser = new Parser();
const pluginContexts = new Map<string, Interpreter>();
-export function install(plugin) {
+export function install(plugin: Plugin): void {
// 後方互換性のため
if (plugin.src == null) return;
console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
@@ -15,7 +15,7 @@ export function install(plugin) {
plugin: plugin,
storageKey: 'plugins:' + plugin.id,
}), {
- in: (q) => {
+ in: (q): Promise<string> => {
return new Promise(ok => {
inputText({
title: q,
@@ -28,10 +28,10 @@ export function install(plugin) {
});
});
},
- out: (value) => {
+ out: (value): void => {
console.log(value);
},
- log: (type, params) => {
+ log: (): void => {
},
});
@@ -40,9 +40,9 @@ export function install(plugin) {
aiscript.exec(parser.parse(plugin.src));
}
-function createPluginEnv(opts) {
- const config = new Map();
- for (const [k, v] of Object.entries(opts.plugin.config || {})) {
+function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> {
+ const config = new Map<string, values.Value>();
+ for (const [k, v] of Object.entries(opts.plugin.config ?? {})) {
config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default));
}
@@ -50,22 +50,28 @@ function createPluginEnv(opts) {
...createAiScriptEnv({ ...opts, token: opts.plugin.token }),
//#region Deprecated
'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
//#endregion
'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => {
+ utils.assertString(title);
registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler });
}),
'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => {
@@ -75,54 +81,78 @@ function createPluginEnv(opts) {
registerNotePostInterruptor({ pluginId: opts.plugin.id, handler });
}),
'Plugin:open_url': values.FN_NATIVE(([url]) => {
+ utils.assertString(url);
window.open(url.value, '_blank');
}),
'Plugin:config': values.OBJ(config),
};
}
-function initPlugin({ plugin, aiscript }) {
+function initPlugin({ plugin, aiscript }): void {
pluginContexts.set(plugin.id, aiscript);
}
-function registerPostFormAction({ pluginId, title, handler }) {
+function registerPostFormAction({ pluginId, title, handler }): void {
postFormActions.push({
title, handler: (form, update) => {
- pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
- update(key.value, value.value);
+ const pluginContext = pluginContexts.get(pluginId);
+ if (!pluginContext) {
+ return;
+ }
+ pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
+ if (!key || !value) {
+ return;
+ }
+ update(utils.valToJs(key), utils.valToJs(value));
})]);
},
});
}
-function registerUserAction({ pluginId, title, handler }) {
+function registerUserAction({ pluginId, title, handler }): void {
userActions.push({
title, handler: (user) => {
- pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]);
+ const pluginContext = pluginContexts.get(pluginId);
+ if (!pluginContext) {
+ return;
+ }
+ pluginContext.execFn(handler, [utils.jsToVal(user)]);
},
});
}
-function registerNoteAction({ pluginId, title, handler }) {
+function registerNoteAction({ pluginId, title, handler }): void {
noteActions.push({
title, handler: (note) => {
- pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]);
+ const pluginContext = pluginContexts.get(pluginId);
+ if (!pluginContext) {
+ return;
+ }
+ pluginContext.execFn(handler, [utils.jsToVal(note)]);
},
});
}
-function registerNoteViewInterruptor({ pluginId, handler }) {
+function registerNoteViewInterruptor({ pluginId, handler }): void {
noteViewInterruptors.push({
handler: async (note) => {
- return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
+ const pluginContext = pluginContexts.get(pluginId);
+ if (!pluginContext) {
+ return;
+ }
+ return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)]));
},
});
}
-function registerNotePostInterruptor({ pluginId, handler }) {
+function registerNotePostInterruptor({ pluginId, handler }): void {
notePostInterruptors.push({
handler: async (note) => {
- return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]));
+ const pluginContext = pluginContexts.get(pluginId);
+ if (!pluginContext) {
+ return;
+ }
+ return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)]));
},
});
}
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 69d0ed085d..5170ca4c8c 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -12,29 +12,6 @@ import { Router } from '@/nirax';
export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null;
- async function pushList() {
- const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
- const lists = await os.api('users/lists/list');
- if (lists.length === 0) {
- os.alert({
- type: 'error',
- text: i18n.ts.youHaveNoLists,
- });
- return;
- }
- const { canceled, result: listId } = await os.select({
- title: t,
- items: lists.map(list => ({
- value: list.id, text: list.name,
- })),
- });
- if (canceled) return;
- os.apiWithDialog('users/lists/push', {
- listId: listId,
- userId: user.id,
- });
- }
-
async function toggleMute() {
if (user.isMuted) {
os.apiWithDialog('mute/delete', {
@@ -137,12 +114,67 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
os.post({ specified: user });
},
}, null, {
+ type: 'parent',
icon: 'ti ti-list',
text: i18n.ts.addToList,
- action: pushList,
+ children: async () => {
+ const lists = await os.api('users/lists/list');
+
+ return lists.map(list => ({
+ text: list.name,
+ action: () => {
+ os.apiWithDialog('users/lists/push', {
+ listId: list.id,
+ userId: user.id,
+ });
+ },
+ }));
+ },
}] as any;
if ($i && meId !== user.id) {
+ if (iAmModerator) {
+ menu = menu.concat([{
+ type: 'parent',
+ icon: 'ti ti-badges',
+ text: i18n.ts.roles,
+ children: async () => {
+ const roles = await os.api('admin/roles/list');
+
+ return roles.filter(r => r.target === 'manual').map(r => ({
+ text: r.name,
+ action: async () => {
+ const { canceled, result: period } = await os.select({
+ title: i18n.ts.period,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }, {
+ value: 'oneMonth', text: i18n.ts.oneMonth,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+ : null;
+
+ os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
+ },
+ }));
+ },
+ }]);
+ }
+
menu = menu.concat([null, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
@@ -166,24 +198,6 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
text: i18n.ts.reportAbuse,
action: reportAbuse,
}]);
-
- if (iAmModerator) {
- menu = menu.concat([null, {
- icon: 'ti ti-badges',
- text: i18n.ts.roles,
- action: async () => {
- const roles = await os.api('admin/roles/list');
-
- const { canceled, result: roleId } = await os.select({
- title: i18n.ts._role.chooseRoleToAssign,
- items: roles.map(r => ({ text: r.name, value: r.id })),
- });
- if (canceled) return;
-
- await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id });
- },
- }]);
- }
}
if ($i && meId === user.id) {
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
index 4a0ded637d..b7238016c6 100644
--- a/packages/frontend/src/scripts/hotkey.ts
+++ b/packages/frontend/src/scripts/hotkey.ts
@@ -53,10 +53,10 @@ const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, c
return result;
});
-const ignoreElemens = ['input', 'textarea'];
+const ignoreElements = ['input', 'textarea'];
function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
- const key = ev.code.toLowerCase();
+ const key = ev.key.toLowerCase();
return patterns.some(pattern => pattern.which.includes(key) &&
pattern.ctrl === ev.ctrlKey &&
pattern.shift === ev.shiftKey &&
@@ -70,7 +70,7 @@ export const makeHotkey = (keymap: Keymap) => {
return (ev: KeyboardEvent) => {
if (document.activeElement) {
- if (ignoreElemens.some(el => document.activeElement!.matches(el))) return;
+ if (ignoreElements.some(el => document.activeElement!.matches(el))) return;
if (document.activeElement.attributes['contenteditable']) return;
}
diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts
index 69f6a82803..35813edbd5 100644
--- a/packages/frontend/src/scripts/keycode.ts
+++ b/packages/frontend/src/scripts/keycode.ts
@@ -16,18 +16,3 @@ export const aliases = {
'right': 'ArrowRight',
'plus': ['NumpadAdd', 'Semicolon'],
};
-
-/*!
-* Programmatically add the following
-*/
-
-// lower case chars
-for (let i = 97; i < 123; i++) {
- const char = String.fromCharCode(i);
- aliases[char] = `Key${char.toUpperCase()}`;
-}
-
-// numbers
-for (let i = 0; i < 10; i++) {
- aliases[i] = [`Numpad${i}`, `Digit${i}`];
-}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
index 274e96e0a1..2fe5bdcf8f 100644
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ b/packages/frontend/src/scripts/media-proxy.ts
@@ -1,20 +1,20 @@
-import { query, appendQuery } from '@/scripts/url';
+import { query } from '@/scripts/url';
import { url } from '@/config';
import { instance } from '@/instance';
-export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
- if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/')) {
- // もう既にproxyっぽそうだったらsearchParams付けるだけ
- return appendQuery(imageUrl, query({
- fallback: '1',
- ...(type ? { [type]: '1' } : {}),
- }));
+export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigin: boolean = false): string {
+ const localProxy = `${url}/proxy`;
+
+ if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
+ // もう既にproxyっぽそうだったらurlを取り出す
+ imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
}
- return `${instance.mediaProxy}/image.webp?${query({
+ return `${mustOrigin ? localProxy : instance.mediaProxy}/image.webp?${query({
url: imageUrl,
fallback: '1',
...(type ? { [type]: '1' } : {}),
+ ...(mustOrigin ? { origin: '1' } : {}),
})}`;
}
diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts
index 0db8369f9d..8810e26960 100644
--- a/packages/frontend/src/scripts/page-metadata.ts
+++ b/packages/frontend/src/scripts/page-metadata.ts
@@ -10,7 +10,6 @@ export type PageMetadata = {
icon?: string | null;
avatar?: misskey.entities.User | null;
userName?: misskey.entities.User | null;
- bg?: string;
};
export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index 9d1f603235..b08982facb 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -4,6 +4,27 @@ const cache = new Map<string, HTMLAudioElement>();
export const soundsTypes = [
null,
+ 'syuilo/n-aec',
+ 'syuilo/n-aec-4va',
+ 'syuilo/n-aec-4vb',
+ 'syuilo/n-aec-8va',
+ 'syuilo/n-aec-8vb',
+ 'syuilo/n-cea',
+ 'syuilo/n-cea-4va',
+ 'syuilo/n-cea-4vb',
+ 'syuilo/n-cea-8va',
+ 'syuilo/n-cea-8vb',
+ 'syuilo/n-eca',
+ 'syuilo/n-eca-4va',
+ 'syuilo/n-eca-4vb',
+ 'syuilo/n-eca-8va',
+ 'syuilo/n-eca-8vb',
+ 'syuilo/n-ea',
+ 'syuilo/n-ea-4va',
+ 'syuilo/n-ea-4vb',
+ 'syuilo/n-ea-8va',
+ 'syuilo/n-ea-8vb',
+ 'syuilo/n-ea-harmony',
'syuilo/up',
'syuilo/down',
'syuilo/pope1',
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 54c159ed6b..2766b434fc 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -197,6 +197,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ showFixedPostFormInChannel: {
+ where: 'device',
+ default: false,
+ },
enableInfiniteScroll: {
where: 'device',
default: true,
@@ -271,7 +275,11 @@ export const defaultStore = markRaw(new Storage('base', {
},
numberOfPageCache: {
where: 'device',
- default: 5,
+ default: 3,
+ },
+ showNoteActionsOnlyHover: {
+ where: 'device',
+ default: false,
},
aiChanMode: {
where: 'device',
@@ -283,12 +291,15 @@ export const defaultStore = markRaw(new Storage('base', {
const PREFIX = 'miux:' as const;
-type Plugin = {
+export type Plugin = {
id: string;
name: string;
active: boolean;
+ config?: Record<string, { default: any }>;
configData: Record<string, any>;
token: string;
+ src: string | null;
+ version: string;
ast: any[];
};
@@ -312,14 +323,14 @@ export class ColdDeviceStorage {
syncDeviceDarkMode: true,
plugins: [] as Plugin[],
mediaVolume: 0.5,
- sound_masterVolume: 0.3,
- sound_note: { type: 'syuilo/down', volume: 1 },
- sound_noteMy: { type: 'syuilo/up', volume: 1 },
- sound_notification: { type: 'syuilo/pope2', volume: 1 },
- sound_chat: { type: 'syuilo/pope1', volume: 1 },
- sound_chatBg: { type: 'syuilo/waon', volume: 1 },
- sound_antenna: { type: 'syuilo/triple', volume: 1 },
- sound_channel: { type: 'syuilo/square-pico', volume: 1 },
+ sound_masterVolume: 0.5,
+ sound_note: { type: 'syuilo/n-aec', volume: 0.5 },
+ sound_noteMy: { type: 'syuilo/n-cea', volume: 0.5 },
+ sound_notification: { type: 'syuilo/n-ea', volume: 0.5 },
+ sound_chat: { type: 'syuilo/pope1', volume: 0.5 },
+ sound_chatBg: { type: 'syuilo/waon', volume: 0.5 },
+ sound_antenna: { type: 'syuilo/triple', volume: 0.5 },
+ sound_channel: { type: 'syuilo/square-pico', volume: 0.5 },
};
public static watchers: Watcher[] = [];
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 5a465d7873..3634e02745 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -285,6 +285,12 @@ hr {
flex-wrap: wrap;
}
+._buttonsCenter {
+ @extend ._buttons;
+
+ justify-content: center;
+}
+
._borderButton {
@extend ._button;
display: block;
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 4c6b41e42e..b81d6729e6 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -61,7 +61,6 @@ function post() {
channel: {
id: props.column.channelId,
},
- instant: true,
});
}
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index 38ee37de27..e895847bd9 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -22,7 +22,7 @@
<span :class="$style.title"><slot name="header"></slot></span>
<button v-tooltip="i18n.ts.settings" :class="$style.menu" class="_button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button>
</header>
- <div v-show="active" ref="body" :class="$style.body">
+ <div v-show="active" ref="body" v-container :class="$style.body">
<slot></slot>
</div>
</section>
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index beae799f5c..11d1c85e38 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -2,7 +2,7 @@
<div :class="[$style.root, { [$style.withWallpaper]: wallpaper }]">
<XSidebar v-if="!isMobile" :class="$style.sidebar"/>
- <MkStickyContainer :class="$style.contents">
+ <MkStickyContainer v-container :class="$style.contents">
<template #header><XStatusBars :class="$style.statusbars"/></template>
<main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
<div :class="$style.content" style="container-type: inline-size;">
@@ -18,7 +18,7 @@
<button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
- <div v-if="isMobile" :class="$style.nav">
+ <div v-if="isMobile" ref="navFooter" :class="$style.nav">
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
<button :class="$style.navButton" class="_button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button>
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"><i :class="$style.navButtonIcon" class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
@@ -84,7 +84,7 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, inject, Ref } from 'vue';
import XCommon from './_common_/common.vue';
import { instanceName } from '@/config';
import { StickySidebar } from '@/scripts/sticky-sidebar';
@@ -98,6 +98,7 @@ import { mainRouter } from '@/router';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import { miLocalStorage } from '@/local-storage';
+import { CURRENT_STICKY_BOTTOM } from '@/const';
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
@@ -115,6 +116,7 @@ window.addEventListener('resize', () => {
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
const widgetsEl = $shallowRef<HTMLElement>();
const widgetsShowing = $ref(false);
+const navFooter = $shallowRef<HTMLElement>();
provide('router', mainRouter);
provideMetadataReceiver((info) => {
@@ -207,6 +209,21 @@ function top() {
}
const wallpaper = miLocalStorage.getItem('wallpaper') != null;
+
+let navFooterHeight = $ref(0);
+provide<Ref<number>>(CURRENT_STICKY_BOTTOM, $$(navFooterHeight));
+
+watch($$(navFooter), () => {
+ if (navFooter) {
+ navFooterHeight = navFooter.offsetHeight;
+ document.body.style.setProperty('--stickyBottom', `${navFooterHeight}px`);
+ } else {
+ navFooterHeight = 0;
+ document.body.style.setProperty('--stickyBottom', '0px');
+ }
+}, {
+ immediate: true,
+});
</script>
<style lang="scss" module>
@@ -342,8 +359,8 @@ $widgets-hide-threshold: 1090px;
grid-gap: 8px;
width: 100%;
box-sizing: border-box;
- -webkit-backdrop-filter: var(--blur, blur(32px));
- backdrop-filter: var(--blur, blur(32px));
+ -webkit-backdrop-filter: var(--blur, blur(24px));
+ backdrop-filter: var(--blur, blur(24px));
background-color: var(--header);
border-top: solid 0.5px var(--divider);
}
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
index da92b37d19..6286d076c7 100644
--- a/packages/sw/src/scripts/create-notification.ts
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -138,26 +138,11 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
if (reaction.startsWith(':')) {
// カスタム絵文字の場合
- const customEmoji = data.body.note.emojis.find(x => x.name === reaction.substr(1, reaction.length - 2));
- if (customEmoji) {
- if (reaction.includes('@')) {
- reaction = `:${reaction.substr(1, reaction.indexOf('@') - 1)}:`;
- }
-
- const u = new URL(customEmoji.url);
- if (u.href.startsWith(`${origin}/proxy/`)) {
- // もう既にproxyっぽそうだったらsearchParams付けるだけ
- u.searchParams.set('badge', '1');
- badge = u.href;
- } else {
- // 拡張子がないとキャッシュしてくれないCDNがあるので
- const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.png`;
- badge = `${origin}/proxy/${dummy}?${url.query({
- url: u.href,
- badge: '1',
- })}`;
- }
- }
+ const name = reaction.substring(1, reaction.length - 1);
+ badge = `${origin}/emoji/${name}.webp?${url.query({
+ badge: '1',
+ })}`;
+ reaction = name.split('@')[0];
} else {
// Unicode絵文字の場合
badge = `/twemoji-badge/${char2fileName(reaction)}.png`;
@@ -171,6 +156,7 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
return [`${reaction} ${getUserName(data.body.user)}`, {
body: data.body.note.text ?? '',
icon: data.body.user.avatarUrl,
+ tag,
badge,
data,
actions: [