mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
Add Comment Count to Video Preview Components (#6635)
* WIP: Add backend functionality to store comment count per video and update on comment visibility actions * WIP: Display image icon and comment count on video miniature component * Probably don't need to index the comment count * Added comment count back to mini video component * Added basic tests * Sort by comments, more robust comments count --------- Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
parent
75d7c2a9dc
commit
25a9f37ded
14 changed files with 255 additions and 56 deletions
|
@ -78,14 +78,15 @@
|
|||
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
|
||||
</th>
|
||||
|
||||
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration" i18n>{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name" i18n>{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('privacy')" i18n>{{ getColumn('privacy').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('sensitive')" i18n>{{ getColumn('sensitive').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('playlists')" i18n>{{ getColumn('playlists').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views" i18n>{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration">{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('privacy')">{{ getColumn('privacy').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('sensitive')">{{ getColumn('sensitive').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('playlists')">{{ getColumn('playlists').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views">{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('comments')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="comments">{{ getColumn('comments').label }} <p-sortIcon field="comments"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('published')" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="publishedAt">{{ getColumn('published').label }} <p-sortIcon field="publishedAt"></p-sortIcon></th>
|
||||
<th scope="col" *ngIf="isSelected('state')" i18n>{{ getColumn('state').label }}</th>
|
||||
<th scope="col" *ngIf="isSelected('state')">{{ getColumn('state').label }}</th>
|
||||
|
||||
<th scope="col" width="250px" class="action-head">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
|
@ -162,6 +163,10 @@
|
|||
</a>
|
||||
</td>
|
||||
|
||||
<td *ngIf="isSelected('comments')">
|
||||
<span i18n>{video.comments, plural, =0 {No comments} =1 {1 comment} other {{{ video.comments | myNumberFormatter }} comments}}</span>
|
||||
</td>
|
||||
|
||||
<td *ngIf="isSelected('published')">
|
||||
{{ video.publishedAt | ptDate: 'short' }}
|
||||
</td>
|
||||
|
|
|
@ -52,7 +52,7 @@ import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-priv
|
|||
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
|
||||
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||
|
||||
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state'
|
||||
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state' | 'comments'
|
||||
type CommonFilter = 'live' | 'vod' | 'private' | 'internal' | 'unlisted' | 'password-protected' | 'public'
|
||||
|
||||
@Component({
|
||||
|
@ -157,6 +157,7 @@ export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDes
|
|||
{ id: 'sensitive', label: $localize`Sensitive`, selected: true },
|
||||
{ id: 'playlists', label: $localize`Playlists`, selected: true },
|
||||
{ id: 'insights', label: $localize`Insights`, selected: true },
|
||||
{ id: 'comments', label: $localize`Comments`, selected: false },
|
||||
{ id: 'published', label: $localize`Published`, selected: true },
|
||||
{ id: 'state', label: $localize`State`, selected: true }
|
||||
]
|
||||
|
|
|
@ -116,6 +116,8 @@ export class Video implements VideoServerModel {
|
|||
|
||||
automaticTags?: string[]
|
||||
|
||||
comments: number
|
||||
|
||||
static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
|
||||
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
|
||||
}
|
||||
|
@ -211,6 +213,8 @@ export class Video implements VideoServerModel {
|
|||
this.aspectRatio = hash.aspectRatio
|
||||
|
||||
this.automaticTags = hash.automaticTags
|
||||
|
||||
this.comments = hash.comments
|
||||
}
|
||||
|
||||
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
|
||||
|
|
|
@ -6,9 +6,9 @@ import {
|
|||
LOCALE_ID,
|
||||
OnInit,
|
||||
booleanAttribute,
|
||||
numberAttribute,
|
||||
inject,
|
||||
input,
|
||||
numberAttribute,
|
||||
output
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
|
@ -24,8 +24,8 @@ import { VideoService } from '../shared-main/video/video.service'
|
|||
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
|
||||
import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service'
|
||||
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
|
||||
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
|
||||
import { ActorHostComponent } from '../standalone-actor/actor-host.component'
|
||||
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
|
||||
|
||||
export type MiniatureDisplayOptions = {
|
||||
date?: boolean
|
||||
|
|
|
@ -51,6 +51,8 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
|
|||
|
||||
likes: number
|
||||
dislikes: number
|
||||
comments: number
|
||||
|
||||
nsfw: boolean
|
||||
|
||||
account: AccountSummary
|
||||
|
|
|
@ -12,9 +12,12 @@ import {
|
|||
} from '@peertube/peertube-models'
|
||||
import {
|
||||
PeerTubeServer,
|
||||
cleanupTests, createMultipleServers,
|
||||
cleanupTests,
|
||||
createMultipleServers,
|
||||
doubleFollow,
|
||||
makeActivityPubGetRequest, makeActivityPubRawRequest, setAccessTokensToServers,
|
||||
makeActivityPubGetRequest,
|
||||
makeActivityPubRawRequest,
|
||||
setAccessTokensToServers,
|
||||
setDefaultAccountAvatar,
|
||||
waitJobs
|
||||
} from '@peertube/peertube-server-commands'
|
||||
|
@ -546,6 +549,68 @@ describe('Test comments approval', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Comment count with moderation', function () {
|
||||
let videoId: string
|
||||
|
||||
before(async function () {
|
||||
videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
|
||||
})
|
||||
|
||||
it('Should not increment comment count when comment is held for review', async function () {
|
||||
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'held comment' })
|
||||
await waitJobs(servers)
|
||||
|
||||
const video = await servers[0].videos.get({ id: videoId })
|
||||
expect(video.comments).to.equal(0)
|
||||
})
|
||||
|
||||
it('Should increment comment count after approving comment', async function () {
|
||||
// Create a new comment that will be held for review
|
||||
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'test comment' })
|
||||
await waitJobs(servers)
|
||||
|
||||
// Get the held comment
|
||||
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
|
||||
const heldComment = data.find(c => c.text === 'test comment')
|
||||
expect(heldComment.heldForReview).to.be.true
|
||||
|
||||
// Verify initial count is 0
|
||||
let video = await servers[0].videos.get({ id: videoId })
|
||||
expect(video.comments).to.equal(0)
|
||||
|
||||
// Approve the comment
|
||||
await servers[0].comments.approve({ token: userToken, videoId, commentId: heldComment.id })
|
||||
await waitJobs(servers)
|
||||
|
||||
// Verify count incremented after approval
|
||||
video = await servers[0].videos.get({ id: videoId })
|
||||
expect(video.comments).to.equal(1)
|
||||
})
|
||||
|
||||
it('Should not increment comment count when deleting held comment', async function () {
|
||||
// Get initial comment count
|
||||
let video = await servers[0].videos.get({ id: videoId })
|
||||
const initialCount = video.comments
|
||||
|
||||
// Create a new comment that will be held for review
|
||||
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'to be deleted' })
|
||||
await waitJobs(servers)
|
||||
|
||||
// Get the held comment
|
||||
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
|
||||
const heldComment = data.find(c => c.text === 'to be deleted')
|
||||
expect(heldComment.heldForReview).to.be.true
|
||||
|
||||
// Delete the held comment
|
||||
await servers[0].comments.delete({ token: userToken, videoId, commentId: heldComment.id })
|
||||
await waitJobs(servers)
|
||||
|
||||
// Verify count remains unchanged after deleting held comment
|
||||
video = await servers[0].videos.get({ id: videoId })
|
||||
expect(video.comments).to.equal(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests(servers)
|
||||
})
|
||||
|
|
|
@ -45,7 +45,6 @@ describe('Test video comments', function () {
|
|||
})
|
||||
|
||||
describe('User comments', function () {
|
||||
|
||||
it('Should not have threads on this video', async function () {
|
||||
const body = await command.listThreads({ videoId: videoUUID })
|
||||
|
||||
|
@ -256,11 +255,10 @@ describe('Test video comments', function () {
|
|||
})
|
||||
|
||||
describe('Listing comments on my videos and in admin', function () {
|
||||
|
||||
const listFunctions = () => ([
|
||||
const listFunctions = () => [
|
||||
command.listForAdmin.bind(command),
|
||||
command.listCommentsOnMyVideos.bind(command)
|
||||
])
|
||||
]
|
||||
|
||||
it('Should list comments', async function () {
|
||||
for (const fn of listFunctions()) {
|
||||
|
@ -401,6 +399,38 @@ describe('Test video comments', function () {
|
|||
// Auto tags filter is checked auto tags test file
|
||||
})
|
||||
|
||||
describe('Video comment count', function () {
|
||||
let testVideoUUID: string
|
||||
|
||||
before(async function () {
|
||||
const { uuid } = await server.videos.upload()
|
||||
testVideoUUID = uuid
|
||||
})
|
||||
|
||||
it('Should start with 0 comments', async function () {
|
||||
const video = await server.videos.get({ id: testVideoUUID })
|
||||
expect(video.commentsEnabled).to.be.true
|
||||
expect(video.comments).to.equal(0)
|
||||
})
|
||||
|
||||
it('Should increment comment count when adding comment', async function () {
|
||||
await command.createThread({ videoId: testVideoUUID, text: 'test comment' })
|
||||
|
||||
const video = await server.videos.get({ id: testVideoUUID })
|
||||
expect(video.comments).to.equal(1)
|
||||
})
|
||||
|
||||
it('Should decrement count when deleting comment', async function () {
|
||||
const { data } = await command.listThreads({ videoId: testVideoUUID })
|
||||
const commentToDelete = data[0]
|
||||
|
||||
await command.delete({ videoId: testVideoUUID, commentId: commentToDelete.id })
|
||||
|
||||
const video = await server.videos.get({ id: testVideoUUID })
|
||||
expect(video.comments).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
|
|
|
@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LAST_MIGRATION_VERSION = 875
|
||||
export const LAST_MIGRATION_VERSION = 880
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -119,6 +119,7 @@ export const SORTABLE_COLUMNS = {
|
|||
'originallyPublishedAt',
|
||||
'views',
|
||||
'likes',
|
||||
'comments',
|
||||
'trending',
|
||||
'hot',
|
||||
'best',
|
||||
|
@ -1286,7 +1287,9 @@ export async function buildLanguages () {
|
|||
return (l.iso6391 !== undefined && l.type === 'living') ||
|
||||
additionalLanguages[l.iso6393] === true
|
||||
})
|
||||
.forEach(l => { languages[l.iso6391 || l.iso6393] = l.name })
|
||||
.forEach(l => {
|
||||
languages[l.iso6391 || l.iso6393] = l.name
|
||||
})
|
||||
|
||||
// Override Occitan label
|
||||
languages['oc'] = 'Occitan'
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('video', 'comments', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: false
|
||||
}, { transaction })
|
||||
}
|
||||
|
||||
{
|
||||
const query = 'UPDATE "video" SET "comments" = (SELECT COUNT(*) FROM "videoComment" WHERE "videoComment"."videoId" = "video"."id")'
|
||||
await utils.sequelize.query(query, { transaction })
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
down,
|
||||
up
|
||||
}
|
|
@ -127,6 +127,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi
|
|||
? { currentTime: userHistory.currentTime }
|
||||
: undefined,
|
||||
|
||||
comments: video.comments,
|
||||
|
||||
// Can be added by external plugins
|
||||
pluginData: (video as any).pluginData,
|
||||
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
/**
|
||||
*
|
||||
* Class to build video attributes/join names we want to fetch from the database
|
||||
*
|
||||
*/
|
||||
export class VideoTableAttributes {
|
||||
|
||||
constructor (private readonly mode: 'get' | 'list') {
|
||||
|
||||
}
|
||||
|
||||
getChannelAttributesForUser () {
|
||||
|
@ -295,7 +291,8 @@ export class VideoTableAttributes {
|
|||
'channelId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'moveJobsRunning'
|
||||
'moveJobsRunning',
|
||||
'comments'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,19 +7,27 @@ import {
|
|||
VideoCommentForAdminOrUser,
|
||||
VideoCommentObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { afterCommitIfTransaction, retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { extractMentions } from '@server/helpers/mentions.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
|
||||
import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
|
||||
import {
|
||||
AfterCreate,
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
AllowNull,
|
||||
BelongsTo, Column,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Is, Scopes,
|
||||
Is,
|
||||
Scopes,
|
||||
Table,
|
||||
UpdatedAt
|
||||
} from 'sequelize-typescript'
|
||||
|
@ -27,13 +35,14 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
|
|||
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
|
||||
import {
|
||||
MComment,
|
||||
MCommentAP,
|
||||
MCommentAdminOrUserFormattable,
|
||||
MCommentAP,
|
||||
MCommentExport,
|
||||
MCommentFormattable,
|
||||
MCommentId,
|
||||
MCommentOwner,
|
||||
MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed,
|
||||
MCommentOwnerReplyVideoImmutable,
|
||||
MCommentOwnerVideoFeed,
|
||||
MCommentOwnerVideoReply,
|
||||
MVideo,
|
||||
MVideoImmutable
|
||||
|
@ -42,7 +51,7 @@ import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
|
|||
import { AccountModel } from '../account/account.js'
|
||||
import { ActorModel } from '../actor/actor.js'
|
||||
import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
|
||||
import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
|
||||
import { buildLocalAccountIdsIn, buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
|
||||
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
|
||||
import { VideoChannelModel } from './video-channel.js'
|
||||
import { VideoModel } from './video.js'
|
||||
|
@ -222,6 +231,43 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
|
|||
})
|
||||
CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
|
||||
|
||||
@AfterCreate
|
||||
@AfterDestroy
|
||||
static incrementCommentCount (instance: VideoCommentModel, options: any) {
|
||||
if (instance.heldForReview) return // Don't count held comments
|
||||
|
||||
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static updateCommentCountOnHeldStatusChange (instance: VideoCommentModel, options: any) {
|
||||
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static async rebuildCommentsCount (videoId: number) {
|
||||
try {
|
||||
await retryTransactionWrapper(() => {
|
||||
return sequelizeTypescript.transaction(async transaction => {
|
||||
const video = await VideoModel.load(videoId, transaction)
|
||||
|
||||
video.comments = await new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, {
|
||||
selectType: 'comment-only',
|
||||
videoId: video.id,
|
||||
heldForReview: false,
|
||||
notDeleted: true,
|
||||
transaction
|
||||
}).countComments()
|
||||
|
||||
await video.save({ transaction })
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Cannot rebuild comments count for video ' + videoId, { err })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getSQLAttributes (tableName: string, aliasPrefix = '') {
|
||||
|
@ -442,7 +488,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
|
|||
order: [ [ 'createdAt', order ] ] as Order,
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: Sequelize.literal('(' +
|
||||
[Op.in]: Sequelize.literal(
|
||||
'(' +
|
||||
'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
|
||||
`SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
|
||||
'UNION ' +
|
||||
|
@ -450,7 +497,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
|
|||
'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
|
||||
') ' +
|
||||
'SELECT id FROM children' +
|
||||
')'),
|
||||
')'
|
||||
),
|
||||
[Op.ne]: comment.id
|
||||
}
|
||||
},
|
||||
|
|
|
@ -507,6 +507,13 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
@Column
|
||||
dislikes: number
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(0)
|
||||
@IsInt
|
||||
@Min(0)
|
||||
@Column
|
||||
comments: number
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
remote: boolean
|
||||
|
|
|
@ -7186,6 +7186,7 @@ components:
|
|||
- -publishedAt
|
||||
- -views
|
||||
- -likes
|
||||
- -comments
|
||||
- -trending
|
||||
- -hot
|
||||
- -best
|
||||
|
@ -8445,6 +8446,9 @@ components:
|
|||
dislikes:
|
||||
type: integer
|
||||
example: 7
|
||||
comments:
|
||||
description: "**PeerTube >= 7.2** Number of comments on the video"
|
||||
type: integer
|
||||
nsfw:
|
||||
type: boolean
|
||||
waitTranscoding:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue