Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に (#14078)
* feat: implement role policy "canUpdateBioMedia" * docs(changelog): update changelog * docs(changelog): update changelog * chore: regenerate misskey-js type definitions * chore: Apply suggestion from code review Co-authored-by: anatawa12 <anatawa12@icloud.com> * chore: fix unnecessarily strict inequality check * chore: policies should be gotten only once --------- Co-authored-by: anatawa12 <anatawa12@icloud.com>
This commit is contained in:
		
							parent
							
								
									58c596cacf
								
							
						
					
					
						commit
						7afa593d11
					
				|  | @ -5,6 +5,8 @@ | ||||||
| 
 | 
 | ||||||
| ### General | ### General | ||||||
| - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 | - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 | ||||||
|  | - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に | ||||||
|  |   - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます | ||||||
| - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 | - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 | ||||||
| - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 | - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 | ||||||
| - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 | - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 | ||||||
|  |  | ||||||
|  | @ -6594,6 +6594,10 @@ export interface Locale extends ILocale { | ||||||
|              * ファイルにNSFWを常に付与 |              * ファイルにNSFWを常に付与 | ||||||
|              */ |              */ | ||||||
|             "alwaysMarkNsfw": string; |             "alwaysMarkNsfw": string; | ||||||
|  |             /** | ||||||
|  |              * アイコンとバナーの更新を許可 | ||||||
|  |              */ | ||||||
|  |             "canUpdateBioMedia": string; | ||||||
|             /** |             /** | ||||||
|              * ノートのピン留めの最大数 |              * ノートのピン留めの最大数 | ||||||
|              */ |              */ | ||||||
|  |  | ||||||
|  | @ -1705,6 +1705,7 @@ _role: | ||||||
|     canManageAvatarDecorations: "アバターデコレーションの管理" |     canManageAvatarDecorations: "アバターデコレーションの管理" | ||||||
|     driveCapacity: "ドライブ容量" |     driveCapacity: "ドライブ容量" | ||||||
|     alwaysMarkNsfw: "ファイルにNSFWを常に付与" |     alwaysMarkNsfw: "ファイルにNSFWを常に付与" | ||||||
|  |     canUpdateBioMedia: "アイコンとバナーの更新を許可" | ||||||
|     pinMax: "ノートのピン留めの最大数" |     pinMax: "ノートのピン留めの最大数" | ||||||
|     antennaMax: "アンテナの作成可能数" |     antennaMax: "アンテナの作成可能数" | ||||||
|     wordMuteMax: "ワードミュートの最大文字数" |     wordMuteMax: "ワードミュートの最大文字数" | ||||||
|  |  | ||||||
|  | @ -47,6 +47,7 @@ export type RolePolicies = { | ||||||
| 	canHideAds: boolean; | 	canHideAds: boolean; | ||||||
| 	driveCapacityMb: number; | 	driveCapacityMb: number; | ||||||
| 	alwaysMarkNsfw: boolean; | 	alwaysMarkNsfw: boolean; | ||||||
|  | 	canUpdateBioMedia: boolean; | ||||||
| 	pinLimit: number; | 	pinLimit: number; | ||||||
| 	antennaLimit: number; | 	antennaLimit: number; | ||||||
| 	wordMuteLimit: number; | 	wordMuteLimit: number; | ||||||
|  | @ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = { | ||||||
| 	canHideAds: false, | 	canHideAds: false, | ||||||
| 	driveCapacityMb: 100, | 	driveCapacityMb: 100, | ||||||
| 	alwaysMarkNsfw: false, | 	alwaysMarkNsfw: false, | ||||||
|  | 	canUpdateBioMedia: true, | ||||||
| 	pinLimit: 5, | 	pinLimit: 5, | ||||||
| 	antennaLimit: 5, | 	antennaLimit: 5, | ||||||
| 	wordMuteLimit: 200, | 	wordMuteLimit: 200, | ||||||
|  | @ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { | ||||||
| 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), | 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), | ||||||
| 			driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), | 			driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), | ||||||
| 			alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), | 			alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), | ||||||
|  | 			canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), | ||||||
| 			pinLimit: calc('pinLimit', vs => Math.max(...vs)), | 			pinLimit: calc('pinLimit', vs => Math.max(...vs)), | ||||||
| 			antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), | 			antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), | ||||||
| 			wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), | 			wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), | ||||||
|  |  | ||||||
|  | @ -34,6 +34,7 @@ import { StatusError } from '@/misc/status-error.js'; | ||||||
| import type { UtilityService } from '@/core/UtilityService.js'; | import type { UtilityService } from '@/core/UtilityService.js'; | ||||||
| import type { UserEntityService } from '@/core/entities/UserEntityService.js'; | import type { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||||
| import { bindThis } from '@/decorators.js'; | import { bindThis } from '@/decorators.js'; | ||||||
|  | import { RoleService } from '@/core/RoleService.js'; | ||||||
| import { MetaService } from '@/core/MetaService.js'; | import { MetaService } from '@/core/MetaService.js'; | ||||||
| import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | ||||||
| import type { AccountMoveService } from '@/core/AccountMoveService.js'; | import type { AccountMoveService } from '@/core/AccountMoveService.js'; | ||||||
|  | @ -100,6 +101,8 @@ export class ApPersonService implements OnModuleInit { | ||||||
| 
 | 
 | ||||||
| 		@Inject(DI.followingsRepository) | 		@Inject(DI.followingsRepository) | ||||||
| 		private followingsRepository: FollowingsRepository, | 		private followingsRepository: FollowingsRepository, | ||||||
|  | 
 | ||||||
|  | 		private roleService: RoleService, | ||||||
| 	) { | 	) { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -238,6 +241,11 @@ export class ApPersonService implements OnModuleInit { | ||||||
| 			return this.apImageService.resolveImage(user, img).catch(() => null); | 			return this.apImageService.resolveImage(user, img).catch(() => null); | ||||||
| 		})); | 		})); | ||||||
| 
 | 
 | ||||||
