mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Enabling Extensions now possible in SwiftUI
This commit is contained in:
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
struct ExtensionInspectorView: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var showDeactivateConfirmation: Bool = false
|
||||
var extensionPoint: ExtensionPoint?
|
||||
|
||||
@@ -25,14 +26,15 @@ struct ExtensionInspectorView: View {
|
||||
Button(role: .destructive) {
|
||||
showDeactivateConfirmation = true
|
||||
} label: {
|
||||
Text("DEACTIVATE_EXTENSION_TITLE", tableName: "Settings")
|
||||
Text("DEACTIVATE_EXTENSION_BUTTON_TITLE", tableName: "Buttons")
|
||||
}
|
||||
.confirmationDialog(Text("DEACTIVATE_EXTENSION_TITLE", tableName: "Settings") , isPresented: $showDeactivateConfirmation, titleVisibility: .visible) {
|
||||
|
||||
Button(role: .destructive) {
|
||||
ExtensionPointManager.shared.deactivateExtensionPoint(extensionPoint!.extensionPointID)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("DEACTIVATE_BUTTON_TITLE", tableName: "Buttons")
|
||||
Text("DEACTIVATE_EXTENSION_BUTTON_TITLE", tableName: "Buttons")
|
||||
}
|
||||
|
||||
Button(role: .cancel) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// AddExtensionPointViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 4/16/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol AddExtensionPointDismissDelegate: UIViewController {
|
||||
func dismiss()
|
||||
}
|
||||
|
||||
class AddExtensionPointViewController: UITableViewController, AddExtensionPointDismissDelegate {
|
||||
|
||||
private var availableExtensionPointTypes = [ExtensionPoint.Type]()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
availableExtensionPointTypes = ExtensionPointManager.shared.availableExtensionPointTypes.sorted(by: { $0.title < $1.title })
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return availableExtensionPointTypes.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsExtensionTableViewCell", for: indexPath) as! SettingsComboTableViewCell
|
||||
|
||||
let extensionPointType = availableExtensionPointTypes[indexPath.row]
|
||||
cell.comboNameLabel?.text = extensionPointType.title
|
||||
cell.comboImage?.image = extensionPointType.image
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
return NSLocalizedString("Feed Provider", comment: "Feed Provider Header")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
return NSLocalizedString("Feed Providers allow you to subscribe to some pages as if they were RSS feeds.", comment: "Feed Provider Footer")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let navController = UIStoryboard.settings.instantiateViewController(withIdentifier: "EnableExtensionPointNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let enableViewController = navController.topViewController as! EnableExtensionPointViewController
|
||||
enableViewController.delegate = self
|
||||
enableViewController.extensionPointType = availableExtensionPointTypes[indexPath.row]
|
||||
present(navController, animated: true)
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
navigationController?.popViewController(animated: false)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//
|
||||
// EnableExtensionPointViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 4/16/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AuthenticationServices
|
||||
import Account
|
||||
import OAuthSwift
|
||||
import Secrets
|
||||
import SwiftUI
|
||||
|
||||
struct EnableExtensionPointViewWrapper: UIViewControllerRepresentable {
|
||||
|
||||
var extensionPoint: ExtensionPoint.Type?
|
||||
|
||||
func makeUIViewController(context: Context) -> EnableExtensionPointViewController {
|
||||
let controller = UIStoryboard.settings.instantiateViewController(withIdentifier: "EnableExtensionPointViewController") as! EnableExtensionPointViewController
|
||||
controller.extensionPointType = extensionPoint
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: EnableExtensionPointViewController, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
typealias UIViewControllerType = EnableExtensionPointViewController
|
||||
|
||||
}
|
||||
|
||||
class EnableExtensionPointViewController: UITableViewController {
|
||||
|
||||
@IBOutlet weak var extensionDescription: UILabel!
|
||||
|
||||
private var callbackURL: URL? = nil
|
||||
private var oauth: OAuthSwift?
|
||||
|
||||
weak var delegate: AddExtensionPointDismissDelegate?
|
||||
var extensionPointType: ExtensionPoint.Type?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
navigationItem.title = extensionPointType?.title
|
||||
extensionDescription.attributedText = extensionPointType?.description
|
||||
tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
|
||||
}
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
delegate?.dismiss()
|
||||
}
|
||||
|
||||
@IBAction func enable(_ sender: Any) {
|
||||
guard let extensionPointType = extensionPointType else { return }
|
||||
|
||||
if let oauth1 = extensionPointType as? OAuth1SwiftProvider.Type {
|
||||
enableOauth1(oauth1)
|
||||
} else if let oauth2 = extensionPointType as? OAuth2SwiftProvider.Type {
|
||||
enableOauth2(oauth2)
|
||||
} else {
|
||||
ExtensionPointManager.shared.activateExtensionPoint(extensionPointType) { result in
|
||||
if case .failure(let error) = result {
|
||||
self.presentError(error)
|
||||
}
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
self.delegate?.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
if section == 0 {
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView
|
||||
headerView.imageView.image = extensionPointType?.image
|
||||
return headerView
|
||||
} else {
|
||||
return super.tableView(tableView, viewForHeaderInSection: section)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension EnableExtensionPointViewController: OAuthSwiftURLHandlerType {
|
||||
|
||||
public func handle(_ url: URL) {
|
||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURL!.scheme, completionHandler: { (url, error) in
|
||||
if let callbackedURL = url {
|
||||
OAuth1Swift.handle(url: callbackedURL)
|
||||
}
|
||||
|
||||
guard let error = error else { return }
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
self.delegate?.dismiss()
|
||||
}
|
||||
|
||||
if case ASWebAuthenticationSessionError.canceledLogin = error {
|
||||
print("Login cancelled.")
|
||||
} else {
|
||||
self.presentError(error)
|
||||
}
|
||||
})
|
||||
|
||||
session.presentationContextProvider = self
|
||||
if !session.start() {
|
||||
print("Session failed to start!!!")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension EnableExtensionPointViewController: ASWebAuthenticationPresentationContextProviding {
|
||||
|
||||
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return view.window!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension EnableExtensionPointViewController {
|
||||
|
||||
func enableOauth1(_ provider: OAuth1SwiftProvider.Type) {
|
||||
callbackURL = provider.callbackURL
|
||||
|
||||
let oauth1 = provider.oauth1Swift
|
||||
self.oauth = oauth1
|
||||
oauth1.authorizeURLHandler = self
|
||||
|
||||
oauth1.authorize(withCallbackURL: callbackURL!) { [weak self] result in
|
||||
guard let self = self, let extensionPointType = self.extensionPointType else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let tokenSuccess):
|
||||
ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { result in
|
||||
if case .failure(let error) = result {
|
||||
self.presentError(error)
|
||||
}
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
self.delegate?.dismiss()
|
||||
}
|
||||
case .failure(let oauthSwiftError):
|
||||
self.presentError(oauthSwiftError)
|
||||
}
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
}
|
||||
}
|
||||
|
||||
func enableOauth2(_ provider: OAuth2SwiftProvider.Type) {
|
||||
callbackURL = provider.callbackURL
|
||||
|
||||
let oauth2 = provider.oauth2Swift
|
||||
self.oauth = oauth2
|
||||
oauth2.authorizeURLHandler = self
|
||||
|
||||
let oauth2Vars = provider.oauth2Vars
|
||||
|
||||
oauth2.authorize(withCallbackURL: callbackURL!, scope: oauth2Vars.scope, state: oauth2Vars.state, parameters: oauth2Vars.params) { [weak self] result in
|
||||
guard let self = self, let extensionPointType = self.extensionPointType else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let tokenSuccess):
|
||||
ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { result in
|
||||
if case .failure(let error) = result {
|
||||
self.presentError(error)
|
||||
}
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
self.delegate?.dismiss()
|
||||
}
|
||||
case .failure(let oauthSwiftError):
|
||||
self.presentError(oauthSwiftError)
|
||||
}
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public final class AddAccountListViewModel: ObservableObject, OAuthAccountAuthor
|
||||
@Published public var showAddAccountSheet: (Bool, accountType: AccountType) = (false, .onMyMac)
|
||||
public var webAccountTypes: [AccountType] {
|
||||
if AppDefaults.shared.isDeveloperBuild {
|
||||
return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader].filter({ $0.isDeveloperRestricted == false })
|
||||
return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader]
|
||||
//.filter({ $0.isDeveloperRestricted == false })
|
||||
} else {
|
||||
return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader]
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ struct AddExtensionListView: View {
|
||||
footer: Text("FEED_PROVIDER_FOOTER", tableName: "Settings")) {
|
||||
ForEach(0..<availableExtensionPointTypes.count, id: \.self) { i in
|
||||
NavigationLink {
|
||||
EnableExtensionPointViewWrapper(extensionPoint: availableExtensionPointTypes[i])
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
EnableExtensionPointView(extensionPoint: availableExtensionPointTypes[i])
|
||||
} label: {
|
||||
Image(uiImage: availableExtensionPointTypes[i].image)
|
||||
.resizable()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// EnableExtensionPointView.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 19/12/2022.
|
||||
// Copyright © 2022 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EnableExtensionPointView: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@StateObject private var viewModel = EnableExtensionViewModel()
|
||||
@State private var extensionError: (Error?, Bool) = (nil, false)
|
||||
var extensionPoint: ExtensionPoint.Type
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
ExtensionSectionHeader(extensionPoint: extensionPoint)
|
||||
Section(footer: extensionExplainer) {}
|
||||
Section { enableButton }
|
||||
}
|
||||
.alert(Text("ERROR_TITLE", tableName: "Errors"), isPresented: $extensionError.1, actions: {
|
||||
Button(action: {}, label: { Text("DISMISS_BUTTON_TITLE", tableName: "Buttons") })
|
||||
}, message: {
|
||||
Text(extensionError.0?.localizedDescription ?? "Unknown Error")
|
||||
})
|
||||
.navigationTitle(extensionPoint.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.dismissOnExternalContextLaunch()
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ActiveExtensionPointsDidChange)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
|
||||
var extensionExplainer: some View {
|
||||
Text(extensionPoint.description.string)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
var enableButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
viewModel.configure(extensionPoint)
|
||||
do {
|
||||
try await viewModel.enableExtension()
|
||||
} catch {
|
||||
extensionError = (error, true)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("ENABLE_EXTENSION_BUTTON_TITLE", tableName: "Buttons")
|
||||
Spacer()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//
|
||||
// EnableExtensionViewModel.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 19/12/2022.
|
||||
// Copyright © 2022 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import Account
|
||||
import OAuthSwift
|
||||
import Secrets
|
||||
import RSCore
|
||||
|
||||
@MainActor
|
||||
public final class EnableExtensionViewModel: NSObject, ObservableObject, OAuthSwiftURLHandlerType, ASWebAuthenticationPresentationContextProviding, Logging {
|
||||
|
||||
private var extensionPointType: ExtensionPoint.Type?
|
||||
private var oauth: OAuthSwift?
|
||||
private var callbackURL: URL? = nil
|
||||
|
||||
|
||||
func configure(_ extensionPointType: ExtensionPoint.Type) {
|
||||
self.extensionPointType = extensionPointType
|
||||
}
|
||||
|
||||
func enableExtension() async throws {
|
||||
guard let extensionPointType = extensionPointType else { return }
|
||||
if let oauth1 = extensionPointType as? OAuth1SwiftProvider.Type {
|
||||
try await enableOAuth1(oauth1)
|
||||
} else if let oauth2 = extensionPointType as? OAuth2SwiftProvider.Type {
|
||||
try await enableOAuth2(oauth2)
|
||||
} else {
|
||||
try await activateExtensionPoint(extensionPointType)
|
||||
}
|
||||
}
|
||||
|
||||
private func activateExtensionPoint(_ point: ExtensionPoint.Type) async throws {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
ExtensionPointManager.shared.activateExtensionPoint(point) { result in
|
||||
switch result {
|
||||
case .success(_):
|
||||
continuation.resume()
|
||||
return
|
||||
case .failure(let failure):
|
||||
continuation.resume(throwing: failure)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Enable OAuth
|
||||
private func enableOAuth1(_ provider: OAuth1SwiftProvider.Type) async throws {
|
||||
callbackURL = provider.callbackURL
|
||||
|
||||
let oauth1 = provider.oauth1Swift
|
||||
self.oauth = oauth1
|
||||
oauth1.authorizeURLHandler = self
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
oauth1.authorize(withCallbackURL: callbackURL!) { [weak self] result in
|
||||
|
||||
guard let self = self, let extensionPointType = self.extensionPointType else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let tokenSuccess):
|
||||
ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { result in
|
||||
switch result {
|
||||
case .success(_):
|
||||
continuation.resume()
|
||||
return
|
||||
case .failure(let failure):
|
||||
continuation.resume(throwing: failure)
|
||||
return
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func enableOAuth2(_ provider: OAuth2SwiftProvider.Type) async throws {
|
||||
|
||||
callbackURL = provider.callbackURL
|
||||
|
||||
let oauth2 = provider.oauth2Swift
|
||||
self.oauth = oauth2
|
||||
oauth2.authorizeURLHandler = self
|
||||
|
||||
let oauth2Vars = provider.oauth2Vars
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
oauth2.authorize(withCallbackURL: callbackURL!, scope: oauth2Vars.scope, state: oauth2Vars.state, parameters: oauth2Vars.params) { [weak self] result in
|
||||
guard let self = self, let extensionPointType = self.extensionPointType else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let tokenSuccess):
|
||||
ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { [weak self] result in
|
||||
switch result {
|
||||
case .success(_):
|
||||
self?.logger.debug("Enabled extension successfully.")
|
||||
case .failure(let failure):
|
||||
continuation.resume(throwing: failure)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
case .failure(let oauthSwiftError):
|
||||
continuation.resume(throwing: oauthSwiftError)
|
||||
return
|
||||
}
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
public func handle(_ url: URL) {
|
||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURL!.scheme, completionHandler: { (url, error) in
|
||||
if let callbackedURL = url {
|
||||
OAuth1Swift.handle(url: callbackedURL)
|
||||
}
|
||||
|
||||
guard let error = error else { return }
|
||||
|
||||
self.oauth?.cancel()
|
||||
self.oauth = nil
|
||||
|
||||
DispatchQueue.main.async {
|
||||
//self.dismiss(animated: true, completion: nil)
|
||||
//self.delegate?.dismiss()
|
||||
}
|
||||
|
||||
if case ASWebAuthenticationSessionError.canceledLogin = error {
|
||||
print("Login cancelled.")
|
||||
} else {
|
||||
//self.presentError(error)
|
||||
}
|
||||
})
|
||||
|
||||
session.presentationContextProvider = self
|
||||
if !session.start() {
|
||||
print("Session failed to start!!!")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return rootViewController!.view.window!
|
||||
}
|
||||
|
||||
public var rootViewController: UIViewController? {
|
||||
var currentKeyWindow: UIWindow? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.filter { $0.activationState == .foregroundActive }
|
||||
.map { $0 as? UIWindowScene }
|
||||
.compactMap { $0 }
|
||||
.first?.windows
|
||||
.filter { $0.isKeyWindow }
|
||||
.first
|
||||
}
|
||||
|
||||
var rootViewController: UIViewController? {
|
||||
currentKeyWindow?.rootViewController
|
||||
}
|
||||
|
||||
return rootViewController
|
||||
}
|
||||
|
||||
}
|
||||
30
iOS/SwiftUI Extensions/ExtensionSectionHeader.swift
Normal file
30
iOS/SwiftUI Extensions/ExtensionSectionHeader.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// ExtensionSectionHeader.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Stuart Breckenridge on 19/12/2022.
|
||||
// Copyright © 2022 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
struct ExtensionSectionHeader: View {
|
||||
|
||||
var extensionPoint: ExtensionPoint.Type
|
||||
|
||||
var body: some View {
|
||||
Section(header: headerImage) {}
|
||||
}
|
||||
|
||||
var headerImage: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(uiImage: extensionPoint.image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user