Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01733b358c | |||
| 027143499c | |||
| ae1b5ee790 | |||
| 0c0a0ea451 | |||
| 8d090904b4 | |||
| 8d00d5fa16 | |||
| f2bc1cab27 | |||
| 83b99f16b1 |
+14
-7
@@ -7,11 +7,23 @@ disabled_rules:
|
||||
- identifier_name
|
||||
- multiple_closures_with_trailing_closure
|
||||
- class_delegate_protocol
|
||||
- force_unwrapping
|
||||
- force_try
|
||||
- force_cast
|
||||
- function_parameter_count
|
||||
- discouraged_optional_collection
|
||||
- shorthand_operator
|
||||
- reduce_boolean
|
||||
- weak_delegate
|
||||
- nesting
|
||||
- closure_end_indentation
|
||||
- function_default_parameter_at_end
|
||||
- unowned_variable_capture
|
||||
- legacy_constructor
|
||||
opt_in_rules:
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- closure_spacing
|
||||
- closure_end_indentation
|
||||
- closure_body_length
|
||||
- collection_alignment
|
||||
- contains_over_filter_is_empty
|
||||
@@ -27,8 +39,6 @@ opt_in_rules:
|
||||
- file_name_no_space
|
||||
- first_where
|
||||
- flatmap_over_map_reduce
|
||||
- force_unwrapping
|
||||
- function_default_parameter_at_end
|
||||
- implicitly_unwrapped_optional
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
@@ -43,7 +53,6 @@ opt_in_rules:
|
||||
- sorted_first_last
|
||||
- switch_case_on_newline
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- unowned_variable_capture
|
||||
- unused_declaration
|
||||
- unused_import
|
||||
- vertical_whitespace_opening_braces
|
||||
@@ -51,12 +60,10 @@ opt_in_rules:
|
||||
- enum_case_associated_values_counts
|
||||
- legacy_multiple
|
||||
- legacy_random
|
||||
force_cast: error
|
||||
force_unwrapping: error
|
||||
indentation: 2
|
||||
file_length:
|
||||
- 2500
|
||||
- 3000
|
||||
large_tuple:
|
||||
- 5
|
||||
- 6
|
||||
- 6
|
||||
@@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file
|
||||
* [**327**](https://github.com/Juanpe/SkeletonView/pull/327): Add SwiftLint - [@Juanpe](https://github.com/Juanpe)
|
||||
* [**329**](https://github.com/Juanpe/SkeletonView/pull/329): Spanish README 🇪🇸 - [@Juanpe](https://github.com/Juanpe)
|
||||
|
||||
#### 🩹 Bug fixes
|
||||
* [**336**](https://github.com/Juanpe/SkeletonView/pull/336): Not replace text when the skeleton disappears. Solved issues: [#296](https://github.com/Juanpe/SkeletonView/issues/296), [#330](https://github.com/Juanpe/SkeletonView/issues/330) - [@Juanpe](https://github.com/Juanpe)
|
||||
* [**337**](https://github.com/Juanpe/SkeletonView/pull/337): RTL support. Solved issues: [#143](https://github.com/Juanpe/SkeletonView/issues/143) - [@Juanpe](https://github.com/Juanpe)
|
||||
|
||||
## 📦 [1.9](https://github.com/Juanpe/SkeletonView/releases/tag/1.9)
|
||||
|
||||
#### 🩹 Bug fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "SkeletonView"
|
||||
s.version = "1.9"
|
||||
s.version = "1.10.0"
|
||||
s.summary = "An elegant way to show users that something is happening and also prepare them to which contents he is waiting"
|
||||
s.description = <<-DESC
|
||||
Today almost all apps have async processes, as API requests, long runing processes, etc. And while the processes are working, usually developers place a loading view to show users that something is going on.
|
||||
|
||||
@@ -9,25 +9,30 @@ class SkeletonLayerBuilder {
|
||||
var colors: [UIColor] = []
|
||||
var holder: UIView?
|
||||
|
||||
@discardableResult
|
||||
func setSkeletonType(_ type: SkeletonType) -> SkeletonLayerBuilder {
|
||||
self.skeletonType = type
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addColor(_ color: UIColor) -> SkeletonLayerBuilder {
|
||||
return addColors([color])
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addColors(_ colors: [UIColor]) -> SkeletonLayerBuilder {
|
||||
self.colors.append(contentsOf: colors)
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setHolder(_ holder: UIView) -> SkeletonLayerBuilder {
|
||||
self.holder = holder
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func build() -> SkeletonLayer? {
|
||||
guard let type = skeletonType,
|
||||
let holder = holder
|
||||
|
||||
@@ -12,41 +12,55 @@ class SkeletonMultilineLayerBuilder {
|
||||
var cornerRadius: Int?
|
||||
var multilineSpacing: CGFloat = SkeletonAppearance.default.multilineSpacing
|
||||
var paddingInsets: UIEdgeInsets = .zero
|
||||
var isRTL: Bool = false
|
||||
|
||||
@discardableResult
|
||||
func setSkeletonType(_ type: SkeletonType) -> SkeletonMultilineLayerBuilder {
|
||||
self.skeletonType = type
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setIndex(_ index: Int) -> SkeletonMultilineLayerBuilder {
|
||||
self.index = index
|
||||
return self
|
||||
}
|
||||
|
||||
func setHeight(_ height: CGFloat) -> SkeletonMultilineLayerBuilder {
|
||||
self.height = height
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setHeight(_ height: CGFloat) -> SkeletonMultilineLayerBuilder {
|
||||
self.height = height
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setWidth(_ width: CGFloat) -> SkeletonMultilineLayerBuilder {
|
||||
self.width = width
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setCornerRadius(_ radius: Int) -> SkeletonMultilineLayerBuilder {
|
||||
self.cornerRadius = radius
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setMultilineSpacing(_ spacing: CGFloat) -> SkeletonMultilineLayerBuilder {
|
||||
self.multilineSpacing = spacing
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPadding(_ insets: UIEdgeInsets) -> SkeletonMultilineLayerBuilder {
|
||||
self.paddingInsets = insets
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setIsRTL(_ isRTL: Bool) -> SkeletonMultilineLayerBuilder {
|
||||
self.isRTL = isRTL
|
||||
return self
|
||||
}
|
||||
|
||||
func build() -> CALayer? {
|
||||
guard let type = skeletonType,
|
||||
@@ -59,7 +73,11 @@ class SkeletonMultilineLayerBuilder {
|
||||
let layer = type.layer
|
||||
layer.anchorPoint = .zero
|
||||
layer.name = CALayer.skeletonSubLayersName
|
||||
layer.updateLayerFrame(for: index, size: CGSize(width: width, height: height), multilineSpacing: self.multilineSpacing, paddingInsets: paddingInsets)
|
||||
layer.updateLayerFrame(for: index,
|
||||
size: CGSize(width: width, height: height),
|
||||
multilineSpacing: multilineSpacing,
|
||||
paddingInsets: paddingInsets,
|
||||
isRTL: isRTL)
|
||||
|
||||
layer.cornerRadius = CGFloat(radius)
|
||||
layer.masksToBounds = true
|
||||
|
||||
@@ -36,6 +36,15 @@ struct SkeletonMultilinesLayerConfig {
|
||||
var multilineCornerRadius: Int
|
||||
var multilineSpacing: CGFloat
|
||||
var paddingInsets: UIEdgeInsets
|
||||
var isRTL: Bool
|
||||
|
||||
/// Returns padding insets taking into account if the RTL is activated
|
||||
var calculatedPaddingInsets: UIEdgeInsets {
|
||||
UIEdgeInsets(top: paddingInsets.top,
|
||||
left: paddingInsets.right,
|
||||
bottom: paddingInsets.bottom,
|
||||
right: paddingInsets.left)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Skeleton sublayers
|
||||
@@ -60,6 +69,7 @@ extension CALayer {
|
||||
.setMultilineSpacing(config.multilineSpacing)
|
||||
.setPadding(config.paddingInsets)
|
||||
.setHeight(height)
|
||||
.setIsRTL(config.isRTL)
|
||||
|
||||
(0..<numberOfSublayers).forEach { index in
|
||||
let width = calculatedWidthForLine(at: index, totalLines: numberOfSublayers, lastLineFillPercent: config.lastLineFillPercent, paddingInsets: config.paddingInsets)
|
||||
@@ -76,7 +86,7 @@ extension CALayer {
|
||||
let currentSkeletonSublayers = skeletonSublayers
|
||||
let numberOfSublayers = currentSkeletonSublayers.count
|
||||
let lastLineFillPercent = config.lastLineFillPercent
|
||||
let paddingInsets = config.paddingInsets
|
||||
let paddingInsets = config.calculatedPaddingInsets
|
||||
let multilineSpacing = config.multilineSpacing
|
||||
var height = config.lineHeight ?? SkeletonAppearance.default.multilineHeight
|
||||
|
||||
@@ -86,7 +96,11 @@ extension CALayer {
|
||||
|
||||
for (index, layer) in currentSkeletonSublayers.enumerated() {
|
||||
let width = calculatedWidthForLine(at: index, totalLines: numberOfSublayers, lastLineFillPercent: lastLineFillPercent, paddingInsets: paddingInsets)
|
||||
layer.updateLayerFrame(for: index, size: CGSize(width: width, height: height), multilineSpacing: multilineSpacing, paddingInsets: paddingInsets)
|
||||
layer.updateLayerFrame(for: index,
|
||||
size: CGSize(width: width, height: height),
|
||||
multilineSpacing: multilineSpacing,
|
||||
paddingInsets: paddingInsets,
|
||||
isRTL: config.isRTL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +112,14 @@ extension CALayer {
|
||||
return width
|
||||
}
|
||||
|
||||
func updateLayerFrame(for index: Int, size: CGSize, multilineSpacing: CGFloat, paddingInsets: UIEdgeInsets) {
|
||||
func updateLayerFrame(for index: Int, size: CGSize, multilineSpacing: CGFloat, paddingInsets: UIEdgeInsets, isRTL: Bool) {
|
||||
let spaceRequiredForEachLine = SkeletonAppearance.default.multilineHeight + multilineSpacing
|
||||
frame = CGRect(x: paddingInsets.left, y: CGFloat(index) * spaceRequiredForEachLine + paddingInsets.top, width: size.width, height: size.height - paddingInsets.bottom - paddingInsets.top)
|
||||
let newFrame = CGRect(x: paddingInsets.left,
|
||||
y: CGFloat(index) * spaceRequiredForEachLine + paddingInsets.top,
|
||||
width: size.width,
|
||||
height: size.height - paddingInsets.bottom - paddingInsets.top)
|
||||
|
||||
frame = flipRectForRTLIfNeeded(newFrame, isRTL: isRTL)
|
||||
}
|
||||
|
||||
private func calculateNumLines(for config: SkeletonMultilinesLayerConfig) -> Int {
|
||||
@@ -109,6 +128,14 @@ extension CALayer {
|
||||
if config.lines != 0, config.lines <= numberOfSublayers { numberOfSublayers = config.lines }
|
||||
return numberOfSublayers
|
||||
}
|
||||
|
||||
private func flipRectForRTLIfNeeded(_ rect: CGRect, isRTL: Bool) -> CGRect {
|
||||
var newRect = rect
|
||||
if isRTL {
|
||||
newRect.origin.x = (superlayer?.bounds.width ?? 0) - rect.origin.x - rect.width
|
||||
}
|
||||
return newRect
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Animations
|
||||
@@ -126,24 +153,24 @@ public extension CALayer {
|
||||
return pulseAnimation
|
||||
}
|
||||
|
||||
var sliding: CAAnimation {
|
||||
let startPointAnim = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.startPoint))
|
||||
startPointAnim.fromValue = CGPoint(x: -1, y: 0.5)
|
||||
startPointAnim.toValue = CGPoint(x: 1, y: 0.5)
|
||||
|
||||
let endPointAnim = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.endPoint))
|
||||
endPointAnim.fromValue = CGPoint(x: 0, y: 0.5)
|
||||
endPointAnim.toValue = CGPoint(x: 2, y: 0.5)
|
||||
|
||||
let animGroup = CAAnimationGroup()
|
||||
animGroup.animations = [startPointAnim, endPointAnim]
|
||||
animGroup.duration = 1.5
|
||||
animGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
||||
animGroup.repeatCount = .infinity
|
||||
animGroup.isRemovedOnCompletion = false
|
||||
|
||||
return animGroup
|
||||
}
|
||||
// var sliding: CAAnimation {
|
||||
// let startPointAnim = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.startPoint))
|
||||
// startPointAnim.fromValue = CGPoint(x: -1, y: 0.5)
|
||||
// startPointAnim.toValue = CGPoint(x: 1, y: 0.5)
|
||||
//
|
||||
// let endPointAnim = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.endPoint))
|
||||
// endPointAnim.fromValue = CGPoint(x: 0, y: 0.5)
|
||||
// endPointAnim.toValue = CGPoint(x: 2, y: 0.5)
|
||||
//
|
||||
// let animGroup = CAAnimationGroup()
|
||||
// animGroup.animations = [startPointAnim, endPointAnim]
|
||||
// animGroup.duration = 1.5
|
||||
// animGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
||||
// animGroup.repeatCount = .infinity
|
||||
// animGroup.isRemovedOnCompletion = false
|
||||
//
|
||||
// return animGroup
|
||||
// }
|
||||
|
||||
func playAnimation(_ anim: SkeletonLayerAnimation, key: String, completion: (() -> Void)? = nil) {
|
||||
skeletonSublayers.recursiveSearch(leafBlock: {
|
||||
|
||||
@@ -50,4 +50,12 @@ extension UIView {
|
||||
var nonContentSizeLayoutConstraints: [NSLayoutConstraint] {
|
||||
return constraints.filter({ "\(type(of: $0))" != "NSContentSizeLayoutConstraint" })
|
||||
}
|
||||
|
||||
var isRTL: Bool {
|
||||
if #available(iOS 10.0, *), #available(tvOS 10.0, *) {
|
||||
return effectiveUserInterfaceLayoutDirection == .rightToLeft
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ extension UIView {
|
||||
@objc func prepareViewForSkeleton() {
|
||||
startTransition { [weak self] in
|
||||
self?.backgroundColor = .clear
|
||||
self?.isUserInteractionEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +51,6 @@ extension UIButton {
|
||||
backgroundColor = .clear
|
||||
startTransition { [weak self] in
|
||||
self?.setTitle(nil, for: .normal)
|
||||
self?.isUserInteractionEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,15 @@ extension UIView: Recoverable {
|
||||
}
|
||||
|
||||
@objc func recoverViewState(forced: Bool) {
|
||||
guard let safeViewState = viewState else { return }
|
||||
guard let storedViewState = viewState else { return }
|
||||
|
||||
startTransition { [weak self] in
|
||||
self?.layer.cornerRadius = safeViewState.cornerRadius
|
||||
self?.layer.masksToBounds = safeViewState.clipToBounds
|
||||
self?.layer.cornerRadius = storedViewState.cornerRadius
|
||||
self?.layer.masksToBounds = storedViewState.clipToBounds
|
||||
self?.isUserInteractionEnabled = storedViewState.isUserInteractionsEnabled
|
||||
|
||||
if safeViewState.backgroundColor != self?.backgroundColor || forced {
|
||||
self?.backgroundColor = safeViewState.backgroundColor
|
||||
if self?.backgroundColor == .clear || forced {
|
||||
self?.backgroundColor = storedViewState.backgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +52,11 @@ extension UILabel {
|
||||
override func recoverViewState(forced: Bool) {
|
||||
super.recoverViewState(forced: forced)
|
||||
startTransition { [weak self] in
|
||||
self?.textColor = self?.labelState?.textColor
|
||||
self?.text = self?.labelState?.text
|
||||
self?.isUserInteractionEnabled = self?.labelState?.isUserInteractionsEnabled ?? false
|
||||
guard let storedLabelState = self?.labelState else { return }
|
||||
|
||||
if self?.textColor == .clear || forced {
|
||||
self?.textColor = storedLabelState.textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +75,11 @@ extension UITextView {
|
||||
override func recoverViewState(forced: Bool) {
|
||||
super.recoverViewState(forced: forced)
|
||||
startTransition { [weak self] in
|
||||
self?.textColor = self?.textState?.textColor
|
||||
self?.text = self?.textState?.text
|
||||
self?.isUserInteractionEnabled = self?.textState?.isUserInteractionsEnabled ?? false
|
||||
guard let storedLabelState = self?.textState else { return }
|
||||
|
||||
if self?.textColor == .clear || forced {
|
||||
self?.textColor = storedLabelState.textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,8 +117,9 @@ extension UIButton {
|
||||
override func recoverViewState(forced: Bool) {
|
||||
super.recoverViewState(forced: forced)
|
||||
startTransition { [weak self] in
|
||||
self?.setTitle(self?.buttonState?.title, for: .normal)
|
||||
self?.isUserInteractionEnabled = self?.buttonState?.isUserInteractionsEnabled ?? false
|
||||
if self?.title(for: .normal) == nil {
|
||||
self?.setTitle(self?.buttonState?.title, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,29 +12,25 @@ struct RecoverableViewState {
|
||||
var backgroundColor: UIColor?
|
||||
var cornerRadius: CGFloat
|
||||
var clipToBounds: Bool
|
||||
var isUserInteractionsEnabled: Bool
|
||||
|
||||
init(view: UIView) {
|
||||
self.backgroundColor = view.backgroundColor
|
||||
self.clipToBounds = view.layer.masksToBounds
|
||||
self.cornerRadius = view.layer.cornerRadius
|
||||
self.isUserInteractionsEnabled = view.isUserInteractionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
struct RecoverableTextViewState {
|
||||
var text: String?
|
||||
var textColor: UIColor?
|
||||
var isUserInteractionsEnabled: Bool
|
||||
|
||||
init(view: UILabel) {
|
||||
self.textColor = view.textColor
|
||||
self.text = view.text
|
||||
self.isUserInteractionsEnabled = view.isUserInteractionEnabled
|
||||
}
|
||||
|
||||
init(view: UITextView) {
|
||||
self.textColor = view.textColor
|
||||
self.text = view.text
|
||||
self.isUserInteractionsEnabled = view.isUserInteractionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +44,8 @@ struct RecoverableImageViewState {
|
||||
|
||||
struct RecoverableButtonViewState {
|
||||
var title: String?
|
||||
var isUserInteractionsEnabled: Bool
|
||||
|
||||
init(view: UIButton) {
|
||||
self.title = view.titleLabel?.text
|
||||
self.isUserInteractionsEnabled = view.isUserInteractionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,12 @@ struct SkeletonConfig {
|
||||
/// Transition style
|
||||
var transition: SkeletonTransitionStyle
|
||||
|
||||
init(
|
||||
type: SkeletonType,
|
||||
colors: [UIColor],
|
||||
gradientDirection: GradientDirection? = nil,
|
||||
animated: Bool = false,
|
||||
animation: SkeletonLayerAnimation? = nil,
|
||||
transition: SkeletonTransitionStyle = .crossDissolve(0.25)
|
||||
) {
|
||||
init(type: SkeletonType,
|
||||
colors: [UIColor],
|
||||
gradientDirection: GradientDirection? = nil,
|
||||
animated: Bool = false,
|
||||
animation: SkeletonLayerAnimation? = nil,
|
||||
transition: SkeletonTransitionStyle = .crossDissolve(0.25)) {
|
||||
self.type = type
|
||||
self.colors = colors
|
||||
self.gradientDirection = gradientDirection
|
||||
|
||||
@@ -23,12 +23,12 @@ public enum SkeletonType {
|
||||
}
|
||||
}
|
||||
|
||||
var layerAnimation: SkeletonLayerAnimation {
|
||||
func defaultLayerAnimation(isRTL: Bool) -> SkeletonLayerAnimation {
|
||||
switch self {
|
||||
case .solid:
|
||||
return { $0.pulse }
|
||||
case .gradient:
|
||||
return { $0.sliding }
|
||||
return { SkeletonAnimationBuilder().makeSlidingAnimation(withDirection: isRTL ? .rightLeft : .leftRight) }()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ struct SkeletonLayer {
|
||||
lastLineFillPercent: textView.lastLineFillingPercent,
|
||||
multilineCornerRadius: textView.multilineCornerRadius,
|
||||
multilineSpacing: textView.multilineSpacing,
|
||||
paddingInsets: textView.paddingInsets)
|
||||
paddingInsets: textView.paddingInsets,
|
||||
isRTL: holder?.isRTL ?? false)
|
||||
|
||||
maskLayer.addMultilinesLayers(for: config)
|
||||
}
|
||||
@@ -103,7 +104,8 @@ struct SkeletonLayer {
|
||||
lastLineFillPercent: textView.lastLineFillingPercent,
|
||||
multilineCornerRadius: textView.multilineCornerRadius,
|
||||
multilineSpacing: textView.multilineSpacing,
|
||||
paddingInsets: textView.paddingInsets)
|
||||
paddingInsets: textView.paddingInsets,
|
||||
isRTL: holder?.isRTL ?? false)
|
||||
|
||||
maskLayer.updateMultilinesLayers(for: config)
|
||||
}
|
||||
@@ -119,7 +121,7 @@ struct SkeletonLayer {
|
||||
|
||||
extension SkeletonLayer {
|
||||
func start(_ anim: SkeletonLayerAnimation? = nil, completion: (() -> Void)? = nil) {
|
||||
let animation = anim ?? type.layerAnimation
|
||||
let animation = anim ?? type.defaultLayerAnimation(isRTL: holder?.isRTL ?? false)
|
||||
contentLayer.playAnimation(animation, key: "skeletonAnimation", completion: completion)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user