|  | 		if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null)) | ||||||
|  | 				&& !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) { | ||||||
|  | 			return {}; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		/* | 		/* | ||||||
| 			we don't want to return nulls on errors! if the database fields | 			we don't want to return nulls on errors! if the database fields | ||||||
| 			are already null, nothing changes; if the database has old | 			are already null, nothing changes; if the database has old | ||||||
|  |  | ||||||
|  | @ -228,6 +228,10 @@ export const packedRolePoliciesSchema = { | ||||||
| 			type: 'boolean', | 			type: 'boolean', | ||||||
| 			optional: false, nullable: false, | 			optional: false, nullable: false, | ||||||
| 		}, | 		}, | ||||||
|  | 		canUpdateBioMedia: { | ||||||
|  | 			type: 'boolean', | ||||||
|  | 			optional: false, nullable: false, | ||||||
|  | 		}, | ||||||
| 		pinLimit: { | 		pinLimit: { | ||||||
| 			type: 'integer', | 			type: 'integer', | ||||||
| 			optional: false, nullable: false, | 			optional: false, nullable: false, | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||||
| import { AccountUpdateService } from '@/core/AccountUpdateService.js'; | import { AccountUpdateService } from '@/core/AccountUpdateService.js'; | ||||||
| import { HashtagService } from '@/core/HashtagService.js'; | import { HashtagService } from '@/core/HashtagService.js'; | ||||||
| import { DI } from '@/di-symbols.js'; | import { DI } from '@/di-symbols.js'; | ||||||
| import { RoleService } from '@/core/RoleService.js'; | import { RolePolicies, RoleService } from '@/core/RoleService.js'; | ||||||
| import { CacheService } from '@/core/CacheService.js'; | import { CacheService } from '@/core/CacheService.js'; | ||||||
| import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; | import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; | ||||||
| import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; | ||||||
|  | @ -256,6 +256,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||||
| 			const profileUpdates = {} as Partial<MiUserProfile>; | 			const profileUpdates = {} as Partial<MiUserProfile>; | ||||||
| 
 | 
 | ||||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | ||||||
|  | 			let policies: RolePolicies | null = null; | ||||||
| 
 | 
 | ||||||
| 			if (ps.name !== undefined) { | 			if (ps.name !== undefined) { | ||||||
| 				if (ps.name === null) { | 				if (ps.name === null) { | ||||||
|  | @ -296,14 +297,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			if (ps.mutedWords !== undefined) { | 			if (ps.mutedWords !== undefined) { | ||||||
| 				checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
|  | 				checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit); | ||||||
| 				validateMuteWordRegex(ps.mutedWords); | 				validateMuteWordRegex(ps.mutedWords); | ||||||
| 
 | 
 | ||||||
| 				profileUpdates.mutedWords = ps.mutedWords; | 				profileUpdates.mutedWords = ps.mutedWords; | ||||||
| 				profileUpdates.enableWordMute = ps.mutedWords.length > 0; | 				profileUpdates.enableWordMute = ps.mutedWords.length > 0; | ||||||
| 			} | 			} | ||||||
| 			if (ps.hardMutedWords !== undefined) { | 			if (ps.hardMutedWords !== undefined) { | ||||||
| 				checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
|  | 				checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit); | ||||||
| 				validateMuteWordRegex(ps.hardMutedWords); | 				validateMuteWordRegex(ps.hardMutedWords); | ||||||
| 				profileUpdates.hardMutedWords = ps.hardMutedWords; | 				profileUpdates.hardMutedWords = ps.hardMutedWords; | ||||||
| 			} | 			} | ||||||
|  | @ -322,13 +325,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||||
| 			if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; | 			if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; | ||||||
| 			if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; | 			if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; | ||||||
| 			if (typeof ps.alwaysMarkNsfw === 'boolean') { | 			if (typeof ps.alwaysMarkNsfw === 'boolean') { | ||||||
| 				if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
|  | 				if (policies.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); | ||||||
| 				profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; | 				profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; | ||||||
| 			} | 			} | ||||||
| 			if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; | 			if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; | ||||||
| 			if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; | 			if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; | ||||||
| 
 | 
 | ||||||
| 			if (ps.avatarId) { | 			if (ps.avatarId) { | ||||||
|  | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
|  | 				if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); | ||||||
|  | 
 | ||||||
| 				const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); | 				const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); | ||||||
| 
 | 
 | ||||||
| 				if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); | 				if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); | ||||||
|  | @ -344,6 +351,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			if (ps.bannerId) { | 			if (ps.bannerId) { | ||||||
|  | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
|  | 				if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); | ||||||
|  | 
 | ||||||
| 				const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); | 				const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); | ||||||
| 
 | 
 | ||||||
| 				if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); | 				if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); | ||||||
|  | @ -359,14 +369,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			if (ps.avatarDecorations) { | 			if (ps.avatarDecorations) { | ||||||
|  | 				policies ??= await this.roleService.getUserPolicies(user.id); | ||||||
| 				const decorations = await this.avatarDecorationService.getAll(true); | 				const decorations = await this.avatarDecorationService.getAll(true); | ||||||
| 				const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); | 				const myRoles = await this.roleService.getUserRoles(user.id); | ||||||
| 				const allRoles = await this.roleService.getRoles(); | 				const allRoles = await this.roleService.getRoles(); | ||||||
| 				const decorationIds = decorations | 				const decorationIds = decorations | ||||||
| 					.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) | 					.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) | ||||||
| 					.map(d => d.id); | 					.map(d => d.id); | ||||||
| 
 | 
 | ||||||
| 				if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); | 				if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); | ||||||
| 
 | 
 | ||||||
| 				updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ | 				updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ | ||||||
| 					id: d.id, | 					id: d.id, | ||||||
|  |  | ||||||
|  | @ -87,6 +87,7 @@ export const ROLE_POLICIES = [ | ||||||
| 	'canHideAds', | 	'canHideAds', | ||||||
| 	'driveCapacityMb', | 	'driveCapacityMb', | ||||||
| 	'alwaysMarkNsfw', | 	'alwaysMarkNsfw', | ||||||
|  | 	'canUpdateBioMedia', | ||||||
| 	'pinLimit', | 	'pinLimit', | ||||||
| 	'antennaLimit', | 	'antennaLimit', | ||||||
| 	'wordMuteLimit', | 	'wordMuteLimit', | ||||||
|  |  | ||||||
|  | @ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 				</div> | 				</div> | ||||||
| 			</MkFolder> | 			</MkFolder> | ||||||
| 
 | 
 | ||||||
|  | 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> | ||||||
|  | 				<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> | ||||||
|  | 				<template #suffix> | ||||||
|  | 					<span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> | ||||||
|  | 					<span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span> | ||||||
|  | 					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span> | ||||||
|  | 				</template> | ||||||
|  | 				<div class="_gaps"> | ||||||
|  | 					<MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> | ||||||
|  | 						<template #label>{{ i18n.ts._role.useBaseValue }}</template> | ||||||
|  | 					</MkSwitch> | ||||||
|  | 					<MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> | ||||||
|  | 						<template #label>{{ i18n.ts.enable }}</template> | ||||||
|  | 					</MkSwitch> | ||||||
|  | 					<MkRange v-model="role.policies.canUpdateBioMedia.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> | ||||||
|  | 						<template #label>{{ i18n.ts._role.priority }}</template> | ||||||
|  | 					</MkRange> | ||||||
|  | 				</div> | ||||||
|  | 			</MkFolder> | ||||||
|  | 
 | ||||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> | 			<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> | ||||||
| 				<template #label>{{ i18n.ts._role._options.pinMax }}</template> | 				<template #label>{{ i18n.ts._role._options.pinMax }}</template> | ||||||
| 				<template #suffix> | 				<template #suffix> | ||||||
|  |  | ||||||
|  | @ -134,6 +134,14 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 							</MkSwitch> | 							</MkSwitch> | ||||||
| 						</MkFolder> | 						</MkFolder> | ||||||
| 
 | 
 | ||||||
|  | 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> | ||||||
|  | 							<template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> | ||||||
|  | 							<template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template> | ||||||
|  | 							<MkSwitch v-model="policies.canUpdateBioMedia"> | ||||||
|  | 								<template #label>{{ i18n.ts.enable }}</template> | ||||||
|  | 							</MkSwitch> | ||||||
|  | 						</MkFolder> | ||||||
|  | 
 | ||||||
| 						<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> | 						<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> | ||||||
| 							<template #label>{{ i18n.ts._role._options.pinMax }}</template> | 							<template #label>{{ i18n.ts._role._options.pinMax }}</template> | ||||||
| 							<template #suffix>{{ policies.pinLimit }}</template> | 							<template #suffix>{{ policies.pinLimit }}</template> | ||||||
|  |  | ||||||
|  | @ -4786,6 +4786,7 @@ export type components = { | ||||||
|       canHideAds: boolean; |       canHideAds: boolean; | ||||||
|       driveCapacityMb: number; |       driveCapacityMb: number; | ||||||
|       alwaysMarkNsfw: boolean; |       alwaysMarkNsfw: boolean; | ||||||
|  |       canUpdateBioMedia: boolean; | ||||||
|       pinLimit: number; |       pinLimit: number; | ||||||
|       antennaLimit: number; |       antennaLimit: number; | ||||||
|       wordMuteLimit: number; |       wordMuteLimit: number; | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue