chore(mac): label toggle as Clawdis Active
This commit is contained in:
@@ -392,9 +392,7 @@ private struct MenuContent: View {
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: activeBinding) {
|
||||
Text(activeBinding.wrappedValue ? "Clawdis Active" : "Clawdis Paused")
|
||||
}
|
||||
Toggle(isOn: activeBinding) { Text("Clawdis Active") }
|
||||
Button("Settings…") { open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
Button("About Clawdis") { open(tab: .about) }
|
||||
@@ -811,7 +809,7 @@ struct GeneralSettings: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: activeBinding.wrappedValue ? "Clawdis active" : "Clawdis paused",
|
||||
title: "Clawdis active",
|
||||
subtitle: "Pause to stop Clawdis background helpers and notifications.",
|
||||
binding: activeBinding)
|
||||
|
||||
@@ -1203,7 +1201,7 @@ private struct PermissionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding (VibeTunnel-style, multi-step)
|
||||
// MARK: - Onboarding (VibeTunnel-aligned)
|
||||
|
||||
@MainActor
|
||||
final class OnboardingController {
|
||||
@@ -1219,7 +1217,7 @@ final class OnboardingController {
|
||||
let hosting = NSHostingController(rootView: OnboardingView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = "Welcome to Clawdis"
|
||||
window.setContentSize(NSSize(width: 540, height: 420))
|
||||
window.setContentSize(NSSize(width: 640, height: 600))
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
@@ -1234,78 +1232,278 @@ final class OnboardingController {
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@State private var stepIndex = 0
|
||||
@State private var currentPage = 0
|
||||
@State private var permStatus: [Capability: Bool] = [:]
|
||||
@State private var copied = false
|
||||
@State private var isRequesting = false
|
||||
@State private var installingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var copied = false
|
||||
@ObservedObject private var state = AppStateStore.shared
|
||||
|
||||
private var steps: [OnboardingStep] {
|
||||
[
|
||||
.init(title: "Meet Clawdis", detail: "A focused menu bar companion for notifications, screenshots, and privileged agent actions.", systemImage: "sparkles"),
|
||||
.init(title: "Permissions", detail: "Grant Notifications, Accessibility, and Screen Recording so tasks don't get blocked.", systemImage: "lock.shield", showsPermissions: true),
|
||||
.init(title: "CLI helper", detail: "Install the `clawdis-mac` helper so scripts can talk to the app.", systemImage: "terminal", showsCLI: true),
|
||||
.init(title: "Stay running", detail: "Enable launch at login so Clawdis is ready before the agent asks.", systemImage: "arrow.triangle.2.circlepath", showsLogin: true),
|
||||
.init(title: "You're set", detail: "Keep Clawdis running, pause from the menu anytime, and re-run onboarding later if needed.", systemImage: "checkmark.seal")
|
||||
]
|
||||
}
|
||||
private let pageWidth: CGFloat = 640
|
||||
private let contentHeight: CGFloat = 260
|
||||
private var pageCount: Int { 6 }
|
||||
private var buttonTitle: String { currentPage == pageCount - 1 ? "Finish" : "Next" }
|
||||
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
|
||||
|
||||
var body: some View {
|
||||
let step = steps[stepIndex]
|
||||
VStack(spacing: 16) {
|
||||
heroCard(step: step)
|
||||
contentPanel(step: step)
|
||||
progressDots
|
||||
footerButtons
|
||||
VStack(spacing: 0) {
|
||||
GlowingClawdisIcon(size: 148)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 12)
|
||||
.frame(height: 200)
|
||||
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
welcomePage
|
||||
focusPage
|
||||
permissionsPage
|
||||
cliPage
|
||||
launchPage
|
||||
readyPage
|
||||
}
|
||||
.frame(height: contentHeight)
|
||||
.offset(x: CGFloat(-currentPage) * pageWidth)
|
||||
.animation(
|
||||
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
|
||||
value: currentPage
|
||||
)
|
||||
}
|
||||
.frame(height: contentHeight)
|
||||
.clipped()
|
||||
|
||||
navigationBar
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.frame(width: pageWidth)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear { currentPage = 0 }
|
||||
.task { await refreshPerms() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func heroCard(step: OnboardingStep) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.15))
|
||||
.frame(width: 38, height: 38)
|
||||
Image(systemName: step.systemImage)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(step.title)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.white)
|
||||
Text(step.detail)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.92))
|
||||
}
|
||||
Spacer()
|
||||
private var welcomePage: some View {
|
||||
onboardingPage {
|
||||
Text("Welcome to Clawdis")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
Text("We'll guide you through the same flow as VibeTunnel: quick steps, live permission checks, and the helper CLI.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
LinearGradient(colors: [Color.blue.opacity(0.9), Color.purple.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
)
|
||||
.shadow(color: .black.opacity(0.18), radius: 12, y: 5)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentPanel(step: OnboardingStep) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if step.showsPermissions { permissionsCard }
|
||||
if step.showsCLI { CLIInstallCard(copied: $copied) }
|
||||
if step.showsLoginToggle { loginCard }
|
||||
if !step.showsPermissions && !step.showsCLI && !step.showsLoginToggle {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Keep Clawdis running in your menu bar. Pause from the menu if you need silence, or open Settings to adjust permissions later.")
|
||||
.font(.body)
|
||||
private var focusPage: some View {
|
||||
onboardingPage {
|
||||
Text("What Clawdis handles")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
onboardingCard {
|
||||
featureRow(
|
||||
title: "Owns the TCC prompts",
|
||||
subtitle: "Requests Notifications, Accessibility, and Screen Recording so your agents stay unblocked.",
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
featureRow(
|
||||
title: "Native notifications",
|
||||
subtitle: "Shows desktop toasts for agent events with your preferred sound.",
|
||||
systemImage: "bell.and.waveform"
|
||||
)
|
||||
featureRow(
|
||||
title: "Privileged helpers",
|
||||
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI with the right permissions.",
|
||||
systemImage: "terminal"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsPage: some View {
|
||||
onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Match VibeTunnel's checklist: approve these once and the CLI reuses the same grants.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
onboardingCard {
|
||||
ForEach(Capability.allCases, id: \.self) { cap in
|
||||
PermissionRow(capability: cap, status: permStatus[cap] ?? false) {
|
||||
Task { await request(cap) }
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Refresh status") { Task { await refreshPerms() } }
|
||||
.controlSize(.small)
|
||||
if isRequesting {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cliPage: some View {
|
||||
onboardingPage {
|
||||
Text("Install the helper CLI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Link `clawdis-mac` like VibeTunnel's `vt` so scripts and the agent can talk to this app.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
onboardingCard {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await installCLI() }
|
||||
} label: {
|
||||
if installingCLI {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Install helper")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(installingCLI)
|
||||
|
||||
Button(copied ? "Copied" : "Copy dev link") {
|
||||
copyToPasteboard(devLinkCommand)
|
||||
}
|
||||
.disabled(installingCLI)
|
||||
}
|
||||
|
||||
if let cliStatus {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("We install into /usr/local/bin and /opt/homebrew/bin. Rerun anytime if you move the build output.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var launchPage: some View {
|
||||
onboardingPage {
|
||||
Text("Keep it running")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Match VibeTunnel's stay-on guidance: let Clawdis launch with macOS so permissions and notifications are ready.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
onboardingCard {
|
||||
Toggle("Launch at login", isOn: $state.launchAtLogin)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: state.launchAtLogin) { _, newValue in
|
||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||
}
|
||||
Text("You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" button if you need to revisit.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var readyPage: some View {
|
||||
onboardingPage {
|
||||
Text("All set")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
onboardingCard {
|
||||
featureRow(
|
||||
title: "Run the dashboard",
|
||||
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from Settings if you add a new user.",
|
||||
systemImage: "checkmark.seal"
|
||||
)
|
||||
featureRow(
|
||||
title: "Test a notification",
|
||||
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
||||
systemImage: "bell.badge"
|
||||
)
|
||||
}
|
||||
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
.disabled(true)
|
||||
|
||||
if currentPage > 0 {
|
||||
Button(action: { handleBack() }) {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.8)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 80, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<pageCount, id: \.self) { index in
|
||||
Button {
|
||||
withAnimation { currentPage = index }
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(index == currentPage ? Color.accentColor : Color.gray.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: handleNext) {
|
||||
Text(buttonTitle)
|
||||
.frame(minWidth: 88)
|
||||
}
|
||||
.keyboardShortcut(.return)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.frame(height: 60)
|
||||
}
|
||||
|
||||
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(spacing: 22) {
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: pageWidth, alignment: .top)
|
||||
.padding(.horizontal, 26)
|
||||
}
|
||||
|
||||
private func onboardingCard(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
content()
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -1316,73 +1514,30 @@ struct OnboardingView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var loginCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Launch at login")
|
||||
.font(.headline)
|
||||
Text("Keep the companion ready before automations start. You can change this anytime in Settings.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Enable launch at login", isOn: $state.launchAtLogin)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: state.launchAtLogin) { _, newValue in
|
||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Give Clawdis the access it needs")
|
||||
.font(.headline)
|
||||
Text("Grant these once; the CLI will reuse the same approvals.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(Capability.allCases, id: \.self) { cap in
|
||||
PermissionRow(capability: cap, status: permStatus[cap] ?? false) {
|
||||
Task { await request(cap) }
|
||||
}
|
||||
private func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button("Refresh status") { Task { await refreshPerms() } }
|
||||
.font(.footnote)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 8) {
|
||||
Spacer()
|
||||
ForEach(Array(steps.indices), id: \.self) { idx in
|
||||
Circle()
|
||||
.fill(idx == stepIndex ? Color.accentColor : Color.gray.opacity(0.35))
|
||||
.frame(width: 8, height: 8)
|
||||
.scaleEffect(idx == stepIndex ? 1.2 : 1.0)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: stepIndex)
|
||||
}
|
||||
Spacer()
|
||||
private func handleBack() {
|
||||
withAnimation {
|
||||
currentPage = max(0, currentPage - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var footerButtons: some View {
|
||||
HStack {
|
||||
Button("Skip") { finish() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
if stepIndex > 0 {
|
||||
Button("Back") { stepIndex = max(0, stepIndex - 1) }
|
||||
}
|
||||
Button(stepIndex == steps.count - 1 ? "Finish" : "Next") { advance() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
if stepIndex + 1 < steps.count {
|
||||
stepIndex += 1
|
||||
private func handleNext() {
|
||||
if currentPage < pageCount - 1 {
|
||||
withAnimation { currentPage += 1 }
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
@@ -1407,59 +1562,13 @@ struct OnboardingView: View {
|
||||
_ = await PermissionManager.ensure([cap], interactive: true)
|
||||
await refreshPerms()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingStep {
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
var showsPermissions: Bool = false
|
||||
var showsCLI: Bool = false
|
||||
var showsLogin: Bool = false
|
||||
|
||||
var showsLoginToggle: Bool { showsLogin }
|
||||
}
|
||||
|
||||
struct CLIInstallCard: View {
|
||||
@Binding var copied: Bool
|
||||
@State private var installing = false
|
||||
@State private var status: String?
|
||||
private let command = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Install the helper CLI")
|
||||
.font(.headline)
|
||||
Text("Link `clawdis-mac` so scripts and the agent can call the companion.")
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task {
|
||||
guard !installing else { return }
|
||||
installing = true
|
||||
defer { installing = false }
|
||||
await CLIInstaller.install { msg in await MainActor.run { status = msg } }
|
||||
}
|
||||
} label: {
|
||||
if installing { ProgressView() } else { Text("Install helper") }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(copied ? "Copied" : "Copy command") {
|
||||
copyToPasteboard(command)
|
||||
}
|
||||
.disabled(installing)
|
||||
}
|
||||
|
||||
if let status {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("You can rerun this anytime; we install into /usr/local/bin and /opt/homebrew/bin.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
private func installCLI() async {
|
||||
guard !installingCLI else { return }
|
||||
installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
await MainActor.run { cliStatus = message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1471,3 +1580,50 @@ struct CLIInstallCard: View {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { copied = false }
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlowingClawdisIcon: View {
|
||||
let size: CGFloat
|
||||
let glowIntensity: Double
|
||||
let enableFloating: Bool
|
||||
|
||||
@State private var breathe = false
|
||||
|
||||
init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) {
|
||||
self.size = size
|
||||
self.glowIntensity = glowIntensity
|
||||
self.enableFloating = enableFloating
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(glowIntensity),
|
||||
Color.blue.opacity(glowIntensity * 0.6)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.blur(radius: 22)
|
||||
.scaleEffect(breathe ? 1.12 : 0.95)
|
||||
.opacity(0.9)
|
||||
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
|
||||
.scaleEffect(breathe ? 1.02 : 1.0)
|
||||
}
|
||||
.frame(width: size + 60, height: size + 60)
|
||||
.onAppear {
|
||||
guard enableFloating else { return }
|
||||
withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) {
|
||||
breathe.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user