Resolve #5963
This commit is contained in:
		
							parent
							
								
									aa2c8d101e
								
							
						
					
					
						commit
						a54de07260
					
				
					 16 changed files with 247 additions and 5 deletions
				
			
		|  | @ -412,6 +412,9 @@ dayOverDayChanges: "前日比" | |||
| accessibility: "アクセシビリティ" | ||||
| clinetSettings: "クライアント設定" | ||||
| accountSettings: "アカウント設定" | ||||
| promotion: "プロモーション" | ||||
| promote: "プロモート" | ||||
| numberOfDays: "日数" | ||||
| 
 | ||||
| _ago: | ||||
|   unknown: "謎" | ||||
|  |  | |||
							
								
								
									
										28
									
								
								migration/1581979837262-promo.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								migration/1581979837262-promo.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import {MigrationInterface, QueryRunner} from "typeorm"; | ||||
| 
 | ||||
| export class promo1581979837262 implements MigrationInterface { | ||||
|     name = 'promo1581979837262' | ||||
| 
 | ||||
|     public async up(queryRunner: QueryRunner): Promise<any> { | ||||
|         await queryRunner.query(`CREATE TABLE "promo_note" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "REL_e263909ca4fe5d57f8d4230dd5" UNIQUE ("noteId"), CONSTRAINT "PK_e263909ca4fe5d57f8d4230dd5c" PRIMARY KEY ("noteId"))`, undefined); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_83f0862e9bae44af52ced7099e" ON "promo_note" ("userId") `, undefined); | ||||
|         await queryRunner.query(`CREATE TABLE "promo_read" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_61917c1541002422b703318b7c9" PRIMARY KEY ("id"))`, undefined); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_9657d55550c3d37bfafaf7d4b0" ON "promo_read" ("userId") `, undefined); | ||||
|         await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId") `, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined); | ||||
|     } | ||||
| 
 | ||||
|     public async down(queryRunner: QueryRunner): Promise<any> { | ||||
|         await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4"`, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05"`, undefined); | ||||
|         await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`, undefined); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_2882b8a1a07c7d281a98b6db16"`, undefined); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_9657d55550c3d37bfafaf7d4b0"`, undefined); | ||||
|         await queryRunner.query(`DROP TABLE "promo_read"`, undefined); | ||||
|         await queryRunner.query(`DROP INDEX "IDX_83f0862e9bae44af52ced7099e"`, undefined); | ||||
|         await queryRunner.query(`DROP TABLE "promo_note"`, undefined); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -2,7 +2,7 @@ | |||
| <sequential-entrance class="sqadhkmv" ref="list" :direction="direction" :reversed="reversed"> | ||||
| 	<template v-for="(item, i) in items"> | ||||
| 		<slot :item="item" :i="i"></slot> | ||||
| 		<div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()"> | ||||
| 		<div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate() && !item._prInjectionId_ && !items[i + 1]._prInjectionId_"> | ||||
| 			<p class="date"> | ||||
| 				<span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span> | ||||
| 				<span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span> | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ | |||
| 	<x-sub v-for="note in conversation" :key="note.id" :note="note"/> | ||||
| 	<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> | ||||
| 	<div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> | ||||
| 	<div class="pinned" v-if="appearNote._prInjectionId_"><fa :icon="faBullhorn"/> {{ $t('promotion') }}</div> | ||||
| 	<div class="renote" v-if="isRenote"> | ||||
| 		<mk-avatar class="avatar" :user="note.user"/> | ||||
| 		<fa :icon="faRetweet"/> | ||||
|  | @ -83,7 +84,7 @@ | |||
| 
 | ||||
| <script lang="ts"> | ||||
| import Vue from 'vue'; | ||||
| import { faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; | ||||
| import { parse } from '../../mfm/parse'; | ||||
| import { sum, unique } from '../../prelude/array'; | ||||
|  | @ -140,7 +141,7 @@ export default Vue.extend({ | |||
| 			replies: [], | ||||
| 			showContent: false, | ||||
| 			hideThisNote: false, | ||||
| 			faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan | ||||
| 			faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -522,6 +523,15 @@ export default Vue.extend({ | |||
| 					text: this.$t('pin'), | ||||
| 					action: () => this.togglePin(true) | ||||
| 				} : undefined, | ||||
| 				...(this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [ | ||||
| 					null, | ||||
| 					{ | ||||
| 						icon: faBullhorn, | ||||
| 						text: this.$t('promote'), | ||||
| 						action: this.promote | ||||
| 					}] | ||||
| 					: [] | ||||
| 				), | ||||
| 				...(this.appearNote.userId == this.$store.state.i.id ? [ | ||||
| 					null, | ||||
| 					{ | ||||
|  | @ -614,6 +624,30 @@ export default Vue.extend({ | |||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async promote() { | ||||
| 			const { canceled, result: days } = await this.$root.dialog({ | ||||
| 				title: this.$t('numberOfDays'), | ||||
| 				input: { type: 'number' } | ||||
| 			}); | ||||
| 
 | ||||
| 			if (canceled) return; | ||||
| 
 | ||||
| 			this.$root.api('admin/promo/create', { | ||||
| 				noteId: this.appearNote.id, | ||||
| 				expiresAt: Date.now() + (86400000 * days) | ||||
| 			}).then(() => { | ||||
| 				this.$root.dialog({ | ||||
| 					type: 'success', | ||||
| 					iconOnly: true, autoClose: true | ||||
| 				}); | ||||
| 			}).catch(e => { | ||||
| 				this.$root.dialog({ | ||||
| 					type: 'error', | ||||
| 					text: e | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		focus() { | ||||
| 			this.$el.focus(); | ||||
| 		}, | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ | |||
| 	</div> | ||||
| 
 | ||||
| 	<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> | ||||
| 		<x-note :note="note" :detail="detail" :key="note.id"/> | ||||
| 		<x-note :note="note" :detail="detail" :key="note._prInjectionId_ || note.id"/> | ||||
| 	</x-list> | ||||
| 
 | ||||
| 	<div class="more" v-if="more && !reversed" style="margin-top: var(--margin);"> | ||||
|  |  | |||
|  | @ -55,6 +55,8 @@ import { Clip } from '../models/entities/clip'; | |||
| import { ClipNote } from '../models/entities/clip-note'; | ||||
| import { Antenna } from '../models/entities/antenna'; | ||||
| import { AntennaNote } from '../models/entities/antenna-note'; | ||||
| import { PromoNote } from '../models/entities/promo-note'; | ||||
| import { PromoRead } from '../models/entities/promo-read'; | ||||
| 
 | ||||
| const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); | ||||
| 
 | ||||
|  | @ -140,6 +142,8 @@ export const entities = [ | |||
| 	ClipNote, | ||||
| 	Antenna, | ||||
| 	AntennaNote, | ||||
| 	PromoNote, | ||||
| 	PromoRead, | ||||
| 	ReversiGame, | ||||
| 	ReversiMatching, | ||||
| 	...charts as any | ||||
|  |  | |||
							
								
								
									
										28
									
								
								src/models/entities/promo-note.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/models/entities/promo-note.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; | ||||
| import { Note } from './note'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class PromoNote { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public noteId: Note['id']; | ||||
| 
 | ||||
| 	@OneToOne(type => Note, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public note: Note | null; | ||||
| 
 | ||||
| 	@Column('timestamp with time zone') | ||||
| 	public expiresAt: Date; | ||||
| 
 | ||||
| 	//#region Denormalized fields
 | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		comment: '[Denormalized]' | ||||
| 	}) | ||||
| 	public userId: User['id']; | ||||
| 	//#endregion
 | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/models/entities/promo-read.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/models/entities/promo-read.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; | ||||
| import { Note } from './note'; | ||||
| import { User } from './user'; | ||||
| import { id } from '../id'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index(['userId', 'noteId'], { unique: true }) | ||||
| export class PromoRead { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'The created date of the PromoRead.' | ||||
| 	}) | ||||
| 	public createdAt: Date; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column(id()) | ||||
| 	public userId: User['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public user: User | null; | ||||
| 
 | ||||
| 	@Column(id()) | ||||
| 	public noteId: Note['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Note, { | ||||
| 		onDelete: 'CASCADE' | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public note: Note | null; | ||||
| } | ||||
|  | @ -50,6 +50,8 @@ import { ClipRepository } from './repositories/clip'; | |||
| import { ClipNote } from './entities/clip-note'; | ||||
| import { AntennaRepository } from './repositories/antenna'; | ||||
| import { AntennaNote } from './entities/antenna-note'; | ||||
| import { PromoNote } from './entities/promo-note'; | ||||
| import { PromoRead } from './entities/promo-read'; | ||||
| 
 | ||||
| export const Announcements = getRepository(Announcement); | ||||
| export const AnnouncementReads = getRepository(AnnouncementRead); | ||||
|  | @ -102,3 +104,5 @@ export const Clips = getCustomRepository(ClipRepository); | |||
| export const ClipNotes = getRepository(ClipNote); | ||||
| export const Antennas = getCustomRepository(AntennaRepository); | ||||
| export const AntennaNotes = getRepository(AntennaNote); | ||||
| export const PromoNotes = getRepository(PromoNote); | ||||
| export const PromoReads = getRepository(PromoRead); | ||||
|  |  | |||
|  | @ -196,6 +196,7 @@ export class NoteRepository extends Repository<Note> { | |||
| 			renoteId: note.renoteId, | ||||
| 			mentions: note.mentions.length > 0 ? note.mentions : undefined, | ||||
| 			uri: note.uri || undefined, | ||||
| 			_prInjectionId_: (note as any)._prInjectionId_ || undefined, | ||||
| 
 | ||||
| 			...(opts.detail ? { | ||||
| 				reply: note.replyId ? this.pack(note.replyId, meId, { | ||||
|  |  | |||
							
								
								
									
										36
									
								
								src/server/api/common/inject-promo.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								src/server/api/common/inject-promo.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| import rndstr from 'rndstr'; | ||||
| import { Note } from '../../../models/entities/note'; | ||||
| import { User } from '../../../models/entities/user'; | ||||
| import { PromoReads, PromoNotes, Notes, Users } from '../../../models'; | ||||
| import { ensure } from '../../../prelude/ensure'; | ||||
| 
 | ||||
| export async function injectPromo(user: User, timeline: Note[]) { | ||||
| 	if (timeline.length < 5) return; | ||||
| 
 | ||||
| 	// TODO: readやexpireフィルタはクエリ側でやる
 | ||||
| 
 | ||||
| 	const reads = await PromoReads.find({ | ||||
| 		userId: user.id | ||||
| 	}); | ||||
| 
 | ||||
| 	let promos = await PromoNotes.find(); | ||||
| 
 | ||||
| 	promos = promos.filter(n => n.expiresAt.getTime() > Date.now()); | ||||
| 	promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId)); | ||||
| 
 | ||||
| 	if (promos.length === 0) return; | ||||
| 
 | ||||
| 	const promo = promos[Math.floor(Math.random() * promos.length)]; | ||||
| 
 | ||||
| 	// Pick random promo
 | ||||
| 	const note = await Notes.findOne(promo.noteId).then(ensure); | ||||
| 
 | ||||
| 	// Join
 | ||||
| 	note.user = await Users.findOne(note.userId).then(ensure); | ||||
| 
 | ||||
| 	(note as any)._prInjectionId_ = rndstr('a-z0-9', 8); | ||||
| 
 | ||||
| 	// Inject promo
 | ||||
| 	timeline.splice(3, 0, note); | ||||
| 	timeline.pop(); | ||||
| } | ||||
							
								
								
									
										58
									
								
								src/server/api/endpoints/admin/promo/create.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/server/api/endpoints/admin/promo/create.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| import $ from 'cafy'; | ||||
| import { ID } from '../../../../../misc/cafy-id'; | ||||
| import define from '../../../define'; | ||||
| import { ApiError } from '../../../error'; | ||||
| import { getNote } from '../../../common/getters'; | ||||
| import { PromoNotes } from '../../../../../models'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	requireCredential: true as const, | ||||
| 	requireModerator: true, | ||||
| 
 | ||||
| 	params: { | ||||
| 		noteId: { | ||||
| 			validator: $.type(ID), | ||||
| 		}, | ||||
| 
 | ||||
| 		expiresAt: { | ||||
| 			validator: $.num.int() | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchNote: { | ||||
| 			message: 'No such note.', | ||||
| 			code: 'NO_SUCH_NOTE', | ||||
| 			id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc' | ||||
| 		}, | ||||
| 
 | ||||
| 		alreadyPromoted: { | ||||
| 			message: 'The note has already promoted.', | ||||
| 			code: 'ALREADY_PROMOTED', | ||||
| 			id: 'ae427aa2-7a41-484f-a18c-2c1104051604' | ||||
| 		}, | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export default define(meta, async (ps, user) => { | ||||
| 	// Get favoritee
 | ||||
| 	const note = await getNote(ps.noteId).catch(e => { | ||||
| 		if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); | ||||
| 		throw e; | ||||
| 	}); | ||||
| 
 | ||||
| 	// if already favorited
 | ||||
| 	const exist = await PromoNotes.findOne(note.id); | ||||
| 
 | ||||
| 	if (exist != null) { | ||||
| 		throw new ApiError(meta.errors.alreadyPromoted); | ||||
| 	} | ||||
| 
 | ||||
| 	// Create favorite
 | ||||
| 	await PromoNotes.save({ | ||||
| 		noteId: note.id, | ||||
| 		createdAt: new Date(), | ||||
| 		expiresAt: new Date(ps.expiresAt), | ||||
| 		userId: note.userId, | ||||
| 	}); | ||||
| }); | ||||
|  | @ -7,8 +7,8 @@ import { makePaginationQuery } from '../../common/make-pagination-query'; | |||
| import { Notes } from '../../../../models'; | ||||
| import { generateMuteQuery } from '../../common/generate-mute-query'; | ||||
| import { activeUsersChart } from '../../../../services/chart'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { generateRepliesQuery } from '../../common/generate-replies-query'; | ||||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -90,6 +90,8 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	const timeline = await query.take(ps.limit!).getMany(); | ||||
| 
 | ||||
| 	await injectPromo(user, timeline); | ||||
| 
 | ||||
| 	process.nextTick(() => { | ||||
| 		if (user) { | ||||
| 			activeUsersChart.update(user); | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query' | |||
| import { generateMuteQuery } from '../../common/generate-mute-query'; | ||||
| import { activeUsersChart } from '../../../../services/chart'; | ||||
| import { generateRepliesQuery } from '../../common/generate-replies-query'; | ||||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -169,6 +170,8 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	const timeline = await query.take(ps.limit!).getMany(); | ||||
| 
 | ||||
| 	await injectPromo(user, timeline); | ||||
| 
 | ||||
| 	process.nextTick(() => { | ||||
| 		if (user) { | ||||
| 			activeUsersChart.update(user); | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import { generateVisibilityQuery } from '../../common/generate-visibility-query' | |||
| import { activeUsersChart } from '../../../../services/chart'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { generateRepliesQuery } from '../../common/generate-replies-query'; | ||||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -122,6 +123,8 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	const timeline = await query.take(ps.limit!).getMany(); | ||||
| 
 | ||||
| 	await injectPromo(user, timeline); | ||||
| 
 | ||||
| 	process.nextTick(() => { | ||||
| 		if (user) { | ||||
| 			activeUsersChart.update(user); | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ import { generateMuteQuery } from '../../common/generate-mute-query'; | |||
| import { activeUsersChart } from '../../../../services/chart'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { generateRepliesQuery } from '../../common/generate-replies-query'; | ||||
| import { injectPromo } from '../../common/inject-promo'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	desc: { | ||||
|  | @ -155,6 +156,8 @@ export default define(meta, async (ps, user) => { | |||
| 
 | ||||
| 	const timeline = await query.take(ps.limit!).getMany(); | ||||
| 
 | ||||
| 	await injectPromo(user, timeline); | ||||
| 
 | ||||
| 	process.nextTick(() => { | ||||
| 		if (user) { | ||||
| 			activeUsersChart.update(user); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue