feat: add destination-specific global shortcuts
This commit is contained in:
@@ -92,8 +92,13 @@ struct ConjureApp {
|
||||
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
private let uninstaller = ApplicationUninstaller()
|
||||
private lazy var launcher = LauncherWindowController(uninstaller: uninstaller)
|
||||
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
|
||||
private lazy var settings = SettingsWindowController(shortcut: shortcut)
|
||||
private lazy var shortcuts = GlobalShortcuts { [weak self] destination in
|
||||
switch destination {
|
||||
case .launcher: self?.launcher.toggle()
|
||||
case .command(let command): self?.launcher.toggle(command: command)
|
||||
}
|
||||
}
|
||||
private lazy var settings = SettingsWindowController(shortcuts: shortcuts)
|
||||
private var startupShortcutError: String?
|
||||
private var startupRecoveryMessage: String?
|
||||
|
||||
@@ -123,13 +128,10 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
if !isLoginLaunch {
|
||||
launcher.present()
|
||||
}
|
||||
do {
|
||||
try shortcut.start()
|
||||
} catch {
|
||||
startupShortcutError = error.localizedDescription
|
||||
if !isLoginLaunch {
|
||||
showSettings(nil)
|
||||
}
|
||||
let shortcutErrors = shortcuts.start()
|
||||
if !shortcutErrors.isEmpty {
|
||||
startupShortcutError = shortcutErrors.joined(separator: "\n")
|
||||
if !isLoginLaunch { showSettings(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +151,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
shortcut.stop()
|
||||
shortcuts.stop()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
|
||||
@@ -111,6 +111,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
@objc func goBack() {
|
||||
if actions.isShown { actions.close() } else { closed?() }
|
||||
}
|
||||
func close() {
|
||||
actions.close()
|
||||
closed?()
|
||||
}
|
||||
override func cancelOperation(_ sender: Any?) { goBack() }
|
||||
|
||||
func inputChanged(preservingSelection: Bool = false) {
|
||||
|
||||
@@ -36,6 +36,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var translation: TranslationController?
|
||||
private var emojiPicker: EmojiPickerController?
|
||||
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
|
||||
private var activeCommand: Application.Command?
|
||||
private var pendingPresentation: (() -> Void)?
|
||||
|
||||
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
|
||||
self.history = history
|
||||
@@ -100,15 +102,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
currencyTask?.cancel()
|
||||
}
|
||||
|
||||
func present() {
|
||||
(window as? LauncherPanel)?.beginNumberHintSession()
|
||||
if launcherFrameBeforeReview != nil || isCommandOpen {
|
||||
showWindow(nil)
|
||||
NSApp.activate()
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
(window as? LauncherPanel)?.sampleNumberHints()
|
||||
func present(command: Application.Command? = nil) {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
if launcherFrameBeforeReview != nil {
|
||||
pendingPresentation = { [weak self] in self?.present(command: command) }
|
||||
uninstallReview?.goBack()
|
||||
return
|
||||
}
|
||||
closeCommand()
|
||||
actions.close()
|
||||
(window as? LauncherPanel)?.beginNumberHintSession()
|
||||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||||
previousApplication = frontmost
|
||||
@@ -124,14 +127,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
(window as? LauncherPanel)?.sampleNumberHints()
|
||||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
refreshApplications()
|
||||
if let command {
|
||||
showCommand(command)
|
||||
history.recordLaunch(of: Application(command: command))
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
func toggle(command: Application.Command? = nil) {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
if window?.isVisible == true, window?.isKeyWindow == true {
|
||||
if window?.isVisible == true, window?.isKeyWindow == true,
|
||||
launcherFrameBeforeReview == nil, activeCommand == command {
|
||||
closeCommand()
|
||||
actions.close()
|
||||
dismiss()
|
||||
} else {
|
||||
present()
|
||||
present(command: command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +738,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
self.openButton.keyEquivalent = "\r"
|
||||
self.updateOpenButton()
|
||||
self.window?.makeFirstResponder(self.searchField)
|
||||
let pending = self.pendingPresentation
|
||||
self.pendingPresentation = nil
|
||||
pending?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,8 +803,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
emojiPicker?.goBack()
|
||||
}
|
||||
|
||||
private func closeCommand() {
|
||||
translation?.goBack()
|
||||
emojiPicker?.close()
|
||||
}
|
||||
|
||||
private func showCommand(_ command: Application.Command) {
|
||||
guard !isCommandOpen, let content = window?.contentView else { return }
|
||||
activeCommand = command
|
||||
let controller: NSViewController
|
||||
let placeholder: String
|
||||
let accessibilityLabel: String
|
||||
@@ -820,6 +839,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
controller.view.removeFromSuperview()
|
||||
self.translation = nil
|
||||
self.emojiPicker = nil
|
||||
self.activeCommand = nil
|
||||
self.commandBack.isHidden = true
|
||||
self.commandBack.target = nil
|
||||
self.searchSymbol.isHidden = false
|
||||
|
||||
@@ -4,7 +4,10 @@ import ServiceManagement
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private let shortcut: GlobalShortcut
|
||||
private let shortcuts: GlobalShortcuts
|
||||
private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||
private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil)
|
||||
private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] }
|
||||
private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil)
|
||||
private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
|
||||
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
||||
@@ -17,10 +20,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private var recoveryReport: UninstallReport?
|
||||
var retryUninstallRecovery: (() async -> UninstallReport)?
|
||||
|
||||
init(shortcut: GlobalShortcut) {
|
||||
self.shortcut = shortcut
|
||||
init(shortcuts: GlobalShortcuts) {
|
||||
self.shortcuts = shortcuts
|
||||
let window = ShortcutSettingsWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 620, height: 640),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 620, height: 690),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
@@ -69,9 +72,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
|
||||
private func configureContent(in window: NSWindow) {
|
||||
let heading = NSTextField(labelWithString: "Global Shortcut")
|
||||
let heading = NSTextField(labelWithString: "Global Shortcuts")
|
||||
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||
|
||||
destinationSelector.addItems(withTitles: GlobalShortcuts.destinations.map(\.title))
|
||||
destinationSelector.target = self
|
||||
destinationSelector.action = #selector(changeDestination)
|
||||
destinationSelector.setAccessibilityLabel("Global shortcut destination")
|
||||
|
||||
recorder.bezelStyle = .rounded
|
||||
recorder.target = self
|
||||
recorder.action = #selector(beginRecording)
|
||||
@@ -82,7 +90,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
self?.errorLabel.stringValue = ""
|
||||
self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
|
||||
}
|
||||
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
|
||||
commandSpaceButton.target = self
|
||||
commandSpaceButton.action = #selector(useCommandSpace)
|
||||
commandSpaceButton.bezelStyle = .rounded
|
||||
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
|
||||
clearButton.bezelStyle = .rounded
|
||||
@@ -95,7 +104,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
controls.spacing = 8
|
||||
|
||||
let instructions = NSTextField(wrappingLabelWithString:
|
||||
"Click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. The global shortcut is paused while recording."
|
||||
"Choose a destination, click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. All global shortcuts are paused while recording."
|
||||
)
|
||||
instructions.textColor = .secondaryLabelColor
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
@@ -139,7 +148,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
loginHeading.textColor = SunsetPalette.text
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
||||
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
||||
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton
|
||||
])
|
||||
stack.orientation = .vertical
|
||||
@@ -240,11 +249,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
private func refresh() {
|
||||
refreshLogin()
|
||||
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
|
||||
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
|
||||
let name = shortcut.shortcut?.displayName
|
||||
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
|
||||
recorder.setAccessibilityValue(name ?? "Not set")
|
||||
if recorder.isRecording {
|
||||
statusLabel.stringValue = "Global shortcut paused while recording."
|
||||
statusLabel.stringValue = "All global shortcuts are paused while recording."
|
||||
} else if shortcut.isRegistered {
|
||||
statusLabel.stringValue = "Global shortcut is active."
|
||||
} else if shortcut.shortcut != nil {
|
||||
@@ -257,7 +268,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
@objc private func beginRecording() {
|
||||
guard !recorder.isRecording else { return }
|
||||
errorLabel.stringValue = ""
|
||||
shortcut.suspend()
|
||||
shortcuts.suspend()
|
||||
recorder.isRecording = true
|
||||
refresh()
|
||||
if window?.makeFirstResponder(recorder) != true {
|
||||
@@ -274,7 +285,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
|
||||
restoreSavedShortcut()
|
||||
restoreSavedShortcuts()
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
@@ -286,11 +297,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = ""
|
||||
do {
|
||||
try shortcut.setShortcut(candidate)
|
||||
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
|
||||
} catch {
|
||||
errorLabel.stringValue = error.localizedDescription
|
||||
if wasRecording { restoreSavedShortcut() }
|
||||
}
|
||||
if wasRecording { restoreSavedShortcuts() }
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -298,19 +309,23 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
guard recorder.isRecording else { return }
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = ""
|
||||
restoreSavedShortcut()
|
||||
restoreSavedShortcuts()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func restoreSavedShortcut() {
|
||||
do {
|
||||
try shortcut.setShortcut(shortcut.shortcut)
|
||||
} catch {
|
||||
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
|
||||
private func restoreSavedShortcuts() {
|
||||
let errors = shortcuts.start()
|
||||
if !errors.isEmpty {
|
||||
let message = errors.joined(separator: "\n")
|
||||
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func changeDestination() {
|
||||
cancelRecording()
|
||||
refresh()
|
||||
}
|
||||
|
||||
@objc private func useCommandSpace() {
|
||||
apply(.commandSpace)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ public final class GlobalShortcut {
|
||||
public private(set) var shortcut: KeyboardShortcut?
|
||||
public private(set) var isRegistered = false
|
||||
|
||||
private static let storageKey = "globalActivationShortcut"
|
||||
private let storageKey: String
|
||||
private let defaults: UserDefaults
|
||||
private let action: @MainActor () -> Void
|
||||
private let native = NativeHotKey()
|
||||
|
||||
public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) {
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
storageKey: String = "globalActivationShortcut",
|
||||
action: @escaping @MainActor () -> Void
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
self.action = action
|
||||
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: Self.storageKey))
|
||||
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: storageKey))
|
||||
native.owner = self
|
||||
}
|
||||
|
||||
@@ -24,7 +29,7 @@ public final class GlobalShortcut {
|
||||
|
||||
public func start() throws {
|
||||
guard !isRegistered else { return }
|
||||
if let saved = defaults.object(forKey: Self.storageKey) {
|
||||
if let saved = defaults.object(forKey: storageKey) {
|
||||
guard let restored = KeyboardShortcut(storedValue: saved) else {
|
||||
throw ShortcutError.invalidSavedShortcut
|
||||
}
|
||||
@@ -37,11 +42,11 @@ public final class GlobalShortcut {
|
||||
if candidate == shortcut, isRegistered { return }
|
||||
if let candidate {
|
||||
try activate(candidate)
|
||||
defaults.set(candidate.storedValue, forKey: Self.storageKey)
|
||||
defaults.set(candidate.storedValue, forKey: storageKey)
|
||||
} else {
|
||||
try native.unregister()
|
||||
isRegistered = false
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
shortcut = candidate
|
||||
}
|
||||
@@ -91,17 +96,23 @@ public final class GlobalShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ShortcutError: LocalizedError {
|
||||
enum ShortcutError: LocalizedError {
|
||||
case invalidSavedShortcut
|
||||
case systemConflict(String)
|
||||
case inAppConflict(shortcut: String, action: String)
|
||||
case duplicate(shortcut: String, destination: String)
|
||||
case native(operation: String, status: OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidSavedShortcut:
|
||||
return "The saved activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
|
||||
return "The saved shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
|
||||
case .systemConflict(let shortcut):
|
||||
return "\(shortcut) is already an enabled macOS shortcut. Free it in System Settings → Keyboard → Keyboard Shortcuts (check Spotlight and Input Sources), or choose another shortcut. Conjure will not change your system settings."
|
||||
case .inAppConflict(let shortcut, let action):
|
||||
return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut."
|
||||
case .duplicate(let shortcut, let destination):
|
||||
return "\(shortcut) is already assigned to \(destination). Clear that binding or choose another shortcut."
|
||||
case .native(let operation, let status):
|
||||
if status == eventHotKeyExistsErr {
|
||||
return "That shortcut is already registered by another app. Change or disable the other app’s shortcut, or choose another one. For Command-Space, also check Spotlight and Input Sources in System Settings → Keyboard → Keyboard Shortcuts."
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
public final class GlobalShortcuts {
|
||||
public enum Destination: Hashable, Sendable {
|
||||
case launcher
|
||||
case command(Application.Command)
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .launcher: "Applications"
|
||||
case .command(let command): Application.commands.first { $0.command == command }!.name
|
||||
}
|
||||
}
|
||||
|
||||
var storageKey: String {
|
||||
switch self {
|
||||
case .launcher: "globalActivationShortcut"
|
||||
case .command(let command): "globalCommandShortcut.\(command.rawValue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) static var destinations: [Destination] =
|
||||
[.launcher] + Application.Command.allCases.map { .command($0) }
|
||||
|
||||
public let bindings: [GlobalShortcut]
|
||||
private let defaults: UserDefaults
|
||||
private var isSuspended = false
|
||||
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
action: @escaping @MainActor (Destination) -> Void
|
||||
) {
|
||||
self.defaults = defaults
|
||||
bindings = Self.destinations.map { destination in
|
||||
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey) {
|
||||
action(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func start() -> [String] {
|
||||
isSuspended = false
|
||||
var errors: [String] = []
|
||||
for (index, binding) in bindings.enumerated() {
|
||||
do {
|
||||
if let saved = defaults.object(forKey: Self.destinations[index].storageKey) {
|
||||
guard let candidate = KeyboardShortcut(storedValue: saved) else {
|
||||
throw ShortcutError.invalidSavedShortcut
|
||||
}
|
||||
try validate(candidate, at: index)
|
||||
}
|
||||
try binding.start()
|
||||
} catch {
|
||||
binding.suspend()
|
||||
errors.append("\(Self.destinations[index].title): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
public func suspend() {
|
||||
isSuspended = true
|
||||
bindings.forEach { $0.suspend() }
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
isSuspended = false
|
||||
bindings.forEach { $0.stop() }
|
||||
}
|
||||
|
||||
public func setShortcut(_ candidate: KeyboardShortcut?, at index: Int) throws {
|
||||
if let candidate { try validate(candidate, at: index) }
|
||||
let binding = bindings[index]
|
||||
try binding.setShortcut(candidate)
|
||||
if isSuspended { binding.suspend() }
|
||||
}
|
||||
|
||||
func validate(_ candidate: KeyboardShortcut, at index: Int) throws {
|
||||
if let action = Self.localAction(for: candidate, keyName: candidate.keyName) {
|
||||
throw ShortcutError.inAppConflict(shortcut: candidate.displayName, action: action)
|
||||
}
|
||||
for (otherIndex, destination) in Self.destinations.enumerated() where otherIndex != index {
|
||||
if KeyboardShortcut(storedValue: defaults.object(forKey: destination.storageKey)) == candidate {
|
||||
throw ShortcutError.duplicate(shortcut: candidate.displayName, destination: destination.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match Conjure's menu equivalents, action buttons, and launcher/emoji panel handling.
|
||||
static func localAction(for candidate: KeyboardShortcut, keyName: String) -> String? {
|
||||
let key = keyName.lowercased()
|
||||
if candidate.modifiers == .command {
|
||||
switch key {
|
||||
case ",": return "Settings"
|
||||
case "h": return "Hide Conjure"
|
||||
case "q": return "Quit Conjure"
|
||||
case "w": return "Close Window"
|
||||
case "x": return "Cut"
|
||||
case "c": return "Copy"
|
||||
case "v": return "Paste"
|
||||
case "a": return "Select All"
|
||||
case "m": return "Minimize"
|
||||
case "k": return "entry actions"
|
||||
case "return", "enter": return "Open, Copy, Translate, or confirm uninstall"
|
||||
case "1"..."9" where key.utf8.count == 1: return "numbered application launching"
|
||||
default: break
|
||||
}
|
||||
} else if candidate.modifiers == [.command, .option], key == "h" {
|
||||
return "Hide Others"
|
||||
} else if candidate.modifiers == .control {
|
||||
switch key {
|
||||
case "h", "j", "k", "l": return "launcher and emoji navigation"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public struct KeyboardShortcut: Equatable, Sendable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private var keyName: String {
|
||||
var keyName: String {
|
||||
switch keyCode {
|
||||
case 36: return "Return"
|
||||
case 48: return "Tab"
|
||||
|
||||
Reference in New Issue
Block a user