enhance: 非ログイン時にはMisskey Hub経由で別サーバーに遷移できるように
This commit is contained in:
		
							parent
							
								
									7beb4ed131
								
							
						
					
					
						commit
						51d62a0a4f
					
				|  | @ -724,6 +724,10 @@ export interface Locale extends ILocale { | |||
|      * リモートで表示 | ||||
|      */ | ||||
|     "showOnRemote": string; | ||||
|     /** | ||||
|      * リモートで続行 | ||||
|      */ | ||||
|     "continueOnRemote": string; | ||||
|     /** | ||||
|      * 全般 | ||||
|      */ | ||||
|  | @ -1897,9 +1901,13 @@ export interface Locale extends ILocale { | |||
|      */ | ||||
|     "onlyOneFileCanBeAttached": string; | ||||
|     /** | ||||
|      * 続行する前に、サインアップまたはサインインが必要です | ||||
|      * 続行する前に、登録またはログインが必要です | ||||
|      */ | ||||
|     "signinRequired": string; | ||||
|     /** | ||||
|      * 続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります | ||||
|      */ | ||||
|     "signinOrContinueOnRemote": string; | ||||
|     /** | ||||
|      * 招待 | ||||
|      */ | ||||
|  |  | |||
|  | @ -177,6 +177,7 @@ addAccount: "アカウントを追加" | |||
| reloadAccountsList: "アカウントリストの情報を更新" | ||||
| loginFailed: "ログインに失敗しました" | ||||
| showOnRemote: "リモートで表示" | ||||
| continueOnRemote: "リモートで続行" | ||||
| general: "全般" | ||||
| wallpaper: "壁紙" | ||||
| setWallpaper: "壁紙を設定" | ||||
|  | @ -470,7 +471,8 @@ quoteQuestion: "引用として添付しますか?" | |||
| noMessagesYet: "まだチャットはありません" | ||||
| newMessageExists: "新しいメッセージがあります" | ||||
| onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" | ||||
| signinRequired: "続行する前に、サインアップまたはサインインが必要です" | ||||
| signinRequired: "続行する前に、登録またはログインが必要です" | ||||
| signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" | ||||
| invitations: "招待" | ||||
| invitationCode: "招待コード" | ||||
| checking: "確認しています" | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ import type { Config } from '@/config.js'; | |||
| import { getNoteSummary } from '@/misc/get-note-summary.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import * as Acct from '@/misc/acct.js'; | ||||
| import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
|  | @ -31,6 +32,11 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js'; | |||
| import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; | ||||
| import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; | ||||
| import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; | ||||
| import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; | ||||
| import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; | ||||
| import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; | ||||
| import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, ReversiGamesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; | ||||
| import type Logger from '@/logger.js'; | ||||
| import { deepClone } from '@/misc/clone.js'; | ||||
|  | @ -100,6 +106,11 @@ export class ClientServerService { | |||
| 		private feedService: FeedService, | ||||
| 		private roleService: RoleService, | ||||
| 		private clientLoggerService: ClientLoggerService, | ||||
| 		private apResolverService: ApResolverService, | ||||
| 		private apDbResolverService: ApDbResolverService, | ||||
| 		private apPersonService: ApPersonService, | ||||
| 		private apNoteService: ApNoteService, | ||||
| 		private utilityService: UtilityService, | ||||
| 
 | ||||
| 		@Inject('queue:system') public systemQueue: SystemQueue, | ||||
| 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, | ||||
|  | @ -745,6 +756,63 @@ export class ClientServerService { | |||
| 			return await reply.view('flush'); | ||||
| 		}); | ||||
| 
 | ||||
| 		// マストドン互換・照会してリダイレクトするエンドポイント
 | ||||
| 		fastify.get('/authorize_interaction', async (request, reply) => { | ||||
| 			const { uri } = request.query as { uri?: string; }; | ||||
| 
 | ||||
| 			if (!uri) { | ||||
| 				reply.redirect(302, '/'); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			// ブロックしてたら中断
 | ||||
| 			const fetchedMeta = await this.metaService.fetch(); | ||||
| 			if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) { | ||||
| 				reply.redirect(302, '/'); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			const [localUser, localNote] = await Promise.all([ | ||||
| 				this.apDbResolverService.getUserFromApId(uri), | ||||
| 				this.apDbResolverService.getNoteFromApId(uri), | ||||
| 			]); | ||||
| 
 | ||||
| 			if (localUser != null) { | ||||
| 				reply.redirect(302, `/@${localUser.username}${localUser.host == null ? '' : '@' + localUser.host}`); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			if (localNote != null) { | ||||
| 				reply.redirect(302, `/notes/${localNote.id}`); | ||||
| 				return; | ||||
| 			} | ||||
| 
 | ||||
| 			// リモートから一旦オブジェクトフェッチ
 | ||||
| 			const resolver = this.apResolverService.createResolver(); | ||||
| 			const object = await resolver.resolve(uri) as any; | ||||
| 
 | ||||
| 			// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
 | ||||
| 			// これはDBに存在する可能性があるため再度DB検索
 | ||||
| 			if (uri !== object.id) { | ||||
| 				const [remoteUser, remoteNote] = await Promise.all([ | ||||
| 					this.apDbResolverService.getUserFromApId(object.id), | ||||
| 					this.apDbResolverService.getNoteFromApId(object.id), | ||||
| 				]); | ||||
| 
 | ||||
| 				if (remoteUser != null) { | ||||
| 					reply.redirect(302, `/@${remoteUser.username}${remoteUser.host == null ? '' : '@' + remoteUser.host}`); | ||||
| 					return; | ||||
| 				} | ||||
| 
 | ||||
| 				if (remoteNote != null) { | ||||
| 					reply.redirect(302, `/notes/${remoteNote.id}`); | ||||
| 					return; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			reply.redirect(302, '/'); | ||||
| 		}); | ||||
| 
 | ||||
| 		// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる
 | ||||
| 		fastify.get('/streaming', async (request, reply) => { | ||||
| 			reply.code(503); | ||||
|  |  | |||
|  | @ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; | |||
| import { useStream } from '@/stream.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { claimAchievement } from '@/scripts/achievements.js'; | ||||
| import { pleaseLogin } from '@/scripts/please-login.js'; | ||||
| import { host } from '@/config.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| 
 | ||||
|  | @ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro | |||
| const wait = ref(false); | ||||
| const connection = useStream().useChannel('main'); | ||||
| 
 | ||||
| if (props.user.isFollowing == null) { | ||||
| if (props.user.isFollowing == null && $i) { | ||||
| 	misskeyApi('users/show', { | ||||
| 		userId: props.user.id, | ||||
| 	}) | ||||
|  | @ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { | |||
| } | ||||
| 
 | ||||
| async function onClick() { | ||||
| 	pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` }); | ||||
| 
 | ||||
| 	wait.value = true; | ||||
| 
 | ||||
| 	try { | ||||
|  |  | |||
|  | @ -6,10 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| <template> | ||||
| <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> | ||||
| 	<div class="_gaps_m"> | ||||
| 		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> | ||||
| 		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> | ||||
| 		<MkInfo v-if="message"> | ||||
| 			{{ message }} | ||||
| 		</MkInfo> | ||||
| 		<div v-if="misskeyHub" class="_gaps_m"> | ||||
| 			<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openMisskeyHub(misskeyHub)"> | ||||
| 				{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> | ||||
| 			</MkButton> | ||||
| 			<div :class="$style.orHr"> | ||||
| 				<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div v-if="!totpLogin" class="normal-signin _gaps_m"> | ||||
| 			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> | ||||
| 				<template #prefix>@</template> | ||||
|  | @ -28,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 					{{ i18n.ts.retry }} | ||||
| 				</MkButton> | ||||
| 			</div> | ||||
| 			<div v-if="user && user.securityKeys" class="or-hr"> | ||||
| 				<p class="or-msg">{{ i18n.ts.or }}</p> | ||||
| 			<div v-if="user && user.securityKeys" :class="$style.orHr"> | ||||
| 				<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 			<div class="twofa-group totp-group"> | ||||
| 				<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p> | ||||
|  | @ -53,6 +61,7 @@ import { defineAsyncComponent, ref } from 'vue'; | |||
| import { toUnicode } from 'punycode/'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; | ||||
| import type { MisskeyHubOptions } from '@/scripts/please-login.js'; | ||||
| import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
|  | @ -77,22 +86,16 @@ const emit = defineEmits<{ | |||
| 	(ev: 'login', v: any): void; | ||||
| }>(); | ||||
| 
 | ||||
| const props = defineProps({ | ||||
| 	withAvatar: { | ||||
| 		type: Boolean, | ||||
| 		required: false, | ||||
| 		default: true, | ||||
| 	}, | ||||
| 	autoSet: { | ||||
| 		type: Boolean, | ||||
| 		required: false, | ||||
| 		default: false, | ||||
| 	}, | ||||
| 	message: { | ||||
| 		type: String, | ||||
| 		required: false, | ||||
| 		default: '', | ||||
| 	}, | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	withAvatar?: boolean; | ||||
| 	autoSet?: boolean; | ||||
| 	message?: string, | ||||
| 	misskeyHub?: MisskeyHubOptions, | ||||
| }>(), { | ||||
| 	withAvatar: true, | ||||
| 	autoSet: false, | ||||
| 	message: '', | ||||
| 	misskeyHub: undefined, | ||||
| }); | ||||
| 
 | ||||
| function onUsernameChange(): void { | ||||
|  | @ -219,6 +222,28 @@ function resetPassword(): void { | |||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { | ||||
| 	}, 'closed'); | ||||
| } | ||||
| 
 | ||||
| function openMisskeyHub(hubOptions: MisskeyHubOptions): void { | ||||
| 	switch (hubOptions.type) { | ||||
| 		case 'web': { | ||||
| 			window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(hubOptions.path)}`, '_blank', 'noopener'); | ||||
| 			break; | ||||
| 		} | ||||
| 		case 'share': { | ||||
| 			const rawParams = { ...hubOptions.params }; | ||||
| 			// undefinedの値をすべて除去 | ||||
| 			Object.keys(rawParams).forEach(key => { | ||||
| 				// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||||
| 				if (rawParams[key] === undefined) { | ||||
| 					delete rawParams[key]; | ||||
| 				} | ||||
| 			}); | ||||
| 			const params = new URLSearchParams(rawParams); | ||||
| 			window.open(`https://misskey-hub.net/share/?${params.toString()}`, '_blank', 'noopener'); | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
|  | @ -231,4 +256,25 @@ function resetPassword(): void { | |||
| 	background-size: cover; | ||||
| 	border-radius: 100%; | ||||
| } | ||||
| 
 | ||||
| .orHr { | ||||
| 	position: relative; | ||||
| 	margin: .4em auto; | ||||
| 	width: 100%; | ||||
| 	height: 1px; | ||||
| 	background: var(--divider); | ||||
| } | ||||
| 
 | ||||
| .orMsg { | ||||
| 	position: absolute; | ||||
| 	top: -.6em; | ||||
| 	display: inline-block; | ||||
| 	padding: 0 1em; | ||||
| 	background: var(--panel); | ||||
| 	font-size: 0.8em; | ||||
| 	color: var(--fgOnPanel); | ||||
| 	margin: 0; | ||||
| 	left: 50%; | ||||
| 	transform: translateX(-50%); | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -14,13 +14,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 	<template #header>{{ i18n.ts.login }}</template> | ||||
| 
 | ||||
| 	<MkSpacer :marginMin="20" :marginMax="28"> | ||||
| 		<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/> | ||||
| 		<MkSignin :autoSet="autoSet" :message="message" :misskeyHub="misskeyHub" @login="onLogin"/> | ||||
| 	</MkSpacer> | ||||
| </MkModalWindow> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { shallowRef } from 'vue'; | ||||
| import type { MisskeyHubOptions } from '@/scripts/please-login.js'; | ||||
| import MkSignin from '@/components/MkSignin.vue'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | @ -28,9 +29,11 @@ import { i18n } from '@/i18n.js'; | |||
| withDefaults(defineProps<{ | ||||
| 	autoSet?: boolean; | ||||
| 	message?: string, | ||||
| 	misskeyHub?: MisskeyHubOptions, | ||||
| }>(), { | ||||
| 	autoSet: false, | ||||
| 	message: '', | ||||
| 	misskeyHub: undefined, | ||||
| }); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
|  |  | |||
|  | @ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 			<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/> | ||||
| 	<MkFollowButton :class="$style.follow" :user="user" mini/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue'; | |||
| import MkContextMenu from '@/components/MkContextMenu.vue'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
| import copyToClipboard from '@/scripts/copy-to-clipboard.js'; | ||||
| import { pleaseLogin } from '@/scripts/please-login.js'; | ||||
| import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; | ||||
| 
 | ||||
| export const openingWindowsCount = ref(0); | ||||
|  | @ -592,6 +593,15 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) | |||
| } | ||||
| 
 | ||||
| export function post(props: Record<string, any> = {}): Promise<void> { | ||||
| 	pleaseLogin(undefined, (props.initialText || props.initialNote ? { | ||||
| 		type: 'share', | ||||
| 		params: { | ||||
| 			text: props.initialText ?? props.initialNote.text, | ||||
| 			visibility: props.initialVisibility ?? props.initialNote?.visibility, | ||||
| 			localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0', | ||||
| 		}, | ||||
| 	} : undefined)); | ||||
| 
 | ||||
| 	showMovedDialog(); | ||||
| 
 | ||||
| 	return new Promise((resolve, reject) => { | ||||
|  |  | |||
|  | @ -75,6 +75,7 @@ import { defaultStore } from '@/store.js'; | |||
| import { $i } from '@/account.js'; | ||||
| import { isSupportShare } from '@/scripts/navigator.js'; | ||||
| import copyToClipboard from '@/scripts/copy-to-clipboard.js'; | ||||
| import { pleaseLogin } from '@/scripts/please-login.js'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	id: string; | ||||
|  | @ -114,6 +115,8 @@ function shareWithNote() { | |||
| } | ||||
| 
 | ||||
| function like() { | ||||
| 	pleaseLogin(); | ||||
| 
 | ||||
| 	os.apiWithDialog('flash/like', { | ||||
| 		flashId: flash.value.id, | ||||
| 	}).then(() => { | ||||
|  | @ -123,6 +126,8 @@ function like() { | |||
| } | ||||
| 
 | ||||
| async function unlike() { | ||||
| 	pleaseLogin(); | ||||
| 
 | ||||
| 	const confirm = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.ts.unlikeConfirm, | ||||
|  |  | |||
|  | @ -32,9 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 							</div> | ||||
| 						</div> | ||||
| 						<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> | ||||
| 						<div v-if="$i" class="actions"> | ||||
| 						<div class="actions"> | ||||
| 							<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> | ||||
| 							<MkFollowButton v-if="$i.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> | ||||
| 							<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<MkAvatar class="avatar" :user="user" indicator/> | ||||
|  |  | |||
|  | @ -177,7 +177,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter | |||
| 			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; | ||||
| 			copyToClipboard(`${url}/${canonical}`); | ||||
| 		}, | ||||
| 	}, { | ||||
| 	}, ...($i ? [{ | ||||
| 		icon: 'ti ti-mail', | ||||
| 		text: i18n.ts.sendMessage, | ||||
| 		action: () => { | ||||
|  | @ -250,7 +250,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter | |||
| 				}, | ||||
| 			})); | ||||
| 		}, | ||||
| 	}] as any; | ||||
| 	}] : [])] as any; | ||||
| 
 | ||||
| 	if ($i && meId !== user.id) { | ||||
| 		if (iAmModerator) { | ||||
|  |  | |||
|  | @ -8,12 +8,21 @@ import { $i } from '@/account.js'; | |||
| import { i18n } from '@/i18n.js'; | ||||
| import { popup } from '@/os.js'; | ||||
| 
 | ||||
| export function pleaseLogin(path?: string) { | ||||
| export type MisskeyHubOptions = { | ||||
| 	type: 'web'; | ||||
| 	path: string; | ||||
| } | { | ||||
| 	type: 'share'; | ||||
| 	params: Record<string, string>; | ||||
| }; | ||||
| 
 | ||||
| export function pleaseLogin(path?: string, misskeyHub?: MisskeyHubOptions) { | ||||
| 	if ($i) return; | ||||
| 
 | ||||
| 	popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { | ||||
| 		autoSet: true, | ||||
| 		message: i18n.ts.signinRequired, | ||||
| 		message: misskeyHub ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired, | ||||
| 		misskeyHub, | ||||
| 	}, { | ||||
| 		cancelled: () => { | ||||
| 			if (path) { | ||||
|  |  | |||
|  | @ -41,6 +41,7 @@ const devConfig = { | |||
| 			}, | ||||
| 			'/url': 'http://localhost:3000', | ||||
| 			'/proxy': 'http://localhost:3000', | ||||
| 			'/authorize_interaction': 'http://localhost:3000', | ||||
| 		}, | ||||
| 	}, | ||||
| 	build: { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue