Merge branch 'develop' into img-max
This commit is contained in:
		| @@ -15,14 +15,19 @@ | ||||
| ## 13.x.x (unreleased) | ||||
|  | ||||
| ### General | ||||
| - カスタム絵文字関連の変更 | ||||
| - 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 | ||||
| 	- Deckのカラムとしても追加可能 | ||||
| - カスタム絵文字関連の改善 | ||||
|   * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように | ||||
|   * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように | ||||
| - カスタム絵文字でリアクションできないことがある問題を修正 | ||||
|  | ||||
| ### Client | ||||
| - | ||||
|  | ||||
| ### Server | ||||
| - Misskey Webでのサーバーサイドエラー画面を改善 | ||||
| - Misskey Webでのサーバーサイドエラーのログが残るように | ||||
| - ノート作成時のアンテナ追加パフォーマンスを改善 | ||||
| - フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io | ||||
|  | ||||
|   | ||||
| @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { | ||||
| }); | ||||
|  | ||||
| gulp.task('build:backend:style', () => { | ||||
| 	return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) | ||||
| 	return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css']) | ||||
| 		.pipe(cssnano({ | ||||
| 			zindex: false | ||||
| 		})) | ||||
|   | ||||
| @@ -1946,6 +1946,7 @@ _deck: | ||||
|     channel: "チャンネル" | ||||
|     mentions: "あなた宛て" | ||||
|     direct: "ダイレクト" | ||||
|     roleTimeline: "ロールタイムライン" | ||||
|  | ||||
| _dialog: | ||||
|   charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { query } from '@/misc/prelude/url.js'; | ||||
| import type { Serialized } from '@/server/api/stream/types.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CustomEmojiService { | ||||
| @@ -44,7 +45,13 @@ export class CustomEmojiService { | ||||
| 			memoryCacheLifetime: 1000 * 60 * 3, // 3m | ||||
| 			fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), | ||||
| 			toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), | ||||
| 			fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換 | ||||
| 			fromRedisConverter: (value) => { | ||||
| 				if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) | ||||
| 				return new Map(JSON.parse(value).map((x: Serialized<Emoji>) => [x.name, { | ||||
| 					...x, | ||||
| 					updatedAt: x.updatedAt && new Date(x.updatedAt), | ||||
| 				}])); | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -14,11 +14,13 @@ import type { | ||||
| 	MainStreamTypes, | ||||
| 	NoteStreamTypes, | ||||
| 	UserListStreamTypes, | ||||
| 	RoleTimelineStreamTypes, | ||||
| } from '@/server/api/stream/types.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Role } from '@/models'; | ||||
|  | ||||
| @Injectable() | ||||
| export class GlobalEventService { | ||||
| @@ -81,6 +83,11 @@ export class GlobalEventService { | ||||
| 		this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { | ||||
| 		this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public publishNotesStream(note: Packed<'Note'>): void { | ||||
| 		this.publish('notesStream', null, note); | ||||
|   | ||||
| @@ -547,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
|  | ||||
| 			this.globalEventService.publishNotesStream(noteObj); | ||||
|  | ||||
| 			this.roleService.addNoteToRoleTimeline(noteObj); | ||||
|  | ||||
| 			this.webhookService.getActiveWebhooks().then(webhooks => { | ||||
| 				webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); | ||||
| 				for (const webhook of webhooks) { | ||||
|   | ||||
| @@ -13,6 +13,7 @@ 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 { Packed } from '@/misc/json-schema'; | ||||
| import type { OnApplicationShutdown } from '@nestjs/common'; | ||||
|  | ||||
| export type RolePolicies = { | ||||
| @@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 	public static NotAssignedError = class extends Error {}; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.redisForSub) | ||||
| 		private redisForSub: Redis.Redis, | ||||
|  | ||||
| @@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown { | ||||
| 		this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> { | ||||
| 		const roles = await this.getUserRoles(note.userId); | ||||
|  | ||||
| 		const redisPipeline = this.redisClient.pipeline(); | ||||
|  | ||||
| 		for (const role of roles) { | ||||
| 			redisPipeline.xadd( | ||||
| 				`roleTimeline:${role.id}`, | ||||
| 				'MAXLEN', '~', '1000', | ||||
| 				'*', | ||||
| 				'note', note.id); | ||||
|  | ||||
| 			this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); | ||||
| 		} | ||||
|  | ||||
| 		redisPipeline.exec(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		this.redisForSub.off('message', this.onMessage); | ||||
|   | ||||
| @@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js'; | ||||
| import { UserBlockingService } from '@/core/UserBlockingService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import Logger from '../logger.js'; | ||||
|  | ||||
| const logger = new Logger('following/create'); | ||||
| @@ -44,6 +45,9 @@ export class UserFollowingService implements OnModuleInit { | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
|  | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| @@ -411,7 +415,7 @@ export class UserFollowingService implements OnModuleInit { | ||||
| 		} | ||||
|  | ||||
| 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); | ||||
| 			const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); | ||||
| 			this.queueService.deliver(follower, content, followee.inbox, false); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -8,7 +8,7 @@ export class RedisKVCache<T> { | ||||
| 	private memoryCache: MemoryKVCache<T>; | ||||
| 	private fetcher: (key: string) => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T; | ||||
| 	private fromRedisConverter: (value: string) => T | undefined; | ||||
|  | ||||
| 	constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { | ||||
| 		lifetime: RedisKVCache<T>['lifetime']; | ||||
| @@ -92,7 +92,7 @@ export class RedisSingleCache<T> { | ||||
| 	private memoryCache: MemorySingleCache<T>; | ||||
| 	private fetcher: () => Promise<T>; | ||||
| 	private toRedisConverter: (value: T) => string; | ||||
| 	private fromRedisConverter: (value: string) => T; | ||||
| 	private fromRedisConverter: (value: string) => T | undefined; | ||||
|  | ||||
| 	constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { | ||||
| 		lifetime: RedisSingleCache<T>['lifetime']; | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; | ||||
| import accepts from 'accepts'; | ||||
| import vary from 'vary'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js'; | ||||
| import * as url from '@/misc/prelude/url.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; | ||||
| @@ -54,6 +54,9 @@ export class ActivityPubServerService { | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.followRequestsRepository) | ||||
| 		private followRequestsRepository: FollowRequestsRepository, | ||||
|  | ||||
| 		private utilityService: UtilityService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| @@ -205,22 +208,22 @@ export class ActivityPubServerService { | ||||
| 			reply.code(400); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const page = request.query.page === 'true'; | ||||
| 	 | ||||
|  | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			reply.code(404); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		//#region Check ff visibility | ||||
| 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | ||||
| 	 | ||||
|  | ||||
| 		if (profile.ffVisibility === 'private') { | ||||
| 			reply.code(403); | ||||
| 			reply.header('Cache-Control', 'public, max-age=30'); | ||||
| @@ -231,31 +234,31 @@ export class ActivityPubServerService { | ||||
| 			return; | ||||
| 		} | ||||
| 		//#endregion | ||||
| 	 | ||||
|  | ||||
| 		const limit = 10; | ||||
| 		const partOf = `${this.config.url}/users/${userId}/following`; | ||||
| 	 | ||||
|  | ||||
| 		if (page) { | ||||
| 			const query = { | ||||
| 				followerId: user.id, | ||||
| 			} as FindOptionsWhere<Following>; | ||||
| 	 | ||||
|  | ||||
| 			// カーソルが指定されている場合 | ||||
| 			if (cursor) { | ||||
| 				query.id = LessThan(cursor); | ||||
| 			} | ||||
| 	 | ||||
|  | ||||
| 			// Get followings | ||||
| 			const followings = await this.followingsRepository.find({ | ||||
| 				where: query, | ||||
| 				take: limit + 1, | ||||
| 				order: { id: -1 }, | ||||
| 			}); | ||||
| 	 | ||||
|  | ||||
| 			// 「次のページ」があるかどうか | ||||
| 			const inStock = followings.length === limit + 1; | ||||
| 			if (inStock) followings.pop(); | ||||
| 	 | ||||
|  | ||||
| 			const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); | ||||
| 			const rendered = this.apRendererService.renderOrderedCollectionPage( | ||||
| 				`${partOf}?${url.query({ | ||||
| @@ -269,7 +272,7 @@ export class ActivityPubServerService { | ||||
| 					cursor: followings[followings.length - 1].id, | ||||
| 				})}` : undefined, | ||||
| 			); | ||||
| 	 | ||||
|  | ||||
| 			this.setResponseType(request, reply); | ||||
| 			return (this.apRendererService.addContext(rendered)); | ||||
| 		} else { | ||||
| @@ -330,33 +333,33 @@ export class ActivityPubServerService { | ||||
| 			reply.code(400); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const untilId = request.query.until_id; | ||||
| 		if (untilId != null && typeof untilId !== 'string') { | ||||
| 			reply.code(400); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const page = request.query.page === 'true'; | ||||
| 	 | ||||
|  | ||||
| 		if (countIf(x => x != null, [sinceId, untilId]) > 1) { | ||||
| 			reply.code(400); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
| 	 | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			reply.code(404); | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
|  | ||||
| 		const limit = 20; | ||||
| 		const partOf = `${this.config.url}/users/${userId}/outbox`; | ||||
| 	 | ||||
|  | ||||
| 		if (page) { | ||||
| 			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) | ||||
| 				.andWhere('note.userId = :userId', { userId: user.id }) | ||||
| @@ -365,11 +368,11 @@ export class ActivityPubServerService { | ||||
| 					.orWhere('note.visibility = \'home\''); | ||||
| 				})) | ||||
| 				.andWhere('note.localOnly = FALSE'); | ||||
| 	 | ||||
|  | ||||
| 			const notes = await query.take(limit).getMany(); | ||||
| 	 | ||||
|  | ||||
| 			if (sinceId) notes.reverse(); | ||||
| 	 | ||||
|  | ||||
| 			const activities = await Promise.all(notes.map(note => this.packActivity(note))); | ||||
| 			const rendered = this.apRendererService.renderOrderedCollectionPage( | ||||
| 				`${partOf}?${url.query({ | ||||
| @@ -387,7 +390,7 @@ export class ActivityPubServerService { | ||||
| 					until_id: notes[notes.length - 1].id, | ||||
| 				})}` : undefined, | ||||
| 			); | ||||
| 	 | ||||
|  | ||||
| 			this.setResponseType(request, reply); | ||||
| 			return (this.apRendererService.addContext(rendered)); | ||||
| 		} else { | ||||
| @@ -457,7 +460,7 @@ export class ActivityPubServerService { | ||||
| 		// note | ||||
| 		fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { | ||||
| 			vary(reply.raw, 'Accept'); | ||||
| 	 | ||||
|  | ||||
| 			const note = await this.notesRepository.findOneBy({ | ||||
| 				id: request.params.note, | ||||
| 				visibility: In(['public', 'home']), | ||||
| @@ -639,6 +642,41 @@ export class ActivityPubServerService { | ||||
| 			return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); | ||||
| 		}); | ||||
|  | ||||
| 		// follow | ||||
| 		fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { | ||||
| 			// This may be used before the follow is completed, so we do not | ||||
| 			// check if the following exists and only check if the follow request exists. | ||||
|  | ||||
| 			const followRequest = await this.followRequestsRepository.findOneBy({ | ||||
| 				id: request.params.followRequestId, | ||||
| 			}); | ||||
|  | ||||
| 			if (followRequest == null) { | ||||
| 				reply.code(404); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const [follower, followee] = await Promise.all([ | ||||
| 				this.usersRepository.findOneBy({ | ||||
| 					id: followRequest.followerId, | ||||
| 					host: IsNull(), | ||||
| 				}), | ||||
| 				this.usersRepository.findOneBy({ | ||||
| 					id: followRequest.followeeId, | ||||
| 					host: Not(IsNull()), | ||||
| 				}), | ||||
| 			]); | ||||
|  | ||||
| 			if (follower == null || followee == null) { | ||||
| 				reply.code(404); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			reply.header('Cache-Control', 'public, max-age=180'); | ||||
| 			this.setResponseType(request, reply); | ||||
| 			return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); | ||||
| 		}); | ||||
|  | ||||
| 		done(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; | ||||
| import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; | ||||
| import { UserListChannelService } from './api/stream/channels/user-list.js'; | ||||
| import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | ||||
| import { ClientLoggerService } from './web/ClientLoggerService.js'; | ||||
| import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; | ||||
|  | ||||
| @Module({ | ||||
| 	imports: [ | ||||
| @@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | ||||
| 	], | ||||
| 	providers: [ | ||||
| 		ClientServerService, | ||||
| 		ClientLoggerService, | ||||
| 		FeedService, | ||||
| 		UrlPreviewService, | ||||
| 		ActivityPubServerService, | ||||
| @@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; | ||||
| 		DriveChannelService, | ||||
| 		GlobalTimelineChannelService, | ||||
| 		HashtagChannelService, | ||||
| 		RoleTimelineChannelService, | ||||
| 		HomeTimelineChannelService, | ||||
| 		HybridTimelineChannelService, | ||||
| 		LocalTimelineChannelService, | ||||
|   | ||||
| @@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; | ||||
| import * as ep___roles_list from './endpoints/roles/list.js'; | ||||
| import * as ep___roles_show from './endpoints/roles/show.js'; | ||||
| import * as ep___roles_users from './endpoints/roles/users.js'; | ||||
| import * as ep___roles_notes from './endpoints/roles/notes.js'; | ||||
| import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; | ||||
| import * as ep___resetDb from './endpoints/reset-db.js'; | ||||
| import * as ep___resetPassword from './endpoints/reset-password.js'; | ||||
| @@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r | ||||
| const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; | ||||
| const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; | ||||
| const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; | ||||
| const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; | ||||
| const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; | ||||
| const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; | ||||
| const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; | ||||
| @@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$roles_list, | ||||
| 		$roles_show, | ||||
| 		$roles_users, | ||||
| 		$roles_notes, | ||||
| 		$requestResetPassword, | ||||
| 		$resetDb, | ||||
| 		$resetPassword, | ||||
| @@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$roles_list, | ||||
| 		$roles_show, | ||||
| 		$roles_users, | ||||
| 		$roles_notes, | ||||
| 		$requestResetPassword, | ||||
| 		$resetDb, | ||||
| 		$resetPassword, | ||||
|   | ||||
| @@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; | ||||
| import * as ep___roles_list from './endpoints/roles/list.js'; | ||||
| import * as ep___roles_show from './endpoints/roles/show.js'; | ||||
| import * as ep___roles_users from './endpoints/roles/users.js'; | ||||
| import * as ep___roles_notes from './endpoints/roles/notes.js'; | ||||
| import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; | ||||
| import * as ep___resetDb from './endpoints/reset-db.js'; | ||||
| import * as ep___resetPassword from './endpoints/reset-password.js'; | ||||
| @@ -626,6 +627,7 @@ const eps = [ | ||||
| 	['roles/list', ep___roles_list], | ||||
| 	['roles/show', ep___roles_show], | ||||
| 	['roles/users', ep___roles_users], | ||||
| 	['roles/notes', ep___roles_notes], | ||||
| 	['request-reset-password', ep___requestResetPassword], | ||||
| 	['reset-db', ep___resetDb], | ||||
| 	['reset-password', ep___resetPassword], | ||||
|   | ||||
| @@ -76,11 +76,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				throw new ApiError(meta.errors.noSuchAntenna); | ||||
| 			} | ||||
|  | ||||
| 			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 | ||||
| 			const noteIdsRes = await this.redisClient.xrevrange( | ||||
| 				`antennaTimeline:${antenna.id}`, | ||||
| 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', | ||||
| 				'-', | ||||
| 				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 | ||||
| 				'COUNT', limit); | ||||
|  | ||||
| 			if (noteIdsRes.length === 0) { | ||||
| 				return []; | ||||
|   | ||||
| @@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; | ||||
| 			const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; | ||||
|  | ||||
| 			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 | ||||
| 			const notificationsRes = await this.redisClient.xrevrange( | ||||
| 				`notificationTimeline:${me.id}`, | ||||
| 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', | ||||
| 				'-', | ||||
| 				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 | ||||
| 				'COUNT', limit); | ||||
|  | ||||
| 			if (notificationsRes.length === 0) { | ||||
| 				return []; | ||||
|   | ||||
							
								
								
									
										109
									
								
								packages/backend/src/server/api/endpoints/roles/notes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								packages/backend/src/server/api/endpoints/roles/notes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { NotesRepository, RolesRepository } from '@/models/index.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['role', 'notes'], | ||||
|  | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchRole: { | ||||
| 			message: 'No such role.', | ||||
| 			code: 'NO_SUCH_ROLE', | ||||
| 			id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	res: { | ||||
| 		type: 'array', | ||||
| 		optional: false, nullable: false, | ||||
| 		items: { | ||||
| 			type: 'object', | ||||
| 			optional: false, nullable: false, | ||||
| 			ref: 'Note', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		roleId: { type: 'string', format: 'misskey:id' }, | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		sinceDate: { type: 'integer' }, | ||||
| 		untilDate: { type: 'integer' }, | ||||
| 	}, | ||||
| 	required: ['roleId'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		@Inject(DI.rolesRepository) | ||||
| 		private rolesRepository: RolesRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const role = await this.rolesRepository.findOneBy({ | ||||
| 				id: ps.roleId, | ||||
| 			}); | ||||
|  | ||||
| 			if (role == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchRole); | ||||
| 			} | ||||
|  | ||||
| 			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 | ||||
| 			const noteIdsRes = await this.redisClient.xrevrange( | ||||
| 				`roleTimeline:${role.id}`, | ||||
| 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', | ||||
| 				'-', | ||||
| 				'COUNT', limit); | ||||
|  | ||||
| 			if (noteIdsRes.length === 0) { | ||||
| 				return []; | ||||
| 			} | ||||
|  | ||||
| 			const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); | ||||
|  | ||||
| 			if (noteIds.length === 0) { | ||||
| 				return []; | ||||
| 			} | ||||
|  | ||||
| 			const query = this.notesRepository.createQueryBuilder('note') | ||||
| 				.where('note.id IN (:...noteIds)', { noteIds: noteIds }) | ||||
| 				.innerJoinAndSelect('note.user', 'user') | ||||
| 				.leftJoinAndSelect('note.reply', 'reply') | ||||
| 				.leftJoinAndSelect('note.renote', 'renote') | ||||
| 				.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 				.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
|  | ||||
| 			this.queryService.generateVisibilityQuery(query, me); | ||||
| 			this.queryService.generateMutedUserQuery(query, me); | ||||
| 			this.queryService.generateBlockedUserQuery(query, me); | ||||
|  | ||||
| 			const notes = await query.getMany(); | ||||
| 			notes.sort((a, b) => a.id > b.id ? -1 : 1); | ||||
|  | ||||
| 			return await this.noteEntityService.packMany(notes, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js'; | ||||
| import { AntennaChannelService } from './channels/antenna.js'; | ||||
| import { DriveChannelService } from './channels/drive.js'; | ||||
| import { HashtagChannelService } from './channels/hashtag.js'; | ||||
| import { RoleTimelineChannelService } from './channels/role-timeline.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ChannelsService { | ||||
| @@ -24,6 +25,7 @@ export class ChannelsService { | ||||
| 		private globalTimelineChannelService: GlobalTimelineChannelService, | ||||
| 		private userListChannelService: UserListChannelService, | ||||
| 		private hashtagChannelService: HashtagChannelService, | ||||
| 		private roleTimelineChannelService: RoleTimelineChannelService, | ||||
| 		private antennaChannelService: AntennaChannelService, | ||||
| 		private channelChannelService: ChannelChannelService, | ||||
| 		private driveChannelService: DriveChannelService, | ||||
| @@ -43,6 +45,7 @@ export class ChannelsService { | ||||
| 			case 'globalTimeline': return this.globalTimelineChannelService; | ||||
| 			case 'userList': return this.userListChannelService; | ||||
| 			case 'hashtag': return this.hashtagChannelService; | ||||
| 			case 'roleTimeline': return this.roleTimelineChannelService; | ||||
| 			case 'antenna': return this.antennaChannelService; | ||||
| 			case 'channel': return this.channelChannelService; | ||||
| 			case 'drive': return this.driveChannelService; | ||||
|   | ||||
| @@ -0,0 +1,75 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { isUserRelated } from '@/misc/is-user-related.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import Channel from '../channel.js'; | ||||
| import { StreamMessages } from '../types.js'; | ||||
|  | ||||
| class RoleTimelineChannel extends Channel { | ||||
| 	public readonly chName = 'roleTimeline'; | ||||
| 	public static shouldShare = false; | ||||
| 	public static requireCredential = false; | ||||
| 	private roleId: string; | ||||
|  | ||||
| 	constructor( | ||||
| 		private noteEntityService: NoteEntityService, | ||||
|  | ||||
| 		id: string, | ||||
| 		connection: Channel['connection'], | ||||
| 	) { | ||||
| 		super(id, connection); | ||||
| 		//this.onNote = this.onNote.bind(this); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async init(params: any) { | ||||
| 		this.roleId = params.roleId as string; | ||||
|  | ||||
| 		this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	private async onEvent(data: StreamMessages['roleTimeline']['payload']) { | ||||
| 		if (data.type === 'note') { | ||||
| 			const note = data.body; | ||||
|  | ||||
| 			// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する | ||||
| 			if (isUserRelated(note, this.userIdsWhoMeMuting)) return; | ||||
| 			// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する | ||||
| 			if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; | ||||
|  | ||||
| 			if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; | ||||
|  | ||||
| 			this.send('note', note); | ||||
| 		} else { | ||||
| 			this.send(data.type, data.body); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public dispose() { | ||||
| 		// Unsubscribe events | ||||
| 		this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @Injectable() | ||||
| export class RoleTimelineChannelService { | ||||
| 	public readonly shouldShare = RoleTimelineChannel.shouldShare; | ||||
| 	public readonly requireCredential = RoleTimelineChannel.requireCredential; | ||||
|  | ||||
| 	constructor( | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public create(id: string, connection: Channel['connection']): RoleTimelineChannel { | ||||
| 		return new RoleTimelineChannel( | ||||
| 			this.noteEntityService, | ||||
| 			id, | ||||
| 			connection, | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| @@ -148,6 +148,10 @@ export interface AntennaStreamTypes { | ||||
| 	note: Note; | ||||
| } | ||||
|  | ||||
| export interface RoleTimelineStreamTypes { | ||||
| 	note: Packed<'Note'>; | ||||
| } | ||||
|  | ||||
| export interface AdminStreamTypes { | ||||
| 	newAbuseUserReport: { | ||||
| 		id: AbuseUserReport['id']; | ||||
| @@ -168,7 +172,7 @@ type EventUnionFromDictionary< | ||||
| > = U[keyof U]; | ||||
|  | ||||
| // redis通すとDateのインスタンスはstringに変換されるので | ||||
| type Serialized<T> = { | ||||
| export type Serialized<T> = { | ||||
| 	[K in keyof T]: | ||||
| 		T[K] extends Date | ||||
| 			? string | ||||
| @@ -209,6 +213,10 @@ export type StreamMessages = { | ||||
| 		name: `userListStream:${UserList['id']}`; | ||||
| 		payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>; | ||||
| 	}; | ||||
| 	roleTimeline: { | ||||
| 		name: `roleTimelineStream:${Role['id']}`; | ||||
| 		payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>; | ||||
| 	}; | ||||
| 	antenna: { | ||||
| 		name: `antennaStream:${Antenna['id']}`; | ||||
| 		payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>; | ||||
|   | ||||
							
								
								
									
										14
									
								
								packages/backend/src/server/web/ClientLoggerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/backend/src/server/web/ClientLoggerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import type Logger from '@/logger.js'; | ||||
| import { LoggerService } from '@/core/LoggerService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ClientLoggerService { | ||||
| 	public logger: Logger; | ||||
|  | ||||
| 	constructor( | ||||
| 		private loggerService: LoggerService, | ||||
| 	) { | ||||
| 		this.logger = this.loggerService.getLogger('client'); | ||||
| 	} | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { dirname } from 'node:path'; | ||||
| import { fileURLToPath } from 'node:url'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { createBullBoard } from '@bull-board/api'; | ||||
| import { BullAdapter } from '@bull-board/api/bullAdapter.js'; | ||||
| import { FastifyAdapter } from '@bull-board/fastify'; | ||||
| @@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi | ||||
| import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; | ||||
| import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; | ||||
| import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type Logger from '@/logger.js'; | ||||
| import { deepClone } from '@/misc/clone.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; | ||||
| @@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' }; | ||||
| import { FeedService } from './FeedService.js'; | ||||
| import { UrlPreviewService } from './UrlPreviewService.js'; | ||||
| import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; | ||||
| import { ClientLoggerService } from './ClientLoggerService.js'; | ||||
|  | ||||
| const _filename = fileURLToPath(import.meta.url); | ||||
| const _dirname = dirname(_filename); | ||||
| @@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`; | ||||
|  | ||||
| @Injectable() | ||||
| export class ClientServerService { | ||||
| 	private logger: Logger; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
| @@ -85,6 +90,7 @@ export class ClientServerService { | ||||
| 		private urlPreviewService: UrlPreviewService, | ||||
| 		private feedService: FeedService, | ||||
| 		private roleService: RoleService, | ||||
| 		private clientLoggerService: ClientLoggerService, | ||||
|  | ||||
| 		@Inject('queue:system') public systemQueue: SystemQueue, | ||||
| 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, | ||||
| @@ -649,6 +655,24 @@ export class ClientServerService { | ||||
| 			return await renderBase(reply); | ||||
| 		}); | ||||
|  | ||||
| 		fastify.setErrorHandler(async (error, request, reply) => { | ||||
| 			const errId = uuid(); | ||||
| 			this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, { | ||||
| 				path: request.routerPath, | ||||
| 				params: request.params, | ||||
| 				query: request.query, | ||||
| 				code: error.name, | ||||
| 				stack: error.stack, | ||||
| 				id: errId, | ||||
| 			}); | ||||
| 			reply.code(500); | ||||
| 			reply.header('Cache-Control', 'max-age=10, must-revalidate'); | ||||
| 			return await reply.view('error', { | ||||
| 				code: error.code, | ||||
| 				id: errId, | ||||
| 			}); | ||||
| 		}); | ||||
|  | ||||
| 		done(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										110
									
								
								packages/backend/src/server/web/error.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								packages/backend/src/server/web/error.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| * { | ||||
|     font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; | ||||
| } | ||||
|  | ||||
| #misskey_app, | ||||
| #splash { | ||||
|     display: none !important; | ||||
| } | ||||
|  | ||||
| body, | ||||
| html { | ||||
|     background-color: #222; | ||||
|     color: #dfddcc; | ||||
|     justify-content: center; | ||||
|     margin: auto; | ||||
|     padding: 10px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| button { | ||||
|     border-radius: 999px; | ||||
|     padding: 0px 12px 0px 12px; | ||||
|     border: none; | ||||
|     cursor: pointer; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
|  | ||||
| .button-big { | ||||
|     background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); | ||||
|     line-height: 50px; | ||||
| } | ||||
|  | ||||
| .button-big:hover { | ||||
|     background: rgb(153, 204, 0); | ||||
| } | ||||
|  | ||||
| .button-small { | ||||
|     background: #444; | ||||
|     line-height: 40px; | ||||
| } | ||||
|  | ||||
| .button-small:hover { | ||||
|     background: #555; | ||||
| } | ||||
|  | ||||
| .button-label-big { | ||||
|     color: #222; | ||||
|     font-weight: bold; | ||||
|     font-size: 20px; | ||||
|     padding: 12px; | ||||
| } | ||||
|  | ||||
| .button-label-small { | ||||
|     color: rgb(153, 204, 0); | ||||
|     font-size: 16px; | ||||
|     padding: 12px; | ||||
| } | ||||
|  | ||||
| a { | ||||
|     color: rgb(134, 179, 0); | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| p, | ||||
| li { | ||||
|     font-size: 16px; | ||||
| } | ||||
|  | ||||
| .dont-worry, | ||||
| #msg { | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| .icon-warning { | ||||
|     color: #dec340; | ||||
|     height: 4rem; | ||||
|     padding-top: 2rem; | ||||
| } | ||||
|  | ||||
| h1 { | ||||
|     font-size: 32px; | ||||
| } | ||||
|  | ||||
| code { | ||||
|     display: block; | ||||
|     font-family: Fira, FiraCode, monospace; | ||||
|     background: #333; | ||||
|     padding: 0.5rem 1rem; | ||||
|     max-width: 40rem; | ||||
|     border-radius: 10px; | ||||
|     justify-content: center; | ||||
|     margin: auto; | ||||
|     white-space: pre-wrap; | ||||
|     word-break: break-word; | ||||
| } | ||||
|  | ||||
| summary { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| summary > * { | ||||
|     display: inline; | ||||
|     white-space: pre-wrap; | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 500px) { | ||||
|     details { | ||||
|         width: 50%; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										65
									
								
								packages/backend/src/server/web/views/error.pug
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								packages/backend/src/server/web/views/error.pug
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| doctype html | ||||
|  | ||||
| // | ||||
| 	- | ||||
| 	  _____ _         _            | ||||
| 	 |     |_|___ ___| |_ ___ _ _  | ||||
| 	 | | | | |_ -|_ -| '_| -_| | | | ||||
| 	 |_|_|_|_|___|___|_,_|___|_  | | ||||
| 							 |___| | ||||
| 	 Thank you for using Misskey! | ||||
| 	 If you are reading this message... how about joining the development? | ||||
| 	 https://github.com/misskey-dev/misskey | ||||
| 	  | ||||
|  | ||||
| html | ||||
|  | ||||
| 	head | ||||
| 		meta(charset='utf-8') | ||||
| 		meta(name='viewport' content='width=device-width, initial-scale=1') | ||||
| 		meta(name='application-name' content='Misskey') | ||||
| 		meta(name='referrer' content='origin') | ||||
|  | ||||
| 		title | ||||
| 			block title | ||||
| 				= 'An error has occurred... | Misskey' | ||||
|  | ||||
| 		style | ||||
| 			include ../error.css | ||||
|  | ||||
| body | ||||
| 	svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round") | ||||
| 		path(stroke="none", d="M0 0h24v24H0z", fill="none") | ||||
| 		path(d="M12 9v2m0 4v.01") | ||||
| 		path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75") | ||||
| 	 | ||||
| 	h1 An error has occurred! | ||||
|  | ||||
| 	button.button-big(onclick="location.reload();") | ||||
| 		span.button-label-big Refresh | ||||
| 	 | ||||
| 	p.dont-worry Don't worry, it's (probably) not your fault. | ||||
|  | ||||
| 	p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. | ||||
|  | ||||
| 	div#errors | ||||
| 		code. | ||||
| 			ERROR CODE: #{code} | ||||
| 			ERROR ID: #{id} | ||||
|  | ||||
| 	p You may also try the following options: | ||||
|  | ||||
| 	p Update your os and browser. | ||||
| 	p Disable an adblocker. | ||||
|  | ||||
| 	a(href="/flush") | ||||
| 		button.button-small | ||||
| 			span.button-label-small Clear preferences and cache | ||||
| 	br | ||||
| 	a(href="/cli") | ||||
| 		button.button-small | ||||
| 			span.button-label-small Start the simple client | ||||
| 	br | ||||
| 	a(href="/bios") | ||||
| 		button.button-small | ||||
| 			span.button-label-small Start the repair tool | ||||
							
								
								
									
										868
									
								
								packages/backend/test/e2e/users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										868
									
								
								packages/backend/test/e2e/users.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,868 @@ | ||||
| process.env.NODE_ENV = 'test'; | ||||
|  | ||||
| import * as assert from 'assert'; | ||||
| import { inspect } from 'node:util'; | ||||
| import { DEFAULT_POLICIES } from '@/core/RoleService.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import {  | ||||
| 	signup,  | ||||
| 	post,  | ||||
| 	page, | ||||
| 	role, | ||||
| 	startServer,  | ||||
| 	api, | ||||
| 	successfulApiCall,  | ||||
| 	failedApiCall, | ||||
| 	uploadFile, | ||||
| } from '../utils.js'; | ||||
| import type * as misskey from 'misskey-js'; | ||||
| import type { INestApplicationContext } from '@nestjs/common'; | ||||
|  | ||||
| describe('ユーザー', () => { | ||||
| 	// エンティティとしてのユーザーを主眼においたテストを記述する | ||||
| 	// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) | ||||
|  | ||||
| 	const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => { | ||||
| 		return Object.entries({ ...orig }) | ||||
| 			.filter(([, value]) => value !== undefined) | ||||
| 			.reduce((obj: Partial<T>, [key, value]) => { | ||||
| 				obj[key as keyof T] = value; | ||||
| 				return obj; | ||||
| 			}, {}); | ||||
| 	}; | ||||
|  | ||||
| 	// FIXME: 足りないキーがたくさんある | ||||
| 	type UserLite = misskey.entities.UserLite & { | ||||
| 		badgeRoles: any[], | ||||
| 	}; | ||||
|  | ||||
| 	type UserDetailedNotMe = UserLite &  | ||||
| 	misskey.entities.UserDetailed & { | ||||
| 		roles: any[], | ||||
| 	}; | ||||
|  | ||||
| 	type MeDetailed = UserDetailedNotMe &  | ||||
| 		misskey.entities.MeDetailed & { | ||||
| 		showTimelineReplies: boolean, | ||||
| 		achievements: object[], | ||||
| 		loggedInDays: number, | ||||
| 		policies: object, | ||||
| 	}; | ||||
| 	 | ||||
| 	type User = MeDetailed & { token: string };	 | ||||
|  | ||||
| 	const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => { | ||||
| 		return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; | ||||
| 	}; | ||||
|  | ||||
| 	const userLite = (user: User): Partial<UserLite> => { | ||||
| 		return stripUndefined({ | ||||
| 			id: user.id, | ||||
| 			name: user.name, | ||||
| 			username: user.username, | ||||
| 			host: user.host, | ||||
| 			avatarUrl: user.avatarUrl, | ||||
| 			avatarBlurhash: user.avatarBlurhash, | ||||
| 			isBot: user.isBot, | ||||
| 			isCat: user.isCat, | ||||
| 			instance: user.instance, | ||||
| 			emojis: user.emojis, | ||||
| 			onlineStatus: user.onlineStatus, | ||||
| 			badgeRoles: user.badgeRoles, | ||||
|  | ||||
| 			// BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。 | ||||
| 			isAdmin: undefined, | ||||
| 			isModerator: undefined, | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => { | ||||
| 		return stripUndefined({ | ||||
| 			...userLite(user), | ||||
| 			url: user.url, | ||||
| 			uri: user.uri, | ||||
| 			movedToUri: user.movedToUri, | ||||
| 			alsoKnownAs: user.alsoKnownAs, | ||||
| 			createdAt: user.createdAt, | ||||
| 			updatedAt: user.updatedAt, | ||||
| 			lastFetchedAt: user.lastFetchedAt, | ||||
| 			bannerUrl: user.bannerUrl, | ||||
| 			bannerBlurhash: user.bannerBlurhash, | ||||
| 			isLocked: user.isLocked, | ||||
| 			isSilenced: user.isSilenced, | ||||
| 			isSuspended: user.isSuspended, | ||||
| 			description: user.description, | ||||
| 			location: user.location, | ||||
| 			birthday: user.birthday, | ||||
| 			lang: user.lang, | ||||
| 			fields: user.fields, | ||||
| 			followersCount: user.followersCount, | ||||
| 			followingCount: user.followingCount, | ||||
| 			notesCount: user.notesCount, | ||||
| 			pinnedNoteIds: user.pinnedNoteIds, | ||||
| 			pinnedNotes: user.pinnedNotes, | ||||
| 			pinnedPageId: user.pinnedPageId, | ||||
| 			pinnedPage: user.pinnedPage, | ||||
| 			publicReactions: user.publicReactions, | ||||
| 			ffVisibility: user.ffVisibility, | ||||
| 			twoFactorEnabled: user.twoFactorEnabled, | ||||
| 			usePasswordLessLogin: user.usePasswordLessLogin, | ||||
| 			securityKeys: user.securityKeys, | ||||
| 			roles: user.roles, | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => { | ||||
| 		return stripUndefined({ | ||||
| 			...userDetailedNotMe(user), | ||||
| 			isFollowing: user.isFollowing ?? false, | ||||
| 			isFollowed: user.isFollowed ?? false, | ||||
| 			hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false, | ||||
| 			hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false, | ||||
| 			isBlocking: user.isBlocking ?? false, | ||||
| 			isBlocked: user.isBlocked ?? false, | ||||
| 			isMuted: user.isMuted ?? false, | ||||
| 			isRenoteMuted: user.isRenoteMuted ?? false, | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const meDetailed = (user: User, security = false): Partial<MeDetailed> => { | ||||
| 		return stripUndefined({ | ||||
| 			...userDetailedNotMe(user), | ||||
| 			avatarId: user.avatarId, | ||||
| 			bannerId: user.bannerId, | ||||
| 			isModerator: user.isModerator, | ||||
| 			isAdmin: user.isAdmin, | ||||
| 			injectFeaturedNote: user.injectFeaturedNote, | ||||
| 			receiveAnnouncementEmail: user.receiveAnnouncementEmail, | ||||
| 			alwaysMarkNsfw: user.alwaysMarkNsfw, | ||||
| 			autoSensitive: user.autoSensitive, | ||||
| 			carefulBot: user.carefulBot, | ||||
| 			autoAcceptFollowed: user.autoAcceptFollowed, | ||||
| 			noCrawle: user.noCrawle, | ||||
| 			isExplorable: user.isExplorable, | ||||
| 			isDeleted: user.isDeleted, | ||||
| 			hideOnlineStatus: user.hideOnlineStatus, | ||||
| 			hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, | ||||
| 			hasUnreadMentions: user.hasUnreadMentions, | ||||
| 			hasUnreadAnnouncement: user.hasUnreadAnnouncement, | ||||
| 			hasUnreadAntenna: user.hasUnreadAntenna, | ||||
| 			hasUnreadChannel: user.hasUnreadChannel, | ||||
| 			hasUnreadNotification: user.hasUnreadNotification, | ||||
| 			hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, | ||||
| 			mutedWords: user.mutedWords, | ||||
| 			mutedInstances: user.mutedInstances, | ||||
| 			mutingNotificationTypes: user.mutingNotificationTypes, | ||||
| 			emailNotificationTypes: user.emailNotificationTypes, | ||||
| 			showTimelineReplies: user.showTimelineReplies, | ||||
| 			achievements: user.achievements,  | ||||
| 			loggedInDays: user.loggedInDays, | ||||
| 			policies: user.policies, | ||||
| 			...(security ? { | ||||
| 				email: user.email, | ||||
| 				emailVerified: user.emailVerified, | ||||
| 				securityKeysList: user.securityKeysList, | ||||
| 			} : {}), | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	let app: INestApplicationContext; | ||||
|  | ||||
| 	let root: User; | ||||
| 	let alice: User; | ||||
| 	let aliceNote: misskey.entities.Note; | ||||
| 	let alicePage: misskey.entities.Page; | ||||
| 	let aliceList: misskey.entities.UserList; | ||||
|  | ||||
| 	let bob: User; | ||||
| 	let bobNote: misskey.entities.Note; | ||||
|  | ||||
| 	let carol: User; | ||||
| 	let dave: User; | ||||
| 	let ellen: User; | ||||
| 	let frank: User; | ||||
|  | ||||
| 	let usersReplying: User[]; | ||||
|  | ||||
| 	let userNoNote: User; | ||||
| 	let userNotExplorable: User; | ||||
| 	let userLocking: User; | ||||
| 	let userAdmin: User; | ||||
| 	let roleAdmin: any; | ||||
| 	let userModerator: User; | ||||
| 	let roleModerator: any; | ||||
| 	let userRolePublic: User; | ||||
| 	let rolePublic: any; | ||||
| 	let userRoleBadge: User; | ||||
| 	let roleBadge: any; | ||||
| 	let userSilenced: User; | ||||
| 	let roleSilenced: any; | ||||
| 	let userSuspended: User; | ||||
| 	let userDeletedBySelf: User; | ||||
| 	let userDeletedByAdmin: User; | ||||
| 	let userFollowingAlice: User; | ||||
| 	let userFollowedByAlice: User; | ||||
| 	let userBlockingAlice: User; | ||||
| 	let userBlockedByAlice: User; | ||||
| 	let userMutingAlice: User; | ||||
| 	let userMutedByAlice: User; | ||||
| 	let userRnMutingAlice: User; | ||||
| 	let userRnMutedByAlice: User; | ||||
| 	let userFollowRequesting: User; | ||||
| 	let userFollowRequested: User; | ||||
|  | ||||
| 	beforeAll(async () => { | ||||
| 		app = await startServer(); | ||||
| 	}, 1000 * 60 * 2); | ||||
|  | ||||
| 	beforeAll(async () => { | ||||
| 		root = await signup({ username: 'alice' }); | ||||
| 		alice = root; | ||||
| 		aliceNote = await post(alice, { text: 'test' }) as any;  | ||||
| 		alicePage = await page(alice); | ||||
| 		aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; | ||||
| 		bob = await signup({ username: 'bob' }); | ||||
| 		bobNote = await post(bob, { text: 'test' }) as any;  | ||||
| 		carol = await signup({ username: 'carol' }); | ||||
| 		dave = await signup({ username: 'dave' }); | ||||
| 		ellen = await signup({ username: 'ellen' }); | ||||
| 		frank = await signup({ username: 'frank' }); | ||||
|  | ||||
| 		// @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする | ||||
| 		usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { | ||||
| 			const u = await signup({ username: `replying${i}` }); | ||||
| 			for (let j = 0; j < 10 - i; j++) { | ||||
| 				const p = await post(u, { text: `test${j}` });  | ||||
| 				await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); | ||||
| 			} | ||||
| 			 | ||||
| 			return (await acc).concat(u); | ||||
| 		}, Promise.resolve([] as User[])); | ||||
|  | ||||
| 		userNoNote = await signup({ username: 'userNoNote' }); | ||||
| 		userNotExplorable = await signup({ username: 'userNotExplorable' }); | ||||
| 		await post(userNotExplorable, { text: 'test' }); | ||||
| 		await api('i/update', { isExplorable: false }, userNotExplorable); | ||||
| 		userLocking = await signup({ username: 'userLocking' }); | ||||
| 		await post(userLocking, { text: 'test' }); | ||||
| 		await api('i/update', { isLocked: true }, userLocking); | ||||
| 		userAdmin = await signup({ username: 'userAdmin' }); | ||||
| 		roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' }); | ||||
| 		await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root); | ||||
| 		userModerator = await signup({ username: 'userModerator' }); | ||||
| 		roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' }); | ||||
| 		await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root); | ||||
| 		userRolePublic = await signup({ username: 'userRolePublic' }); | ||||
| 		rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); | ||||
| 		await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); | ||||
| 		userRoleBadge = await signup({ username: 'userRoleBadge' }); | ||||
| 		roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); | ||||
| 		await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); | ||||
| 		userSilenced = await signup({ username: 'userSilenced' }); | ||||
| 		await post(userSilenced, { text: 'test' }); | ||||
| 		roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); | ||||
| 		await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); | ||||
| 		userSuspended = await signup({ username: 'userSuspended' }); | ||||
| 		await post(userSuspended, { text: 'test' }); | ||||
| 		await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); | ||||
| 		await api('admin/suspend-user', { userId: userSuspended.id }, root); | ||||
| 		userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); | ||||
| 		await post(userDeletedBySelf, { text: 'test' }); | ||||
| 		await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); | ||||
| 		userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); | ||||
| 		await post(userDeletedByAdmin, { text: 'test' }); | ||||
| 		await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); | ||||
| 		userFollowingAlice = await signup({ username: 'userFollowingAlice' }); | ||||
| 		await post(userFollowingAlice, { text: 'test' }); | ||||
| 		await api('following/create', { userId: alice.id }, userFollowingAlice); | ||||
| 		userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); | ||||
| 		await post(userFollowedByAlice, { text: 'test' }); | ||||
| 		await api('following/create', { userId: userFollowedByAlice.id }, alice); | ||||
| 		userBlockingAlice = await signup({ username: 'userBlockingAlice' }); | ||||
| 		await post(userBlockingAlice, { text: 'test' }); | ||||
| 		await api('blocking/create', { userId: alice.id }, userBlockingAlice); | ||||
| 		userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); | ||||
| 		await post(userBlockedByAlice, { text: 'test' }); | ||||
| 		await api('blocking/create', { userId: userBlockedByAlice.id }, alice); | ||||
| 		userMutingAlice = await signup({ username: 'userMutingAlice' }); | ||||
| 		await post(userMutingAlice, { text: 'test' }); | ||||
| 		await api('mute/create', { userId: alice.id }, userMutingAlice); | ||||
| 		userMutedByAlice = await signup({ username: 'userMutedByAlice' }); | ||||
| 		await post(userMutedByAlice, { text: 'test' }); | ||||
| 		await api('mute/create', { userId: userMutedByAlice.id }, alice); | ||||
| 		userRnMutingAlice = await signup({ username: 'userRnMutingAlice' }); | ||||
| 		await post(userRnMutingAlice, { text: 'test' }); | ||||
| 		await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice); | ||||
| 		userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' }); | ||||
| 		await post(userRnMutedByAlice, { text: 'test' }); | ||||
| 		await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice); | ||||
| 		userFollowRequesting = await signup({ username: 'userFollowRequesting' }); | ||||
| 		await post(userFollowRequesting, { text: 'test' }); | ||||
| 		userFollowRequested = userLocking; | ||||
| 		await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); | ||||
| 	}, 1000 * 60 * 10); | ||||
|  | ||||
| 	afterAll(async () => { | ||||
| 		await app.close(); | ||||
| 	}); | ||||
|  | ||||
| 	beforeEach(async () => { | ||||
| 		alice = { | ||||
| 			...alice, | ||||
| 			...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any, | ||||
| 		}; | ||||
| 		aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice }); | ||||
| 	}); | ||||
|  | ||||
| 	//#region サインアップ(signup) | ||||
|  | ||||
| 	test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => { | ||||
| 		// SignupApiService.ts | ||||
| 		const response = await successfulApiCall({ | ||||
| 			endpoint: 'signup', | ||||
| 			parameters: { username: 'zoe', password: 'password' }, | ||||
| 			user: undefined, | ||||
| 		}) as unknown as User; // BUG MeDetailedに足りないキーがある | ||||
|  | ||||
| 		// signupの時はtokenが含まれる特別なMeDetailedが返ってくる | ||||
| 		assert.match(response.token, /[a-zA-Z0-9]{16}/); | ||||
|  | ||||
| 		// UserLite | ||||
| 		assert.match(response.id, /[0-9a-z]{10}/); | ||||
| 		assert.strictEqual(response.name, null); | ||||
| 		assert.strictEqual(response.username, 'zoe'); | ||||
| 		assert.strictEqual(response.host, null); | ||||
| 		assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); | ||||
| 		assert.strictEqual(response.avatarBlurhash, null); | ||||
| 		assert.strictEqual(response.isBot, false); | ||||
| 		assert.strictEqual(response.isCat, false); | ||||
| 		assert.strictEqual(response.instance, undefined); | ||||
| 		assert.deepStrictEqual(response.emojis, {}); | ||||
| 		assert.strictEqual(response.onlineStatus, 'unknown'); | ||||
| 		assert.deepStrictEqual(response.badgeRoles, []); | ||||
| 		// UserDetailedNotMeOnly | ||||
| 		assert.strictEqual(response.url, null); | ||||
| 		assert.strictEqual(response.uri, null); | ||||
| 		assert.strictEqual(response.movedToUri, null); | ||||
| 		assert.strictEqual(response.alsoKnownAs, null); | ||||
| 		assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); | ||||
| 		assert.strictEqual(response.updatedAt, null); | ||||
| 		assert.strictEqual(response.lastFetchedAt, null); | ||||
| 		assert.strictEqual(response.bannerUrl, null); | ||||
| 		assert.strictEqual(response.bannerBlurhash, null); | ||||
| 		assert.strictEqual(response.isLocked, false); | ||||
| 		assert.strictEqual(response.isSilenced, false); | ||||
| 		assert.strictEqual(response.isSuspended, false); | ||||
| 		assert.strictEqual(response.description, null); | ||||
| 		assert.strictEqual(response.location, null); | ||||
| 		assert.strictEqual(response.birthday, null); | ||||
| 		assert.strictEqual(response.lang, null); | ||||
| 		assert.deepStrictEqual(response.fields, []); | ||||
| 		assert.strictEqual(response.followersCount, 0); | ||||
| 		assert.strictEqual(response.followingCount, 0); | ||||
| 		assert.strictEqual(response.notesCount, 0); | ||||
| 		assert.deepStrictEqual(response.pinnedNoteIds, []); | ||||
| 		assert.deepStrictEqual(response.pinnedNotes, []); | ||||
| 		assert.strictEqual(response.pinnedPageId, null); | ||||
| 		assert.strictEqual(response.pinnedPage, null); | ||||
| 		assert.strictEqual(response.publicReactions, false); | ||||
| 		assert.strictEqual(response.ffVisibility, 'public'); | ||||
| 		assert.strictEqual(response.twoFactorEnabled, false); | ||||
| 		assert.strictEqual(response.usePasswordLessLogin, false); | ||||
| 		assert.strictEqual(response.securityKeys, false); | ||||
| 		assert.deepStrictEqual(response.roles, []); | ||||
| 		 | ||||
| 		// MeDetailedOnly | ||||
| 		assert.strictEqual(response.avatarId, null); | ||||
| 		assert.strictEqual(response.bannerId, null); | ||||
| 		assert.strictEqual(response.isModerator, false); | ||||
| 		assert.strictEqual(response.isAdmin, false); | ||||
| 		assert.strictEqual(response.injectFeaturedNote, true); | ||||
| 		assert.strictEqual(response.receiveAnnouncementEmail, true); | ||||
| 		assert.strictEqual(response.alwaysMarkNsfw, false); | ||||
| 		assert.strictEqual(response.autoSensitive, false); | ||||
| 		assert.strictEqual(response.carefulBot, false); | ||||
| 		assert.strictEqual(response.autoAcceptFollowed, true); | ||||
| 		assert.strictEqual(response.noCrawle, false); | ||||
| 		assert.strictEqual(response.isExplorable, true); | ||||
| 		assert.strictEqual(response.isDeleted, false); | ||||
| 		assert.strictEqual(response.hideOnlineStatus, false); | ||||
| 		assert.strictEqual(response.hasUnreadSpecifiedNotes, false); | ||||
| 		assert.strictEqual(response.hasUnreadMentions, false); | ||||
| 		assert.strictEqual(response.hasUnreadAnnouncement, false); | ||||
| 		assert.strictEqual(response.hasUnreadAntenna, false); | ||||
| 		assert.strictEqual(response.hasUnreadChannel, false); | ||||
| 		assert.strictEqual(response.hasUnreadNotification, false); | ||||
| 		assert.strictEqual(response.hasPendingReceivedFollowRequest, false); | ||||
| 		assert.deepStrictEqual(response.mutedWords, []); | ||||
| 		assert.deepStrictEqual(response.mutedInstances, []); | ||||
| 		assert.deepStrictEqual(response.mutingNotificationTypes, []); | ||||
| 		assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); | ||||
| 		assert.strictEqual(response.showTimelineReplies, false); | ||||
| 		assert.deepStrictEqual(response.achievements, []); | ||||
| 		assert.deepStrictEqual(response.loggedInDays, 0); | ||||
| 		assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);  | ||||
| 		assert.notStrictEqual(response.email, undefined); | ||||
| 		assert.strictEqual(response.emailVerified, false); | ||||
| 		assert.deepStrictEqual(response.securityKeysList, []); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region 自分の情報(i) | ||||
|  | ||||
| 	test('を読み取ることができる。(自分)', async () => { | ||||
| 		const response = await successfulApiCall({ | ||||
| 			endpoint: 'i', | ||||
| 			parameters: {}, | ||||
| 			user: userNoNote, | ||||
| 		}); | ||||
| 		const expected = meDetailed(userNoNote, true); | ||||
| 		expected.loggedInDays = 1; // iはloggedInDaysを更新する | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region 自分の情報の更新(i/update) | ||||
|  | ||||
| 	test.each([ | ||||
| 		{ parameters: (): object => ({ name: null }) }, | ||||
| 		{ parameters: (): object => ({ name: 'x'.repeat(50) }) }, | ||||
| 		{ parameters: (): object => ({ name: 'x' }) }, | ||||
| 		{ parameters: (): object => ({ name: 'My name' }) }, | ||||
| 		{ parameters: (): object => ({ description: null }) }, | ||||
| 		{ parameters: (): object => ({ description: 'x'.repeat(1500) }) }, | ||||
| 		{ parameters: (): object => ({ description: 'x' }) }, | ||||
| 		{ parameters: (): object => ({ description: 'My description' }) }, | ||||
| 		{ parameters: (): object => ({ location: null }) }, | ||||
| 		{ parameters: (): object => ({ location: 'x'.repeat(50) }) }, | ||||
| 		{ parameters: (): object => ({ location: 'x' }) }, | ||||
| 		{ parameters: (): object => ({ location: 'My location' }) }, | ||||
| 		{ parameters: (): object => ({ birthday: '0000-00-00' }) }, | ||||
| 		{ parameters: (): object => ({ birthday: '9999-99-99' }) }, | ||||
| 		{ parameters: (): object => ({ lang: 'en-US' }) }, | ||||
| 		{ parameters: (): object => ({ fields: [] }) }, | ||||
| 		{ parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) }, | ||||
| 		{ parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない | ||||
| 		{ parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) }, | ||||
| 		{ parameters: (): object => ({ isLocked: true }) }, | ||||
| 		{ parameters: (): object => ({ isLocked: false }) }, | ||||
| 		{ parameters: (): object => ({ isExplorable: false }) }, | ||||
| 		{ parameters: (): object => ({ isExplorable: true }) }, | ||||
| 		{ parameters: (): object => ({ hideOnlineStatus: true }) }, | ||||
| 		{ parameters: (): object => ({ hideOnlineStatus: false }) }, | ||||
| 		{ parameters: (): object => ({ publicReactions: false }) }, | ||||
| 		{ parameters: (): object => ({ publicReactions: true }) }, | ||||
| 		{ parameters: (): object => ({ autoAcceptFollowed: true }) }, | ||||
| 		{ parameters: (): object => ({ autoAcceptFollowed: false }) }, | ||||
| 		{ parameters: (): object => ({ noCrawle: true }) }, | ||||
| 		{ parameters: (): object => ({ noCrawle: false }) }, | ||||
| 		{ parameters: (): object => ({ isBot: true }) }, | ||||
| 		{ parameters: (): object => ({ isBot: false }) }, | ||||
| 		{ parameters: (): object => ({ isCat: true }) }, | ||||
| 		{ parameters: (): object => ({ isCat: false }) }, | ||||
| 		{ parameters: (): object => ({ showTimelineReplies: true }) }, | ||||
| 		{ parameters: (): object => ({ showTimelineReplies: false }) }, | ||||
| 		{ parameters: (): object => ({ injectFeaturedNote: true }) }, | ||||
| 		{ parameters: (): object => ({ injectFeaturedNote: false }) }, | ||||
| 		{ parameters: (): object => ({ receiveAnnouncementEmail: true }) }, | ||||
| 		{ parameters: (): object => ({ receiveAnnouncementEmail: false }) }, | ||||
| 		{ parameters: (): object => ({ alwaysMarkNsfw: true }) }, | ||||
| 		{ parameters: (): object => ({ alwaysMarkNsfw: false }) }, | ||||
| 		{ parameters: (): object => ({ autoSensitive: true }) }, | ||||
| 		{ parameters: (): object => ({ autoSensitive: false }) }, | ||||
| 		{ parameters: (): object => ({ ffVisibility: 'private' }) }, | ||||
| 		{ parameters: (): object => ({ ffVisibility: 'followers' }) }, | ||||
| 		{ parameters: (): object => ({ ffVisibility: 'public' }) }, | ||||
| 		{ parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) }, | ||||
| 		{ parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) }, | ||||
| 		{ parameters: (): object => ({ mutedWords: [] }) }, | ||||
| 		{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, | ||||
| 		{ parameters: (): object => ({ mutedInstances: [] }) }, | ||||
| 		{ parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, | ||||
| 		{ parameters: (): object => ({ mutingNotificationTypes: [] }) }, | ||||
| 		{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, | ||||
| 		{ parameters: (): object => ({ emailNotificationTypes: [] }) }, | ||||
| 	] as const)('を書き換えることができる($#)', async ({ parameters }) => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice }); | ||||
| 		const expected = { ...meDetailed(alice, true), ...parameters() }; | ||||
| 		assert.deepStrictEqual(response, expected, inspect(parameters())); | ||||
| 	}); | ||||
|  | ||||
| 	test('を書き換えることができる(Avatar)', async () => { | ||||
| 		const aliceFile = (await uploadFile(alice)).body; | ||||
| 		const parameters = { avatarId: aliceFile.id }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); | ||||
| 		assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); | ||||
| 		assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); | ||||
| 		const expected = {  | ||||
| 			...meDetailed(alice, true),  | ||||
| 			avatarId: aliceFile.id, | ||||
| 			avatarBlurhash: response.avatarBlurhash, | ||||
| 			avatarUrl: response.avatarUrl, | ||||
| 		}; | ||||
| 		assert.deepStrictEqual(response, expected, inspect(parameters)); | ||||
|  | ||||
| 		if (1) return; // BUG 521eb95 以降アバターのリセットができない。 | ||||
| 		const parameters2 = { avatarId: null }; | ||||
| 		const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); | ||||
| 		const expected2 = {  | ||||
| 			...meDetailed(alice, true),  | ||||
| 			avatarId: null, | ||||
| 			avatarBlurhash: null, | ||||
| 			avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる | ||||
| 		}; | ||||
| 		assert.deepStrictEqual(response2, expected2, inspect(parameters)); | ||||
| 	}); | ||||
|  | ||||
| 	test('を書き換えることができる(Banner)', async () => { | ||||
| 		const aliceFile = (await uploadFile(alice)).body; | ||||
| 		const parameters = { bannerId: aliceFile.id }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); | ||||
| 		assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); | ||||
| 		assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); | ||||
| 		const expected = {  | ||||
| 			...meDetailed(alice, true),  | ||||
| 			bannerId: aliceFile.id, | ||||
| 			bannerBlurhash: response.bannerBlurhash, | ||||
| 			bannerUrl: response.bannerUrl, | ||||
| 		}; | ||||
| 		assert.deepStrictEqual(response, expected, inspect(parameters)); | ||||
|  | ||||
| 		if (1) return; // BUG 521eb95 以降バナーのリセットができない。 | ||||
| 		const parameters2 = { bannerId: null }; | ||||
| 		const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); | ||||
| 		const expected2 = {  | ||||
| 			...meDetailed(alice, true),  | ||||
| 			bannerId: null, | ||||
| 			bannerBlurhash: null, | ||||
| 			bannerUrl: null, | ||||
| 		}; | ||||
| 		assert.deepStrictEqual(response2, expected2, inspect(parameters)); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region 自分の情報の更新(i/pin, i/unpin) | ||||
|  | ||||
| 	test('を書き換えることができる(ピン止めノート)', async () => { | ||||
| 		const parameters = { noteId: aliceNote.id }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); | ||||
| 		const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 		 | ||||
| 		const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); | ||||
| 		const expected2 = meDetailed(alice, false); | ||||
| 		assert.deepStrictEqual(response2, expected2); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region ユーザー(users) | ||||
|  | ||||
| 	test.each([ | ||||
| 		{ label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id }, | ||||
| 		{ label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, | ||||
| 		{ label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, | ||||
| 		{ label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, | ||||
| 		{ label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, | ||||
| 		{ label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, | ||||
| 		{ label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, | ||||
| 	] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); | ||||
|  | ||||
| 		// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する | ||||
| 		const users = await Promise.all(response.map(u => show(u.id))); | ||||
| 		const expected = users.sort((x, y) => { | ||||
| 			const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; | ||||
| 			return index * (parameters.sort?.startsWith('+') ? -1 : 1); | ||||
| 		}); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true }, | ||||
| 		{ label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, | ||||
| 	] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { | ||||
| 		const parameters = { limit: 100 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); | ||||
| 		const expected = (excluded ?? false) ? [] : [await show(user().id)]; | ||||
| 		assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); | ||||
| 	}); | ||||
| 	test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); | ||||
| 	test.todo('をリスト形式で取得することができる(pagenation)'); | ||||
| 	 | ||||
| 	//#endregion | ||||
| 	//#region ユーザー情報(users/show) | ||||
|  | ||||
| 	test.each([ | ||||
| 		{ label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed }, | ||||
| 		{ label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations }, | ||||
| 		{ label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe }, | ||||
| 		{ label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed }, | ||||
| 		{ label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations }, | ||||
| 		{ label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe }, | ||||
| 	] as const)('を取得することができる($label)', async ({ parameters, user, type }) => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() }); | ||||
| 		const expected = type(alice); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin }, | ||||
| 		{ label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined }, | ||||
| 		{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, | ||||
| 		{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, | ||||
| 		{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, | ||||
| 		{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, | ||||
| 		{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, | ||||
| 		{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, | ||||
| 		{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, | ||||
| 		{ label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, | ||||
| 		{ label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing }, | ||||
| 		{ label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed }, | ||||
| 		{ label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking }, | ||||
| 		{ label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked }, | ||||
| 		{ label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted }, | ||||
| 		{ label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted }, | ||||
| 		{ label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou }, | ||||
| 		{ label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou }, | ||||
| 	] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice }); | ||||
| 		assert.strictEqual(selector(response), (expected ?? ((): true => true))()); | ||||
| 	}); | ||||
| 	test('を取得することができ、Publicなロールがセットされていること', async () => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice }); | ||||
| 		assert.deepStrictEqual(response.badgeRoles, []); | ||||
| 		assert.deepStrictEqual(response.roles, [{ | ||||
| 			id: rolePublic.id, | ||||
| 			name: rolePublic.name, | ||||
| 			color: rolePublic.color, | ||||
| 			iconUrl: rolePublic.iconUrl, | ||||
| 			description: rolePublic.description, | ||||
| 			isModerator: rolePublic.isModerator, | ||||
| 			isAdministrator: rolePublic.isAdministrator, | ||||
| 			displayOrder: rolePublic.displayOrder, | ||||
| 		}]); | ||||
| 	}); | ||||
| 	test('を取得することができ、バッヂロールがセットされていること', async () => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); | ||||
| 		assert.deepStrictEqual(response.badgeRoles, [{ | ||||
| 			name: roleBadge.name, | ||||
| 			iconUrl: roleBadge.iconUrl, | ||||
| 			displayOrder: roleBadge.displayOrder, | ||||
| 		}]); | ||||
| 		assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない | ||||
| 	}); | ||||
| 	test('をID指定のリスト形式で取得することができる(空)', async () => { | ||||
| 		const parameters = { userIds: [] }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); | ||||
| 		const expected: [] = []; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test('をID指定のリスト形式で取得することができる', async() => { | ||||
| 		const parameters = { userIds: [bob.id, alice.id, carol.id] }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); | ||||
| 		const expected = [ | ||||
| 			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),  | ||||
| 			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),  | ||||
| 			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),  | ||||
| 		]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, | ||||
| 		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root }, | ||||
| 		// BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる | ||||
| 		//{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },	 | ||||
| 	] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { | ||||
| 		const parameters = { userIds: [user().id] }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); | ||||
| 		const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.todo('をID指定のリスト形式で取得することができる(リモート)'); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region 検索(users/search) | ||||
|  | ||||
| 	test('を検索することができる', async () => { | ||||
| 		const parameters = { query: 'carol', limit: 10 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); | ||||
| 		const expected = [await show(carol.id)]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test('を検索することができる(UserLite)', async () => { | ||||
| 		const parameters = { query: 'carol', detail: false, limit: 10 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); | ||||
| 		const expected = [userLite(await show(carol.id))]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, | ||||
| 		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },	 | ||||
| 	] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { | ||||
| 		const parameters = { query: user().username, limit: 1 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); | ||||
| 		const expected = (excluded ?? false) ? [] : [await show(user().id)]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.todo('を検索することができる(リモート)'); | ||||
| 	test.todo('を検索することができる(pagenation)'); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region ID指定検索(users/search-by-username-and-host) | ||||
|  | ||||
| 	test.each([  | ||||
| 		{ label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, | ||||
| 		{ label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, | ||||
| 		{ label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, | ||||
| 		{ label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] }, | ||||
| 		{ label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] }, | ||||
| 		{ label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] }, | ||||
| 		{ label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] }, | ||||
| 		{ label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] }, | ||||
| 		{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, | ||||
| 	])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); | ||||
| 		const expected = await Promise.all(user().map(u => show(u.id))); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, | ||||
| 		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, | ||||
| 	] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { | ||||
| 		const parameters = { username: user().username }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); | ||||
| 		const expected = (excluded ?? false) ? [] : [await show(user().id)]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.todo('をID&ホスト指定で検索できる(リモート)'); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region ID指定検索(users/get-frequently-replied-users) | ||||
|  | ||||
| 	test('がよくリプライをするユーザーのリストを取得できる', async () => { | ||||
| 		const parameters = { userId: alice.id, limit: 5 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); | ||||
| 		const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({  | ||||
| 			user: await show(s.id), | ||||
| 			weight: (usersReplying.length - i) / usersReplying.length, | ||||
| 		}))); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, | ||||
| 		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, | ||||
| 	] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { | ||||
| 		const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0]; | ||||
| 		await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); | ||||
| 		const parameters = { userId: alice.id, limit: 100 }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); | ||||
| 		const expected = (excluded ?? false) ? [] : [await show(user().id)]; | ||||
| 		assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region ハッシュタグ(hashtags/users) | ||||
|  | ||||
| 	test.each([ | ||||
| 		{ label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, | ||||
| 		{ label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, | ||||
| 		{ label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, | ||||
| 		{ label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, | ||||
| 		{ label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, | ||||
| 		{ label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, | ||||
| 	] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => { | ||||
| 		const hashtag = 'test_hashtag'; | ||||
| 		await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); | ||||
| 		const parameters = { tag: hashtag, limit: 5, ...sort }; | ||||
| 		const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); | ||||
| 		const users = await Promise.all(response.map(u => show(u.id))); | ||||
| 		const expected = users.sort((x, y) => { | ||||
| 			const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; | ||||
| 			return index * (parameters.sort.startsWith('+') ? -1 : 1); | ||||
| 		}); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.each([ | ||||
| 		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, | ||||
| 		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, | ||||
| 		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, | ||||
| 		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, | ||||
| 		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking }, | ||||
| 		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, | ||||
| 		{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, | ||||
| 		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, | ||||
| 		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, | ||||
| 	] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => { | ||||
| 		const hashtag = `user_test${user().username}`; | ||||
| 		if (user() !== userSuspended) { | ||||
| 			// サスペンドユーザーはupdateできない。 | ||||
| 			await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() }); | ||||
| 		} | ||||
| 		const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; | ||||
| 		const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); | ||||
| 		const expected = [await show(user().id)]; | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
| 	test.todo('をハッシュタグ指定で取得することができる(リモート)'); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region オススメユーザー(users/recommendation) | ||||
|  | ||||
| 	// BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note" | ||||
| 	test.skip('のオススメを取得することができる', async () => { | ||||
| 		const parameters = {}; | ||||
| 		const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice }); | ||||
| 		const expected = await Promise.all(response.map(u => show(u.id))); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
| 	//#region ピン止めユーザー(pinned-users) | ||||
|  | ||||
| 	test('のピン止めユーザーを取得することができる', async () => { | ||||
| 		await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); | ||||
| 		const parameters = {} as const; | ||||
| 		const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); | ||||
| 		const expected = await Promise.all([bob, carol].map(u => show(u.id))); | ||||
| 		assert.deepStrictEqual(response, expected); | ||||
| 	}); | ||||
|  | ||||
| 	//#endregion | ||||
|  | ||||
| 	test.todo('を管理人として確認することができる(admin/show-user)'); | ||||
| 	test.todo('を管理人として確認することができる(admin/show-users)'); | ||||
| 	test.todo('をサーバー向けに取得することができる(federation/users)'); | ||||
| }); | ||||
| @@ -6,6 +6,7 @@ import WebSocket from 'ws'; | ||||
| import fetch, { Blob, File, RequestInit } from 'node-fetch'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { JSDOM } from 'jsdom'; | ||||
| import { DEFAULT_POLICIES } from '@/core/RoleService.js'; | ||||
| import { entities } from '../src/postgres.js'; | ||||
| import { loadConfig } from '../src/config.js'; | ||||
| import type * as misskey from 'misskey-js'; | ||||
| @@ -31,12 +32,12 @@ export type ApiRequest = { | ||||
| }; | ||||
|  | ||||
| export const successfulApiCall = async <T, >(request: ApiRequest, assertion: { | ||||
| 	status: number, | ||||
| } = { status: 200 }): Promise<T> => { | ||||
| 	status?: number, | ||||
| } = {}): Promise<T> => { | ||||
| 	const { endpoint, parameters, user } = request; | ||||
| 	const { status } = assertion; | ||||
| 	const res = await api(endpoint, parameters, user); | ||||
| 	assert.strictEqual(res.status, status, inspect(res.body)); | ||||
| 	const status = assertion.status ?? (res.body == null ? 204 : 200); | ||||
| 	assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); | ||||
| 	return res.body; | ||||
| }; | ||||
|  | ||||
| @@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => { | ||||
| 	return res.body; | ||||
| }; | ||||
|  | ||||
| export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => { | ||||
| 	const res = await api('admin/roles/create', { | ||||
| 		asBadge: false, | ||||
| 		canEditMembersByModerator: false, | ||||
| 		color: null, | ||||
| 		condFormula: { | ||||
| 			id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85', | ||||
| 			type: 'isRemote', | ||||
| 		}, | ||||
| 		description: '', | ||||
| 		displayOrder: 0, | ||||
| 		iconUrl: null, | ||||
| 		isAdministrator: false, | ||||
| 		isModerator: false, | ||||
| 		isPublic: false, | ||||
| 		name: 'New Role', | ||||
| 		target: 'manual', | ||||
| 		policies: {  | ||||
| 			...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {  | ||||
| 				priority: 0, | ||||
| 				useDefault: true, | ||||
| 				value: v, | ||||
| 			}]), | ||||
| 			...policies, | ||||
| 		}, | ||||
| 		...role, | ||||
| 	}, user); | ||||
| 	return res.body; | ||||
| }; | ||||
|  | ||||
| interface UploadOptions { | ||||
| 	/** Optional, absolute path or relative from ./resources/ */ | ||||
| 	path?: string | URL; | ||||
|   | ||||
| @@ -15,6 +15,7 @@ const props = defineProps<{ | ||||
| 	list?: string; | ||||
| 	antenna?: string; | ||||
| 	channel?: string; | ||||
| 	role?: string; | ||||
| 	sound?: boolean; | ||||
| }>(); | ||||
|  | ||||
| @@ -121,6 +122,15 @@ if (props.src === 'antenna') { | ||||
| 		channelId: props.channel, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'role') { | ||||
| 	endpoint = 'roles/notes'; | ||||
| 	query = { | ||||
| 		roleId: props.role, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('roleTimeline', { | ||||
| 		roleId: props.role, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } | ||||
|  | ||||
| const pagination = { | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<div class="lznhrdub"> | ||||
| 	<div> | ||||
| 		<div v-if="tab === 'featured'"> | ||||
| 			<XFeatured/> | ||||
| 		</div> | ||||
|   | ||||
| @@ -1,13 +1,16 @@ | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader/></template> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> | ||||
|  | ||||
| 	<MkSpacer :content-max="1200"> | ||||
| 	<MkSpacer v-if="tab === 'users'" :content-max="1200"> | ||||
| 		<div class="_gaps_s"> | ||||
| 			<div v-if="role">{{ role.description }}</div> | ||||
| 			<MkUserList :pagination="users" :extractor="(item) => item.user"/> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| 	<MkSpacer v-else-if="tab === 'timeline'" :content-max="700"> | ||||
| 		<MkTimeline ref="timeline" src="role" :role="props.role"/> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
|  | ||||
| @@ -16,11 +19,17 @@ import { computed, watch } from 'vue'; | ||||
| import * as os from '@/os'; | ||||
| import MkUserList from '@/components/MkUserList.vue'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	role: string; | ||||
| }>(); | ||||
| 	initialTab?: string; | ||||
| }>(), { | ||||
| 	initialTab: 'users', | ||||
| }); | ||||
|  | ||||
| let tab = $ref(props.initialTab); | ||||
| let role = $ref(); | ||||
|  | ||||
| watch(() => props.role, () => { | ||||
| @@ -39,6 +48,16 @@ const users = $computed(() => ({ | ||||
| 	}, | ||||
| })); | ||||
|  | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'users', | ||||
| 	icon: 'ti ti-users', | ||||
| 	title: i18n.ts.users, | ||||
| }, { | ||||
| 	key: 'timeline', | ||||
| 	icon: 'ti ti-pencil', | ||||
| 	title: i18n.ts.timeline, | ||||
| }]); | ||||
|  | ||||
| definePageMetadata(computed(() => ({ | ||||
| 	title: role?.name, | ||||
| 	icon: 'ti ti-badge', | ||||
|   | ||||
| @@ -152,6 +152,7 @@ const addColumn = async (ev) => { | ||||
| 		'channel', | ||||
| 		'mentions', | ||||
| 		'direct', | ||||
| 		'roleTimeline', | ||||
| 	]; | ||||
|  | ||||
| 	const { canceled, result: column } = await os.select({ | ||||
|   | ||||
| @@ -10,6 +10,7 @@ | ||||
| <XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> | ||||
| <XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> | ||||
| <XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> | ||||
| <XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| @@ -23,6 +24,7 @@ import XNotificationsColumn from './notifications-column.vue'; | ||||
| import XWidgetsColumn from './widgets-column.vue'; | ||||
| import XMentionsColumn from './mentions-column.vue'; | ||||
| import XDirectColumn from './direct-column.vue'; | ||||
| import XRoleTimelineColumn from './role-timeline-column.vue'; | ||||
| import { Column } from './deck-store'; | ||||
|  | ||||
| defineProps<{ | ||||
|   | ||||
| @@ -22,6 +22,7 @@ export type Column = { | ||||
| 	antennaId?: string; | ||||
| 	listId?: string; | ||||
| 	channelId?: string; | ||||
| 	roleId?: string; | ||||
| 	includingTypes?: typeof notificationTypes[number][]; | ||||
| 	tl?: 'home' | 'local' | 'social' | 'global'; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										67
									
								
								packages/frontend/src/ui/deck/role-timeline-column.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								packages/frontend/src/ui/deck/role-timeline-column.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| <template> | ||||
| <XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> | ||||
| 	<template #header> | ||||
| 		<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span> | ||||
| 	</template> | ||||
|  | ||||
| 	<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/> | ||||
| </XColumn> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted } from 'vue'; | ||||
| import XColumn from './column.vue'; | ||||
| import { updateColumn, Column } from './deck-store'; | ||||
| import MkTimeline from '@/components/MkTimeline.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	column: Column; | ||||
| 	isStacked: boolean; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'loaded'): void; | ||||
| 	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; | ||||
| }>(); | ||||
|  | ||||
| let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); | ||||
|  | ||||
| onMounted(() => { | ||||
| 	if (props.column.roleId == null) { | ||||
| 		setRole(); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| async function setRole() { | ||||
| 	const roles = await os.api('roles/list'); | ||||
| 	const { canceled, result: role } = await os.select({ | ||||
| 		title: i18n.ts.role, | ||||
| 		items: roles.map(x => ({ | ||||
| 			value: x, text: x.name, | ||||
| 		})), | ||||
| 		default: props.column.roleId, | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	updateColumn(props.column.id, { | ||||
| 		roleId: role.id, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const menu = [{ | ||||
| 	icon: 'ti ti-pencil', | ||||
| 	text: i18n.ts.role, | ||||
| 	action: setRole, | ||||
| }]; | ||||
|  | ||||
| /* | ||||
| function focus() { | ||||
| 	timeline.focus(); | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
| 	focus, | ||||
| }); | ||||
| */ | ||||
| </script> | ||||
		Reference in New Issue
	
	Block a user
	 tamaina
					tamaina