mirror of
https://github.com/hyperoslo/Cache.git
synced 2026-04-07 19:17:36 +00:00
Merge remote-tracking branch 'upstream/master'
# Conflicts: # Tests/iOS/Specs/Storage/DiskStorageSpec.swift
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
57506FAE1EC29437009B71E9 /* CacheEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57506FAD1EC29437009B71E9 /* CacheEntry.swift */; };
|
||||
57506FBF1EC2E1B7009B71E9 /* CacheEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57506FAD1EC29437009B71E9 /* CacheEntry.swift */; };
|
||||
57506FC01EC2E1BA009B71E9 /* CacheEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57506FAD1EC29437009B71E9 /* CacheEntry.swift */; };
|
||||
BDEDD3601DBCE5CE007416A6 /* BasicHybridCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5291C6A1C2827FB00B702C9 /* BasicHybridCache.swift */; };
|
||||
BDEDD3611DBCE5CE007416A6 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACACDD1CD0272600567809 /* Cache.swift */; };
|
||||
BDEDD3621DBCE5CE007416A6 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5291C151C28220B00B702C9 /* Config.swift */; };
|
||||
@@ -143,6 +146,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
57506FAD1EC29437009B71E9 /* CacheEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CacheEntry.swift; sourceTree = "<group>"; };
|
||||
BDEDD3561DBCE5B1007416A6 /* Cache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Cache.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BDEDD3781DBCEB8A007416A6 /* Cache-tvOS-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Cache-tvOS-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D5291C151C28220B00B702C9 /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
|
||||
@@ -196,7 +200,7 @@
|
||||
D5ACACCB1CD0207300567809 /* SyncHybridCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncHybridCache.swift; sourceTree = "<group>"; };
|
||||
D5ACACCE1CD0227200567809 /* SyncCacheSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncCacheSpec.swift; sourceTree = "<group>"; };
|
||||
D5ACACD21CD0254300567809 /* SyncHybridCacheSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncHybridCacheSpec.swift; sourceTree = "<group>"; };
|
||||
D5ACACDD1CD0272600567809 /* Cache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
|
||||
D5ACACDD1CD0272600567809 /* Cache.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; tabWidth = 2; };
|
||||
D5DC59E01C20593E003BD79B /* Cache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Cache.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -322,6 +326,7 @@
|
||||
D5291C251C28220B00B702C9 /* MemoryStorage.swift */,
|
||||
D5291C261C28220B00B702C9 /* StorageAware.swift */,
|
||||
D5291C271C28220B00B702C9 /* StorageFactory.swift */,
|
||||
57506FAD1EC29437009B71E9 /* CacheEntry.swift */,
|
||||
);
|
||||
path = Storage;
|
||||
sourceTree = "<group>";
|
||||
@@ -840,6 +845,7 @@
|
||||
BDEDD36D1DBCE5D8007416A6 /* SyncCache.swift in Sources */,
|
||||
BDEDD36A1DBCE5D8007416A6 /* NSDate+Cache.swift in Sources */,
|
||||
BDEDD36F1DBCE5D8007416A6 /* DiskStorage.swift in Sources */,
|
||||
57506FC01EC2E1BA009B71E9 /* CacheEntry.swift in Sources */,
|
||||
D5A138C21EB29BFA00881A20 /* UIImage+Cache.swift in Sources */,
|
||||
BDEDD3601DBCE5CE007416A6 /* BasicHybridCache.swift in Sources */,
|
||||
BDEDD3701DBCE5D8007416A6 /* MemoryStorage.swift in Sources */,
|
||||
@@ -922,6 +928,7 @@
|
||||
D5291D911C283CFB00B702C9 /* String+Cache.swift in Sources */,
|
||||
D5291D901C283CFB00B702C9 /* NSDate+Cache.swift in Sources */,
|
||||
D5A138C41EB29C2100881A20 /* NSImage+Cache.swift in Sources */,
|
||||
57506FBF1EC2E1B7009B71E9 /* CacheEntry.swift in Sources */,
|
||||
D5291D931C283CFB00B702C9 /* DiskStorage.swift in Sources */,
|
||||
D5291D861C283CFB00B702C9 /* BasicHybridCache.swift in Sources */,
|
||||
D5ACACCD1CD0207300567809 /* SyncHybridCache.swift in Sources */,
|
||||
@@ -960,6 +967,7 @@
|
||||
D5291C321C28220B00B702C9 /* StorageKind.swift in Sources */,
|
||||
D5291C301C28220B00B702C9 /* Expiry.swift in Sources */,
|
||||
D5291C331C28220B00B702C9 /* JSON+Cache.swift in Sources */,
|
||||
57506FAE1EC29437009B71E9 /* CacheEntry.swift in Sources */,
|
||||
D5A138C11EB29BFA00881A20 /* UIImage+Cache.swift in Sources */,
|
||||
D5291C2F1C28220B00B702C9 /* Capsule.swift in Sources */,
|
||||
D5291C2D1C28220B00B702C9 /* Config.swift in Sources */,
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
github "krzyzanowskim/CryptoSwift" "0.6.8"
|
||||
github "Quick/Nimble" "v6.1.0"
|
||||
github "Quick/Nimble" "v7.0.0"
|
||||
github "Quick/Quick" "v1.1.0"
|
||||
github "krzyzanowskim/CryptoSwift" "0.6.9"
|
||||
|
||||
@@ -32,16 +32,22 @@ public class BasicHybridCache: NSObject {
|
||||
- Parameter name: A name of the cache
|
||||
- Parameter config: Cache configuration
|
||||
*/
|
||||
public init(name: String, config: Config = Config.defaultConfig) {
|
||||
public convenience init(name: String, config: Config = Config.defaultConfig) {
|
||||
let frontStorage = StorageFactory.resolve(name, kind: config.frontKind, maxSize: UInt(config.maxObjects))
|
||||
let backStorage = StorageFactory.resolve(name, kind: config.backKind, maxSize: config.maxSize)
|
||||
self.init(name: name, frontStorage: frontStorage, backStorage: backStorage, config: config)
|
||||
|
||||
}
|
||||
internal init(name: String, frontStorage: StorageAware, backStorage: StorageAware, config: Config) {
|
||||
self.name = name
|
||||
self.frontStorage = frontStorage
|
||||
self.backStorage = backStorage
|
||||
self.config = config
|
||||
|
||||
frontStorage = StorageFactory.resolve(name, kind: config.frontKind, maxSize: UInt(config.maxObjects))
|
||||
backStorage = StorageFactory.resolve(name, kind: config.backKind, maxSize: config.maxSize)
|
||||
super.init()
|
||||
|
||||
|
||||
let notificationCenter = NotificationCenter.default
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
notificationCenter.addObserver(self, selector: #selector(clearExpiredDataInBackStorage),
|
||||
name: NSNotification.Name.NSApplicationWillTerminate, object: nil)
|
||||
@@ -56,7 +62,7 @@ public class BasicHybridCache: NSObject {
|
||||
name: .UIApplicationDidEnterBackground, object: nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Removes notification center observer.
|
||||
*/
|
||||
@@ -96,20 +102,39 @@ public class BasicHybridCache: NSObject {
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object or nil
|
||||
*/
|
||||
func object<T: Cachable>(forKey key: String, completion: @escaping (_ object: T?) -> Void) {
|
||||
frontStorage.object(key) { [weak self] (object: T?) in
|
||||
if let object = object {
|
||||
completion(object)
|
||||
func object<T: Cachable>(forKey key: String, completion: @escaping (_ object: T?) -> Void){
|
||||
cacheEntry(forKey: key) { (entry: CacheEntry<T>?) in
|
||||
completion(entry?.object)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Tries to retrieve the cache entry from to the front and back cache storages.
|
||||
|
||||
- Parameter key: Unique key to identify the cache entry in the cache
|
||||
- Parameter completion: Completion closure returns cache entry or nil
|
||||
*/
|
||||
func cacheEntry<T: Cachable>(forKey key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void) {
|
||||
frontStorage.cacheEntry(key) { [weak self] (entry: CacheEntry<T>?) in
|
||||
if let entry = entry {
|
||||
completion(entry)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
guard let weakSelf = self else {
|
||||
completion(object)
|
||||
completion(entry)
|
||||
return
|
||||
}
|
||||
|
||||
weakSelf.backStorage.object(key) { (object: T?) in
|
||||
completion(object)
|
||||
|
||||
weakSelf.backStorage.cacheEntry(key) { (entry: CacheEntry<T>?) in
|
||||
guard let entry = entry else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
weakSelf.frontStorage.add(key, object: entry.object, expiry: entry.expiry) { _ in
|
||||
completion(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,17 @@ public final class Cache<T: Cachable>: BasicHybridCache {
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object or nil
|
||||
*/
|
||||
|
||||
public func object(_ key: String, completion: @escaping (_ object: T?) -> Void) {
|
||||
super.object(forKey: key, completion: completion)
|
||||
}
|
||||
|
||||
/**
|
||||
Tries to retrieve the cache entry from to the front and back cache storages.
|
||||
|
||||
- Parameter key: Unique key to identify the cache entry in the cache
|
||||
- Parameter completion: Completion closure returns cache entry or nil
|
||||
*/
|
||||
public func cacheEntry(_ key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void) {
|
||||
super.cacheEntry(forKey: key, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,14 @@ public class HybridCache: BasicHybridCache {
|
||||
public func object<T: Cachable>(_ key: String, completion: @escaping (_ object: T?) -> Void) {
|
||||
super.object(forKey: key, completion: completion)
|
||||
}
|
||||
|
||||
/**
|
||||
Tries to retrieve the cache entry from to the front and back cache storages.
|
||||
|
||||
- Parameter key: Unique key to identify the cache entry in the cache
|
||||
- Parameter completion: Completion closure returns cache entry or nil
|
||||
*/
|
||||
public func cacheEntry<T: Cachable>(_ key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void) {
|
||||
super.cacheEntry(forKey: key, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
public struct CacheEntry<T> {
|
||||
public let object: T
|
||||
public let expiry: Expiry
|
||||
}
|
||||
@@ -87,30 +87,62 @@ public final class DiskStorage: StorageAware {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Tries to retrieve the object from the disk storage.
|
||||
|
||||
Gets information about the cached object.
|
||||
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object or nil
|
||||
*/
|
||||
public func object<T: Cachable>(_ key: String, completion: @escaping (_ object: T?) -> Void) {
|
||||
cacheEntry(key) { (entry: CacheEntry<T>?) in
|
||||
completion(entry?.object)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Get cache entry which includes object with metadata.
|
||||
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object wrapper with metadata or nil
|
||||
*/
|
||||
public func cacheEntry<T: Cachable>(_ key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void) {
|
||||
readQueue.async { [weak self] in
|
||||
guard let weakSelf = self else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let filePath = weakSelf.filePath(key)
|
||||
var cachedObject: T?
|
||||
|
||||
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) {
|
||||
cachedObject = T.decode(data) as? T
|
||||
}
|
||||
|
||||
completion(cachedObject)
|
||||
|
||||
if let cachedObject = cachedObject,
|
||||
let expiry = weakSelf.cachedObjectExpiry(path: filePath) {
|
||||
|
||||
completion(CacheEntry(object: cachedObject, expiry: expiry))
|
||||
return
|
||||
}
|
||||
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func cachedObjectExpiry(path: String) -> Expiry? {
|
||||
do {
|
||||
let attributes = try fileManager.attributesOfItem(atPath: path)
|
||||
|
||||
guard let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date else {
|
||||
return nil
|
||||
}
|
||||
return Expiry.date(modificationDate)
|
||||
} catch {}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
Removes the object from the cache by the given key.
|
||||
|
||||
@@ -64,26 +64,47 @@ public final class MemoryStorage: StorageAware {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Tries to retrieve the object from the memory storage.
|
||||
|
||||
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object or nil
|
||||
*/
|
||||
|
||||
public func object<T: Cachable>(_ key: String, completion: @escaping (_ object: T?) -> Void) {
|
||||
cacheEntry(key) { (entry: CacheEntry<T>?) in
|
||||
completion(entry?.object)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Get cache entry which includes object with metadata.
|
||||
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object wrapper with metadata or nil
|
||||
*/
|
||||
public func cacheEntry<T: Cachable>(_ key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void) {
|
||||
readQueue.async { [weak self] in
|
||||
guard let weakSelf = self else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let capsule = weakSelf.cache.object(forKey: key as AnyObject) as? Capsule
|
||||
completion(capsule?.object as? T)
|
||||
|
||||
if let capsule = capsule {
|
||||
weakSelf.removeIfExpired(key, capsule: capsule)
|
||||
|
||||
guard let capsule = weakSelf.cache.object(forKey: key as AnyObject) as? Capsule else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var entry: CacheEntry<T>?
|
||||
|
||||
if let object = capsule.object as? T {
|
||||
entry = CacheEntry(object: object, expiry: Expiry.date(capsule.expiryDate))
|
||||
}
|
||||
|
||||
completion(entry)
|
||||
|
||||
weakSelf.removeIfExpired(key, capsule: capsule)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ public protocol CacheAware {
|
||||
*/
|
||||
func add<T: Cachable>(_ key: String, object: T, expiry: Expiry, completion: (() -> Void)?)
|
||||
|
||||
/**
|
||||
Get cache entry which includes object with metadata.
|
||||
|
||||
- Parameter key: Unique key to identify the object in the cache
|
||||
- Parameter completion: Completion closure returns object wrapper with metadata or nil
|
||||
*/
|
||||
func cacheEntry<T: Cachable>(_ key: String, completion: @escaping (_ object: CacheEntry<T>?) -> Void)
|
||||
|
||||
/**
|
||||
Tries to retrieve the object from the cache.
|
||||
|
||||
|
||||
@@ -71,11 +71,29 @@ class CacheSpec: QuickSpec {
|
||||
self.waitForExpectations(timeout: 8.0, handler:nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
describe("#cacheEntry") {
|
||||
it("resolves cache entry") {
|
||||
waitUntil(timeout: 4.0) { done in
|
||||
let expiryDate = Date()
|
||||
|
||||
cache.add(key, object: object, expiry: .date(expiryDate)) {
|
||||
cache.cacheEntry(key) { (entry: CacheEntry<User>?) in
|
||||
expect(entry?.object.firstName).to(equal(object.firstName))
|
||||
expect(entry?.object.lastName).to(equal(object.lastName))
|
||||
expect(entry?.expiry.date).to(equal(expiryDate))
|
||||
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("#object") {
|
||||
it("resolves cached object") {
|
||||
let expectation = self.expectation(description: "Object Expectation")
|
||||
|
||||
|
||||
cache.add(key, object: object) {
|
||||
cache.object(key) { (receivedObject: User?) in
|
||||
expect(receivedObject?.firstName).to(equal(object.firstName))
|
||||
@@ -83,9 +101,37 @@ class CacheSpec: QuickSpec {
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
self.waitForExpectations(timeout: 4.0, handler:nil)
|
||||
}
|
||||
|
||||
it("should resolve from disk and set in-memory cache if object not in-memory") {
|
||||
let frontStorage = MemoryStorage(name: "MemoryStorage")
|
||||
let backStorage = DiskStorage(name: "DiskStorage")
|
||||
let config = Config.defaultConfig
|
||||
let key = "myusernamedjohn"
|
||||
let object = SpecHelper.user
|
||||
|
||||
let cache = Cache<User>(name: "MyCache", frontStorage: frontStorage, backStorage: backStorage, config: config)
|
||||
|
||||
waitUntil(timeout: 4.0) { done in
|
||||
|
||||
backStorage.add(key, object: object) {
|
||||
|
||||
cache.object(key) { (receivedObject: User?) in
|
||||
|
||||
expect(receivedObject?.firstName).to(equal(object.firstName))
|
||||
expect(receivedObject?.lastName).to(equal(object.lastName))
|
||||
|
||||
frontStorage.object(key) { (inmemoryCachedUser: User?) in
|
||||
expect(inmemoryCachedUser?.firstName).to(equal(object.firstName))
|
||||
expect(inmemoryCachedUser?.lastName).to(equal(object.lastName))
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("#remove") {
|
||||
|
||||
@@ -67,6 +67,39 @@ class DiskStorageSpec: QuickSpec {
|
||||
}
|
||||
}
|
||||
|
||||
describe("#cacheEntry") {
|
||||
it("returns nil if entry doesn't exist") {
|
||||
let storage = DiskStorage(name: name)
|
||||
|
||||
waitUntil(timeout: 2.0) { done in
|
||||
storage.cacheEntry(key) { (entry: CacheEntry<User>?) in
|
||||
expect(entry).to(beNil())
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("returns entry if object exists") {
|
||||
let storage = DiskStorage(name: name)
|
||||
|
||||
waitUntil(timeout: 2.0) { done in
|
||||
|
||||
storage.add(key, object: object) {
|
||||
storage.cacheEntry(key) { (entry: CacheEntry<User>?) in
|
||||
|
||||
let attributes = try! fileManager.attributesOfItem(atPath: storage.filePath(key))
|
||||
let expiry = Expiry.date(attributes[FileAttributeKey.modificationDate] as! Date)
|
||||
|
||||
expect(entry?.object.firstName).to(equal(object.firstName))
|
||||
expect(entry?.object.lastName).to(equal(object.lastName))
|
||||
expect(entry?.expiry.date).to(equal(expiry.date))
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("#object") {
|
||||
it("resolves cached object") {
|
||||
let expectation = self.expectation(description: "Object Expectation")
|
||||
|
||||
@@ -47,7 +47,38 @@ class MemoryStorageSpec: QuickSpec {
|
||||
self.waitForExpectations(timeout: 2.0, handler:nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
describe("#cacheEntry") {
|
||||
it("returns nil if entry doesn't exist") {
|
||||
let storage = DiskStorage(name: name)
|
||||
|
||||
waitUntil(timeout: 2.0) { done in
|
||||
storage.cacheEntry(key) { (entry: CacheEntry<User>?) in
|
||||
expect(entry).to(beNil())
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("returns entry if object exists") {
|
||||
let storage = MemoryStorage(name: name)
|
||||
let expiry = Expiry.date(Date())
|
||||
|
||||
waitUntil(timeout: 2.0) { done in
|
||||
|
||||
storage.add(key, object: object, expiry: expiry) {
|
||||
storage.cacheEntry(key) { (entry: CacheEntry<User>?) in
|
||||
|
||||
expect(entry?.object.firstName).to(equal(object.firstName))
|
||||
expect(entry?.object.lastName).to(equal(object.lastName))
|
||||
expect(entry?.expiry.date).to(equal(expiry.date))
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("#object") {
|
||||
it("resolves cached object") {
|
||||
let expectation = self.expectation(description: "Object Expectation")
|
||||
|
||||
Reference in New Issue
Block a user