forked from shadowfacts/Tusker
Actual gap cell implementation
This commit is contained in:
parent
0fddf94292
commit
ce534c4a05
|
@ -99,6 +99,11 @@
|
||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "NO">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "CG_CONTEXT_SHOW_BACKTRACE"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "NO">
|
||||||
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
||||||
value = ""
|
value = ""
|
||||||
|
|
|
@ -10,36 +10,62 @@ import UIKit
|
||||||
|
|
||||||
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
var fillGap: ((TimelineGapDirection) -> Void)?
|
private(set) var direction = TimelineGapDirection.above
|
||||||
|
|
||||||
|
private let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let chevronView = AnimatingChevronView()
|
||||||
|
|
||||||
|
override var isHighlighted: Bool {
|
||||||
|
didSet {
|
||||||
|
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showsIndicator: Bool = false {
|
||||||
|
didSet {
|
||||||
|
if showsIndicator {
|
||||||
|
indicator.isHidden = false
|
||||||
|
indicator.startAnimating()
|
||||||
|
} else {
|
||||||
|
indicator.isHidden = true
|
||||||
|
indicator.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
backgroundColor = .red
|
backgroundColor = .systemGroupedBackground
|
||||||
|
|
||||||
var belowConfig = UIButton.Configuration.borderless()
|
indicator.isHidden = true
|
||||||
belowConfig.title = "Below"
|
indicator.color = .tintColor
|
||||||
let below = UIButton(configuration: belowConfig)
|
|
||||||
below.addTarget(self, action: #selector(loadBelowPressed), for: .touchUpInside)
|
|
||||||
|
|
||||||
var aboveConfig = UIButton.Configuration.borderless()
|
let label = UILabel()
|
||||||
aboveConfig.title = "Above"
|
label.text = "Load more"
|
||||||
let above = UIButton(configuration: aboveConfig)
|
label.font = .preferredFont(forTextStyle: .headline)
|
||||||
above.addTarget(self, action: #selector(loadAbovePressed), for: .touchUpInside)
|
label.textColor = .tintColor
|
||||||
|
|
||||||
|
chevronView.update(direction: .above)
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
above,
|
label,
|
||||||
below,
|
chevronView,
|
||||||
])
|
])
|
||||||
stack.axis = .vertical
|
stack.axis = .horizontal
|
||||||
stack.spacing = 4
|
stack.spacing = 8
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(stack)
|
contentView.addSubview(stack)
|
||||||
|
|
||||||
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(indicator)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||||
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
|
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
|
||||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||||
|
contentView.heightAnchor.constraint(equalToConstant: 44),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,12 +73,92 @@ class TimelineGapCollectionViewCell: UICollectionViewCell {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func loadBelowPressed() {
|
override func didMoveToSuperview() {
|
||||||
fillGap?(.below)
|
super.didMoveToSuperview()
|
||||||
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func loadAbovePressed() {
|
func update() {
|
||||||
fillGap?(.above)
|
guard let superview = superview as? UICollectionView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let yInParent = frame.minY - superview.contentOffset.y
|
||||||
|
let centerInParent = yInParent + bounds.height / 2
|
||||||
|
let centerOfParent = superview.frame.height / 2
|
||||||
|
|
||||||
|
let newDirection: TimelineGapDirection
|
||||||
|
if centerInParent > centerOfParent {
|
||||||
|
newDirection = .above
|
||||||
|
} else {
|
||||||
|
newDirection = .below
|
||||||
|
}
|
||||||
|
|
||||||
|
if newDirection != direction {
|
||||||
|
direction = newDirection
|
||||||
|
chevronView.update(direction: newDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AnimatingChevronView: UIView {
|
||||||
|
|
||||||
|
override class var layerClass: AnyClass { CAShapeLayer.self }
|
||||||
|
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
|
||||||
|
|
||||||
|
var animator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
let upPath: CGPath = {
|
||||||
|
let path = UIBezierPath()
|
||||||
|
let width: CGFloat = 20
|
||||||
|
let height: CGFloat = 25
|
||||||
|
path.move(to: CGPoint(x: 0, y: height / 2))
|
||||||
|
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
|
||||||
|
path.addLine(to: CGPoint(x: width, y: height / 2))
|
||||||
|
return path.cgPath
|
||||||
|
}()
|
||||||
|
let downPath: CGPath = {
|
||||||
|
let path = UIBezierPath()
|
||||||
|
let width: CGFloat = 20
|
||||||
|
let height: CGFloat = 25
|
||||||
|
path.move(to: CGPoint(x: 0, y: height / 2))
|
||||||
|
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
|
||||||
|
path.addLine(to: CGPoint(x: width, y: height / 2))
|
||||||
|
return path.cgPath
|
||||||
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
shapeLayer.fillColor = nil
|
||||||
|
shapeLayer.strokeColor = tintColor.cgColor
|
||||||
|
shapeLayer.lineCap = .round
|
||||||
|
shapeLayer.lineJoin = .round
|
||||||
|
shapeLayer.lineWidth = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tintColorDidChange() {
|
||||||
|
super.tintColorDidChange()
|
||||||
|
shapeLayer.strokeColor = tintColor.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(direction: TimelineGapDirection) {
|
||||||
|
if animator?.isRunning == true {
|
||||||
|
animator!.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
if direction == .below {
|
||||||
|
self.shapeLayer.path = self.upPath
|
||||||
|
} else {
|
||||||
|
self.shapeLayer.path = self.downPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animator!.startAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
private(set) var collectionView: UICollectionView!
|
private(set) var collectionView: UICollectionView!
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
private var contentOffsetObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -56,8 +58,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
}
|
} else {
|
||||||
if case .status(_, _) = item {
|
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
|
@ -87,34 +88,43 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
|
let split = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
// var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
// let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
// if statuses.count > 20 {
|
if statuses.count > 20 {
|
||||||
// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
|
let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
|
||||||
// if !toRemove.isEmpty {
|
if !toRemove.isEmpty {
|
||||||
// print("REMOVING MIDDLE \(toRemove.count) STATUSES")
|
print("REMOVING MIDDLE \(toRemove.count) STATUSES")
|
||||||
// snapshot.insertItems([.gap], beforeItem: toRemove.first!)
|
snapshot.insertItems([.gap], beforeItem: toRemove.first!)
|
||||||
// snapshot.deleteItems(toRemove)
|
snapshot.deleteItems(toRemove)
|
||||||
// self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }))
|
}))
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
|
let rewind = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
if statuses.count > 20 {
|
if statuses.count > 20 {
|
||||||
let toRemove = Array(statuses.dropLast(20))
|
let toRemove = Array(statuses.dropLast(20))
|
||||||
snapshot.deleteItems(toRemove)
|
snapshot.deleteItems(toRemove)
|
||||||
self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first {
|
|
||||||
// self.controller.dataSource.state.newer = .after(id: id, count: nil)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
navigationItem.trailingItemGroups = [
|
||||||
|
UIBarButtonItemGroup(barButtonItems: [split, rewind], representativeItem: nil)
|
||||||
|
]
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
#error("remove me")
|
#error("remove me")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
|
||||||
|
if let indexPath = self?.dataSource.indexPath(for: .gap),
|
||||||
|
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
|
||||||
|
cell.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -127,12 +137,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||||
}
|
}
|
||||||
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
|
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
||||||
cell.fillGap = { [unowned self] direction in
|
cell.showsIndicator = false
|
||||||
Task {
|
|
||||||
await self.controller.fillGap(in: direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||||
guard case .public(let local) = timeline else {
|
guard case .public(let local) = timeline else {
|
||||||
|
@ -276,7 +282,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
var config = ToastConfiguration(title: "Jump to present")
|
var config = ToastConfiguration(title: "Jump to present")
|
||||||
config.edge = .top
|
config.edge = .top
|
||||||
config.systemImageName = "arrow.up"
|
config.systemImageName = "arrow.up"
|
||||||
config.dismissAutomaticallyAfter = 2
|
config.dismissAutomaticallyAfter = 4
|
||||||
config.action = { [unowned self] toast in
|
config.action = { [unowned self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
|
||||||
|
@ -359,7 +365,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .status(id: _, state: _):
|
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -520,6 +526,8 @@ extension TimelineViewController {
|
||||||
snapshot.deleteItems([.gap])
|
snapshot.deleteItems([.gap])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await apply(snapshot, animatingDifferences: !addedItems)
|
||||||
|
|
||||||
case .below:
|
case .below:
|
||||||
let beforeGap = statusItems[..<gapIndex].suffix(20)
|
let beforeGap = statusItems[..<gapIndex].suffix(20)
|
||||||
precondition(!beforeGap.contains(.gap))
|
precondition(!beforeGap.contains(.gap))
|
||||||
|
@ -551,6 +559,34 @@ extension TimelineViewController {
|
||||||
if indexOfLastTimelineItemExistingAboveGap != nil {
|
if indexOfLastTimelineItemExistingAboveGap != nil {
|
||||||
snapshot.deleteItems([.gap])
|
snapshot.deleteItems([.gap])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if addedItems {
|
||||||
|
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
||||||
|
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
||||||
|
snapshotView.layer.zPosition = 1000
|
||||||
|
snapshotView.frame = view.bounds
|
||||||
|
view.addSubview(snapshotView)
|
||||||
|
|
||||||
|
// Yes, these are all load bearing. Setting the contentOffset seems to cause the collection view to recalculate the size
|
||||||
|
// of some cells, thus changing the contentSize and the offset necessary to match to match the bottom offset.
|
||||||
|
// Three DispatchQueue.main.async's seems to be the fewest we can reliably get away with.
|
||||||
|
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
|
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
||||||
|
snapshotView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
|
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
|
||||||
|
@ -559,8 +595,6 @@ extension TimelineViewController {
|
||||||
config.dismissAutomaticallyAfter = 2
|
config.dismissAutomaticallyAfter = 2
|
||||||
showToast(configuration: config, animated: true)
|
showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
await apply(snapshot, animatingDifferences: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
@ -601,7 +635,14 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
selected(status: status.reblog?.id ?? id, state: state.copy())
|
||||||
case .gap, .loadingIndicator, .confirmLoadMore:
|
case .gap:
|
||||||
|
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
||||||
|
cell.showsIndicator = true
|
||||||
|
Task {
|
||||||
|
await controller.fillGap(in: cell.direction)
|
||||||
|
cell.showsIndicator = false
|
||||||
|
}
|
||||||
|
case .loadingIndicator, .confirmLoadMore:
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -206,7 +206,7 @@ class TimelineLikeController<Item> {
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingGap(let token, let direction):
|
case .loadingGap(let token, let direction):
|
||||||
return "loadingGap(\(ObjectIdentifier(token)), \(direction)"
|
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
backgroundColor = .secondarySystemBackground
|
backgroundColor = .systemGroupedBackground
|
||||||
|
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
||||||
|
|
Loading…
Reference in New Issue