summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-03-03 15:35:40 +0900
committerGitHub <noreply@github.com>2023-03-03 15:35:40 +0900
commit5bd68aa3e0ce65a9183e193ff113b2486021f679 (patch)
tree7cce90cfd87cbdfc932226273a6a89aa7871b33e /packages
parentMerge pull request #10112 from misskey-dev/develop (diff)
parentfix CHANGELOG.md (diff)
downloadmisskey-5bd68aa3e0ce65a9183e193ff113b2486021f679.tar.gz
misskey-5bd68aa3e0ce65a9183e193ff113b2486021f679.tar.bz2
misskey-5bd68aa3e0ce65a9183e193ff113b2486021f679.zip
Merge pull request #10177 from misskey-dev/develop
Release: 13.9.0
Diffstat (limited to '')
-rw-r--r--packages/backend/jest.config.cjs13
-rw-r--r--packages/backend/migration/1677570181236-role-assignment-expires-at.js13
-rw-r--r--packages/backend/package.json5
-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/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.ts24
-rw-r--r--packages/backend/src/core/entities/GalleryPostEntityService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts8
-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/misc/is-not-null.ts5
-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.ts5
-rw-r--r--packages/backend/src/server/ServerService.ts36
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts5
-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/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/i/update-email.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/roles/users.ts5
-rw-r--r--packages/backend/src/server/api/stream/types.ts9
-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)301
-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)98
-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/unit/RoleService.ts75
-rw-r--r--packages/backend/test/utils.ts203
-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/src/components/MkCropperDialog.vue4
-rw-r--r--packages/frontend/src/components/MkFolder.vue4
-rw-r--r--packages/frontend/src/components/MkMediaList.vue23
-rw-r--r--packages/frontend/src/components/MkMenu.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/MkPostForm.vue10
-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/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/os.ts10
-rw-r--r--packages/frontend/src/pages/about-misskey.vue7
-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.vue56
-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/settings.vue24
-rw-r--r--packages/frontend/src/pages/admin/users.vue115
-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/settings/general.vue2
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue3
-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/welcome.entrance.a.vue8
-rw-r--r--packages/frontend/src/plugin.ts70
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts28
-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.ts25
-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
115 files changed, 1989 insertions, 1502 deletions
diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs
index 8a11ad848c..6b1afec734 100644
--- a/packages/backend/jest.config.cjs
+++ b/packages/backend/jest.config.cjs
@@ -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
@@ -160,7 +160,7 @@ module.exports = {
testMatch: [
"<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",
- //"<rootDir>/test/e2e/**/*.ts"
+ "<rootDir>/test/e2e/**/*.ts",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
@@ -207,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 9fa1e68a46..42efb881e2 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"
@@ -146,7 +146,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",
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/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..f5b1f98153 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,29 @@ 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'>>> {
+ const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
+ const packedFiles = await this.packMany(files, options);
+ return new Map(packedFiles.map(f => [f.id, f]));
+ }
+
+ @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..c732a98a11 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';
@@ -257,6 +258,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean;
_hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
+ packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'>>;
};
},
): Promise<Packed<'Note'>> {
@@ -284,6 +286,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 +307,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 ? note.fileIds.map(fi => packedFiles.get(fi)).filter(isNotNull) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId ?? undefined,
@@ -388,11 +391,14 @@ export class NoteEntityService implements OnModuleInit {
}
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ const fileIds = notes.flatMap(n => n.fileIds);
+ 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/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/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..e5eefac1fa 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -226,7 +226,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
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 6d8540dd4f..f84a3aa59b 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -100,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;
}
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/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/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/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/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/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..e864eab6cb 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,392 @@ 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.png' });
- 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.png');
+ });
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..98ee34d8d1 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 } 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);
@@ -215,7 +215,7 @@ describe('Note', () => {
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 +228,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 +244,7 @@ describe('Note', () => {
});
test('投票の選択肢が1つで怒られる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {
choices: ['Strawberry Pasta'],
},
@@ -253,14 +253,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 +269,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 +290,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 +298,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 +317,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 +327,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/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/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/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/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..a54a1c2305 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -43,8 +43,8 @@
import { nextTick, onMounted } from 'vue';
const props = withDefaults(defineProps<{
- defaultOpen: boolean;
- maxHeight: number | null;
+ defaultOpen?: boolean;
+ maxHeight?: number | null;
}>(), {
defaultOpen: false,
maxHeight: null,
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index fafa0bd232..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);
@@ -198,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);
@@ -219,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/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/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index f73eab5b86..2b3e2c8646 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':
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/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/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 a49025c920..51c3de43fe 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -84,10 +84,6 @@
</div>
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
</FormSection>
- <FormSection>
- <template #label>Credits</template>
- <p>Misskeyで使われる画像の一部は、許可を得て「あの子がこっちを見てるメーカー」で作成したものが含まれます。</p>
- </FormSection>
</div>
</MkSpacer>
</div>
@@ -121,6 +117,9 @@ const patronsWithIcon = [{
}, {
name: 'ひとぅ',
icon: 'https://misskey-hub.net/patrons/8cc0d0a0a6d84c88bca1aedabf6ed5ab.jpg',
+}, {
+ name: 'ぱーこ',
+ icon: 'https://misskey-hub.net/patrons/79c6602ffade489e8df2fcf2c2bc5d9d.jpg',
}];
const patrons = [
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..2a65a75187 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,45 @@ 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: '',
+ rolePermission: 'normal',
+ 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 +85,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..e09f22e345 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 v-model="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/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/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/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 1b492b15cf..a578c5c747 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -45,6 +45,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 +141,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'));
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 0512a8d0c9..e3cf1aefb0 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',
@@ -420,7 +422,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/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 6c6baf8266..5170ca4c8c 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -143,8 +143,32 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
return roles.filter(r => r.target === 'manual').map(r => ({
text: r.name,
- action: () => {
- os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id });
+ 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 });
},
}));
},
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..a6ad1774ff 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -273,6 +273,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 5,
},
+ showNoteActionsOnlyHover: {
+ where: 'device',
+ default: false,
+ },
aiChanMode: {
where: 'device',
default: false,
@@ -283,12 +287,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 +319,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/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: [