mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Customizable shortcuts, app icon, restore-clipboard preference
- Shortcuts screen now records custom global hotkeys (Fix + Ask), persisted and re-registered live via Preferences + Combine. - Add branded AppIcon.icns (gold nib on ink), wired into Info.plist + build.sh. - "Restore my clipboard after a fix" preference now drives the clipboard fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a7bf45b466
commit
197df3298f
BIN
AppSupport/AppIcon.icns
Normal file
BIN
AppSupport/AppIcon.icns
Normal file
Binary file not shown.
@ -25,6 +25,10 @@
|
|||||||
<string>com.grammagem.app</string>
|
<string>com.grammagem.app</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>GrammaGem</string>
|
<string>GrammaGem</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Root application model. Owns the engines, managers, and the system-wide
|
/// Root application model. Owns the engines, managers, and the system-wide
|
||||||
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
|
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
|
||||||
@ -35,6 +36,7 @@ final class AppState: ObservableObject {
|
|||||||
private var onboardingWindow: NSWindow?
|
private var onboardingWindow: NSWindow?
|
||||||
private var askWindow: NSWindow?
|
private var askWindow: NSWindow?
|
||||||
private var mainWindow: NSWindow?
|
private var mainWindow: NSWindow?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let harper = HarperEngine()
|
let harper = HarperEngine()
|
||||||
@ -64,7 +66,19 @@ final class AppState: ObservableObject {
|
|||||||
hotkeys.onAction = { [weak self] action in
|
hotkeys.onAction = { [weak self] action in
|
||||||
Task { await self?.handle(action) }
|
Task { await self?.handle(action) }
|
||||||
}
|
}
|
||||||
hotkeys.registerDefaults()
|
reregisterHotkeys()
|
||||||
|
|
||||||
|
// Live-apply preference changes.
|
||||||
|
capture.restoreClipboard = preferences.restoreClipboard
|
||||||
|
preferences.$restoreClipboard
|
||||||
|
.sink { [weak self] value in self?.capture.restoreClipboard = value }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
preferences.$fixHotkey.dropFirst()
|
||||||
|
.sink { [weak self] _ in self?.reregisterHotkeys() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
preferences.$askHotkey.dropFirst()
|
||||||
|
.sink { [weak self] _ in self?.reregisterHotkeys() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
permissions.refresh()
|
permissions.refresh()
|
||||||
appAwareEnabled = gate.appAwareEnabled
|
appAwareEnabled = gate.appAwareEnabled
|
||||||
@ -80,6 +94,15 @@ final class AppState: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Hotkey handling
|
// MARK: - Hotkey handling
|
||||||
|
|
||||||
|
/// (Re)register the global hotkeys from the current preferences.
|
||||||
|
func reregisterHotkeys() {
|
||||||
|
hotkeys.unregisterAll()
|
||||||
|
hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
|
||||||
|
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix)
|
||||||
|
hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
|
||||||
|
modifiers: preferences.askHotkey.carbonModifiers, action: .ask)
|
||||||
|
}
|
||||||
|
|
||||||
func handle(_ action: HotkeyManager.Action) async {
|
func handle(_ action: HotkeyManager.Action) async {
|
||||||
switch action {
|
switch action {
|
||||||
case .fix:
|
case .fix:
|
||||||
|
|||||||
@ -3,42 +3,82 @@ import SwiftUI
|
|||||||
// MARK: - Shortcuts
|
// MARK: - Shortcuts
|
||||||
|
|
||||||
struct ShortcutsView: View {
|
struct ShortcutsView: View {
|
||||||
|
@EnvironmentObject private var prefs: Preferences
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DetailScaffold(
|
DetailScaffold(
|
||||||
title: "Shortcuts",
|
title: "Shortcuts",
|
||||||
subtitle: "Global hotkeys that work in every app."
|
subtitle: "Global hotkeys that work in every app. Click a shortcut to change it."
|
||||||
) {
|
) {
|
||||||
Card {
|
Card {
|
||||||
shortcutRow(name: "Fix selection", keys: ["⌘", ";"], detail: "Grammar + clarity, in place")
|
shortcutRow(
|
||||||
|
name: "Fix selection",
|
||||||
|
detail: "Grammar + clarity, in place",
|
||||||
|
combo: $prefs.fixHotkey)
|
||||||
Divider()
|
Divider()
|
||||||
shortcutRow(name: "Ask GrammaGem", keys: ["⌘", "'"], detail: "Rewrite with an instruction")
|
shortcutRow(
|
||||||
}
|
name: "Ask GrammaGem",
|
||||||
Card {
|
detail: "Rewrite with an instruction",
|
||||||
Label("Custom shortcuts", systemImage: "wand.and.stars")
|
combo: $prefs.askHotkey)
|
||||||
.font(.headline)
|
|
||||||
Text("A built-in recorder for fully customizable shortcuts is on the roadmap. For now the defaults above are active system-wide.")
|
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
|
Text("Tip: include at least one modifier (⌘, ⌥, ⌃, ⇧). Press Esc while recording to cancel.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shortcutRow(name: String, keys: [String], detail: String) -> some View {
|
private func shortcutRow(name: String, detail: String, combo: Binding<HotkeyCombo>) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(name).fontWeight(.medium)
|
Text(name).fontWeight(.medium)
|
||||||
Text(detail).font(.caption).foregroundStyle(.secondary)
|
Text(detail).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 4) {
|
ShortcutRecorder(combo: combo)
|
||||||
ForEach(keys, id: \.self) { k in
|
}
|
||||||
Text(k)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small click-to-record control that captures the next modifier+key combo.
|
||||||
|
struct ShortcutRecorder: View {
|
||||||
|
@Binding var combo: HotkeyCombo
|
||||||
|
@State private var recording = false
|
||||||
|
@State private var monitor: Any?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
recording ? stop() : start()
|
||||||
|
} label: {
|
||||||
|
Text(recording ? "Press keys…" : combo.display)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
.frame(minWidth: 76)
|
||||||
.background(Color(nsColor: .controlColor), in: RoundedRectangle(cornerRadius: 6))
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.12)))
|
.background(
|
||||||
}
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(recording ? GG.emerald.opacity(0.15) : Color(nsColor: .controlColor)))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(recording ? GG.emerald : Color.primary.opacity(0.12)))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onDisappear(perform: stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start() {
|
||||||
|
recording = true
|
||||||
|
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
||||||
|
if event.keyCode == 53 { stop(); return nil } // Esc cancels
|
||||||
|
if let captured = HotkeyCombo.from(event: event) {
|
||||||
|
combo = captured
|
||||||
|
stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event // ignore bare keys (no modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
recording = false
|
||||||
|
if let monitor { NSEvent.removeMonitor(monitor); self.monitor = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,9 @@ final class Preferences: ObservableObject {
|
|||||||
@Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } }
|
@Published var appearance: Appearance { didSet { d.set(appearance.rawValue, forKey: "pref.appearance"); applyAppearance() } }
|
||||||
@Published var menuBarStyle: MenuBarStyle { didSet { d.set(menuBarStyle.rawValue, forKey: "pref.menuBarStyle") } }
|
@Published var menuBarStyle: MenuBarStyle { didSet { d.set(menuBarStyle.rawValue, forKey: "pref.menuBarStyle") } }
|
||||||
|
|
||||||
|
@Published var fixHotkey: HotkeyCombo { didSet { persist(fixHotkey, "pref.fixHotkey") } }
|
||||||
|
@Published var askHotkey: HotkeyCombo { didSet { persist(askHotkey, "pref.askHotkey") } }
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
launchAtLogin = SMAppService.mainApp.status == .enabled
|
launchAtLogin = SMAppService.mainApp.status == .enabled
|
||||||
playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true
|
playSounds = d.object(forKey: "pref.playSounds") as? Bool ?? true
|
||||||
@ -34,6 +37,17 @@ final class Preferences: ObservableObject {
|
|||||||
restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true
|
restoreClipboard = d.object(forKey: "pref.restoreClipboard") as? Bool ?? true
|
||||||
appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system
|
appearance = Appearance(rawValue: d.string(forKey: "pref.appearance") ?? "") ?? .system
|
||||||
menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon
|
menuBarStyle = MenuBarStyle(rawValue: d.string(forKey: "pref.menuBarStyle") ?? "") ?? .icon
|
||||||
|
fixHotkey = Preferences.load("pref.fixHotkey") ?? .defaultFix
|
||||||
|
askHotkey = Preferences.load("pref.askHotkey") ?? .defaultAsk
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist(_ combo: HotkeyCombo, _ key: String) {
|
||||||
|
if let raw = try? JSONEncoder().encode(combo) { d.set(raw, forKey: key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func load(_ key: String) -> HotkeyCombo? {
|
||||||
|
guard let raw = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(HotkeyCombo.self, from: raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyLaunchAtLogin() {
|
private func applyLaunchAtLogin() {
|
||||||
|
|||||||
61
Sources/GrammaGem/System/HotkeyCombo.swift
Normal file
61
Sources/GrammaGem/System/HotkeyCombo.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import Carbon.HIToolbox
|
||||||
|
|
||||||
|
/// A user-configurable keyboard shortcut: a key code plus Carbon modifier mask,
|
||||||
|
/// with a human-readable display string (e.g. "⌘;").
|
||||||
|
struct HotkeyCombo: Codable, Equatable {
|
||||||
|
var keyCode: UInt32
|
||||||
|
var carbonModifiers: UInt32
|
||||||
|
var display: String
|
||||||
|
|
||||||
|
static let defaultFix = HotkeyCombo(
|
||||||
|
keyCode: AppConfig.Hotkey.defaultKeyCode,
|
||||||
|
carbonModifiers: AppConfig.Hotkey.defaultModifiers,
|
||||||
|
display: "⌘;")
|
||||||
|
|
||||||
|
static let defaultAsk = HotkeyCombo(
|
||||||
|
keyCode: AppConfig.Hotkey.askKeyCode,
|
||||||
|
carbonModifiers: AppConfig.Hotkey.askModifiers,
|
||||||
|
display: "⌘'")
|
||||||
|
|
||||||
|
/// Build a combo from a recorded key-down event. Requires at least one
|
||||||
|
/// modifier so the shortcut is safe to register globally.
|
||||||
|
static func from(event: NSEvent) -> HotkeyCombo? {
|
||||||
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
var carbon: UInt32 = 0
|
||||||
|
if flags.contains(.command) { carbon |= UInt32(cmdKey) }
|
||||||
|
if flags.contains(.shift) { carbon |= UInt32(shiftKey) }
|
||||||
|
if flags.contains(.option) { carbon |= UInt32(optionKey) }
|
||||||
|
if flags.contains(.control) { carbon |= UInt32(controlKey) }
|
||||||
|
guard carbon != 0 else { return nil }
|
||||||
|
|
||||||
|
let symbol = keySymbol(for: event)
|
||||||
|
var display = ""
|
||||||
|
if flags.contains(.control) { display += "⌃" }
|
||||||
|
if flags.contains(.option) { display += "⌥" }
|
||||||
|
if flags.contains(.shift) { display += "⇧" }
|
||||||
|
if flags.contains(.command) { display += "⌘" }
|
||||||
|
display += symbol
|
||||||
|
return HotkeyCombo(keyCode: UInt32(event.keyCode), carbonModifiers: carbon, display: display)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func keySymbol(for event: NSEvent) -> String {
|
||||||
|
switch Int(event.keyCode) {
|
||||||
|
case kVK_Space: return "Space"
|
||||||
|
case kVK_Return: return "↩"
|
||||||
|
case kVK_Tab: return "⇥"
|
||||||
|
case kVK_Delete: return "⌫"
|
||||||
|
case kVK_Escape: return "⎋"
|
||||||
|
case kVK_LeftArrow: return "←"
|
||||||
|
case kVK_RightArrow: return "→"
|
||||||
|
case kVK_UpArrow: return "↑"
|
||||||
|
case kVK_DownArrow: return "↓"
|
||||||
|
default:
|
||||||
|
if let chars = event.charactersIgnoringModifiers, !chars.isEmpty {
|
||||||
|
return chars.uppercased()
|
||||||
|
}
|
||||||
|
return "key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,10 @@ final class TextCapture {
|
|||||||
|
|
||||||
private let pasteboard = NSPasteboard.general
|
private let pasteboard = NSPasteboard.general
|
||||||
|
|
||||||
|
/// When true (default), the clipboard-fallback path restores the user's
|
||||||
|
/// previous clipboard after pasting the correction. Driven by Preferences.
|
||||||
|
var restoreClipboard = true
|
||||||
|
|
||||||
// MARK: - Capture
|
// MARK: - Capture
|
||||||
|
|
||||||
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
|
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
|
||||||
@ -104,8 +108,8 @@ final class TextCapture {
|
|||||||
writeClipboard(newText)
|
writeClipboard(newText)
|
||||||
sendCommandKey(CGKeyCode(kVK_ANSI_V))
|
sendCommandKey(CGKeyCode(kVK_ANSI_V))
|
||||||
Thread.sleep(forTimeInterval: 0.10)
|
Thread.sleep(forTimeInterval: 0.10)
|
||||||
// Restore the user's previous clipboard contents.
|
// Restore the user's previous clipboard contents (if they want that).
|
||||||
if let saved = savedClipboard {
|
if restoreClipboard, let saved = savedClipboard {
|
||||||
Thread.sleep(forTimeInterval: 0.05)
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
writeClipboard(saved)
|
writeClipboard(saved)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
|
|||||||
|
|
||||||
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
cp "${BUILD_DIR}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||||
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
||||||
# cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/" # TODO: add an icon
|
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
||||||
|
|
||||||
echo "==> Built ${DIST}/${APP}"
|
echo "==> Built ${DIST}/${APP}"
|
||||||
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||||
|
|||||||
67
scripts/make-icon.swift
Normal file
67
scripts/make-icon.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink
|
||||||
|
// brand square. Run from mac/: swift scripts/make-icon.swift
|
||||||
|
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
let ink = NSColor(red: 0x10 / 255.0, green: 0x23 / 255.0, blue: 0x1B / 255.0, alpha: 1)
|
||||||
|
let gold = NSColor(red: 0xC9 / 255.0, green: 0xA2 / 255.0, blue: 0x4B / 255.0, alpha: 1)
|
||||||
|
|
||||||
|
func png(size: Int) -> Data {
|
||||||
|
let S = CGFloat(size)
|
||||||
|
let rep = NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size,
|
||||||
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
|
||||||
|
let ctx = NSGraphicsContext(bitmapImageRep: rep)!
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current = ctx
|
||||||
|
let g = ctx.cgContext
|
||||||
|
|
||||||
|
// Brand square (rounded).
|
||||||
|
let rrect = CGPath(roundedRect: CGRect(x: 0, y: 0, width: S, height: S),
|
||||||
|
cornerWidth: S * 0.22, cornerHeight: S * 0.22, transform: nil)
|
||||||
|
g.addPath(rrect); g.setFillColor(ink.cgColor); g.fillPath()
|
||||||
|
|
||||||
|
// Gold nib (downward triangle), coordinates are y-up.
|
||||||
|
let tri = CGMutablePath()
|
||||||
|
tri.move(to: CGPoint(x: 0.18 * S, y: 0.78 * S))
|
||||||
|
tri.addLine(to: CGPoint(x: 0.82 * S, y: 0.78 * S))
|
||||||
|
tri.addLine(to: CGPoint(x: 0.50 * S, y: 0.10 * S))
|
||||||
|
tri.closeSubpath()
|
||||||
|
g.addPath(tri); g.setFillColor(gold.cgColor); g.fillPath()
|
||||||
|
|
||||||
|
// Center slit.
|
||||||
|
g.setStrokeColor(ink.cgColor)
|
||||||
|
g.setLineWidth(max(1, S * 0.05)); g.setLineCap(.round)
|
||||||
|
g.move(to: CGPoint(x: 0.50 * S, y: 0.20 * S))
|
||||||
|
g.addLine(to: CGPoint(x: 0.50 * S, y: 0.60 * S))
|
||||||
|
g.strokePath()
|
||||||
|
|
||||||
|
// Vent hole.
|
||||||
|
g.setFillColor(ink.cgColor)
|
||||||
|
let r = S * 0.055
|
||||||
|
g.addEllipse(in: CGRect(x: 0.50 * S - r, y: 0.64 * S - r, width: 2 * r, height: 2 * r))
|
||||||
|
g.fillPath()
|
||||||
|
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
return rep.representation(using: .png, properties: [:])!
|
||||||
|
}
|
||||||
|
|
||||||
|
let fm = FileManager.default
|
||||||
|
let dir = "AppIcon.iconset"
|
||||||
|
try? fm.removeItem(atPath: dir)
|
||||||
|
try! fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let specs: [(String, Int)] = [
|
||||||
|
("icon_16x16", 16), ("icon_16x16@2x", 32),
|
||||||
|
("icon_32x32", 32), ("icon_32x32@2x", 64),
|
||||||
|
("icon_128x128", 128), ("icon_128x128@2x", 256),
|
||||||
|
("icon_256x256", 256), ("icon_256x256@2x", 512),
|
||||||
|
("icon_512x512", 512), ("icon_512x512@2x", 1024),
|
||||||
|
]
|
||||||
|
for (name, size) in specs {
|
||||||
|
try! png(size: size).write(to: URL(fileURLWithPath: "\(dir)/\(name).png"))
|
||||||
|
}
|
||||||
|
print("wrote \(specs.count) icon images to \(dir)")
|
||||||
Loading…
x
Reference in New Issue
Block a user