Merge tag '13.9.1' into io

This commit is contained in:
Cookie Ramen 2023-03-05 18:30:02 +09:00
commit 8ecd5d2a12
No known key found for this signature in database
GPG key ID: 82A8B5D42246F02D
139 changed files with 2591 additions and 1675 deletions

View file

@ -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)
};

View file

@ -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"`);
}
}

View file

@ -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",

View file

@ -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(),

View file

@ -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() {

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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 {

View file

@ -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()),
);
}
}
}

View file

@ -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,

View file

@ -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,
},
})));
}

View file

@ -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,
});
}

View 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;
}

View file

@ -39,4 +39,10 @@ export class RoleAssignment {
})
@JoinColumn()
public role: Role | null;
@Index()
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
}

View file

@ -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();
}

View file

@ -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

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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; },
};
});

View file

@ -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);
});
}
}

View file

@ -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);
});
}
}

View file

@ -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,
})));
});
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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

View file

@ -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> = {

View file

@ -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
});
});
*/

View file

@ -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');
}));
});
});
});

View file

@ -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);

View file

@ -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);
});
});
});
*/

View file

@ -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 () => {

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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);
});
});
});

View file

@ -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(() => {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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;

View file

@ -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,

View file

@ -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%);

View file

@ -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>

View file

@ -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;
}
}
}
}

View file

@ -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);
});
},
}, {

View file

@ -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>

View file

@ -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':

View file

@ -34,7 +34,7 @@ export default defineComponent({
> button {
flex: 1;
padding: 10px 8px;
border-radius: var(--radius);
border-radius: 999px;
&:disabled {
opacity: 1 !important;

View file

@ -53,7 +53,7 @@ onMounted(() => {
position: fixed;
left: 0;
right: 0;
top: 0;
top: 50px;
margin: 0 auto;
margin-top: 16px;
min-width: 300px;

View 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>

View file

@ -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>

View file

@ -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;
}

View file

@ -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();

View file

@ -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(() => {

View file

@ -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';

View file

@ -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;

View 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;

View file

@ -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);
}

View file

@ -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,

View file

@ -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),

View file

@ -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インスタンスが使いまわされ、

View file

@ -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 = [

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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'));

View file

@ -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>

View file

@ -47,6 +47,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.statusbar,
icon: 'ti ti-list',
bg: 'var(--bg)',
});
</script>

View file

@ -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