Merge tag '13.9.1' into io
This commit is contained in:
commit
8ecd5d2a12
139 changed files with 2591 additions and 1675 deletions
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -316,6 +330,65 @@ export class RoleService implements OnApplicationShutdown {
|
|||
return users;
|
||||
}
|
||||
|
||||
@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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
5
packages/backend/src/misc/is-not-null.ts
Normal file
5
packages/backend/src/misc/is-not-null.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -39,4 +39,10 @@ export class RoleAssignment {
|
|||
})
|
||||
@JoinColumn()
|
||||
public role: Role | null;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public expiresAt: Date | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
|
|
@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
BIN
packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-aec-4va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-aec-4vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-aec-8va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-aec-8vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-aec.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-aec.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-cea-4va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-cea-4vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-cea-8va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-cea-8vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-cea.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-cea.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea-4va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea-4vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea-8va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea-8vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea-harmony.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-ea.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-ea.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-eca-4va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-eca-4vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-eca-8va.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-eca-8vb.mp3
Normal file
Binary file not shown.
BIN
packages/frontend/assets/sounds/syuilo/n-eca.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/n-eca.mp3
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
.pswp {
|
||||
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
|
||||
--pswp-bg: var(--modalBg) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp {
|
||||
// なぜか機能しない
|
||||
//z-index: v-bind(pswpZIndex);
|
||||
z-index: 2000000;
|
||||
--pswp-bg: var(--modalBg);
|
||||
}
|
||||
|
||||
.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%);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default defineComponent({
|
|||
> button {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
border-radius: var(--radius);
|
||||
border-radius: 999px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 1 !important;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ onMounted(() => {
|
|||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
top: 50px;
|
||||
margin: 0 auto;
|
||||
margin-top: 16px;
|
||||
min-width: 300px;
|
||||
|
|
|
|||
222
packages/frontend/src/components/MkUserPopup.vue
Normal file
222
packages/frontend/src/components/MkUserPopup.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
21
packages/frontend/src/directives/container.ts
Normal file
21
packages/frontend/src/directives/container.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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インスタンスが使いまわされ、
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
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 created(r) {
|
||||
router.push('/admin/roles/' + r.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);
|
||||
}
|
||||
}
|
||||
|
||||
function updated() {
|
||||
router.push('/admin/roles/' + role.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>
|
||||
|
|
|
|||
|
|
@ -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 role = $ref(deepClone(props.modelValue));
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
<style lang="scss" module>
|
||||
.inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
> .inputs {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
.users {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
> * {
|
||||
margin-right: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .users {
|
||||
margin-top: var(--margin);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
<style lang="scss" module>
|
||||
.main {
|
||||
min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
.userItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
.userItemBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .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>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,5 @@ const headerTabs = $computed(() => []);
|
|||
definePageMetadata({
|
||||
title: i18n.ts.statusbar,
|
||||
icon: 'ti ti-list',
|
||||
bg: 'var(--bg)',
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue