diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-03-03 15:35:40 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-03-03 15:35:40 +0900 |
| commit | 5bd68aa3e0ce65a9183e193ff113b2486021f679 (patch) | |
| tree | 7cce90cfd87cbdfc932226273a6a89aa7871b33e /packages | |
| parent | Merge pull request #10112 from misskey-dev/develop (diff) | |
| parent | fix CHANGELOG.md (diff) | |
| download | misskey-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 '')
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 Binary files differnew file mode 100644 index 0000000000..2ef7024beb --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 Binary files differnew file mode 100644 index 0000000000..a8ad11287e --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 Binary files differnew file mode 100644 index 0000000000..8cc2ead028 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 Binary files differnew file mode 100644 index 0000000000..59cbcaf917 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-aec.mp3 b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 Binary files differnew file mode 100644 index 0000000000..7aec0516e7 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-aec.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 Binary files differnew file mode 100644 index 0000000000..98fe354d67 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 Binary files differnew file mode 100644 index 0000000000..7b69b3410d --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 Binary files differnew file mode 100644 index 0000000000..44f2deee3d --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 Binary files differnew file mode 100644 index 0000000000..1342a56f85 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-cea.mp3 b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 Binary files differnew file mode 100644 index 0000000000..88d641fd64 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-cea.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 Binary files differnew file mode 100644 index 0000000000..468b82bc2c --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 Binary files differnew file mode 100644 index 0000000000..3869e894d2 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 Binary files differnew file mode 100644 index 0000000000..f268b7ee8b --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 Binary files differnew file mode 100644 index 0000000000..d6e895e67b --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 Binary files differnew file mode 100644 index 0000000000..8e055db91d --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-ea.mp3 b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 Binary files differnew file mode 100644 index 0000000000..c13d13247b --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-ea.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 Binary files differnew file mode 100644 index 0000000000..06577d5431 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 Binary files differnew file mode 100644 index 0000000000..1622033d6a --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 Binary files differnew file mode 100644 index 0000000000..a3a9f3a9fb --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 Binary files differnew file mode 100644 index 0000000000..4efa848407 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3 diff --git a/packages/frontend/assets/sounds/syuilo/n-eca.mp3 b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 Binary files differnew file mode 100644 index 0000000000..1a3979cc61 --- /dev/null +++ b/packages/frontend/assets/sounds/syuilo/n-eca.mp3 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: [ |