docs(website): lead with the agent-native story
`skills install` becomes the featured card on the home page, in the wide slot the grid gives its first entry: one command hands a coding agent the JSON keys of every record, the exit-code meanings, and the --json rule, with nothing to clone because the skill ships inside the binary. The docs page gains a callout and the command in the shell listing. Also catches the site up with the scriptable command surface the last two releases introduced: `get`, `shares add`, `serve`, `room list`, `message send` and `--json` replace the flags and spellings that no longer exist.
This commit is contained in:
+2
-2
@@ -8,7 +8,7 @@
|
|||||||
<title>soulseek-rs, a Soulseek client for your terminal</title>
|
<title>soulseek-rs, a Soulseek client for your terminal</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library. Search, share, browse, and chat from your terminal."
|
content="soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library, with scriptable subcommands. Search, share, browse, and chat from your terminal."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#14110F" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#14110F" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#F2EDE4" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#F2EDE4" media="(prefers-color-scheme: light)" />
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<meta property="og:title" content="soulseek-rs" />
|
<meta property="og:title" content="soulseek-rs" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="A Soulseek client and protocol library for Rust. Search, share, browse, and chat from your terminal."
|
content="A Soulseek client and protocol library for Rust. Search, share, browse, and chat from your terminal or a script."
|
||||||
/>
|
/>
|
||||||
<meta property="og:url" content="https://re-invention.nl/soulseek-rs/" />
|
<meta property="og:url" content="https://re-invention.nl/soulseek-rs/" />
|
||||||
<link rel="canonical" href="https://re-invention.nl/soulseek-rs/" />
|
<link rel="canonical" href="https://re-invention.nl/soulseek-rs/" />
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ const ALTERNATIVES: readonly Alternative[] = [
|
|||||||
links: [{ label: 'Nicotine+', href: LINKS.nicotine }],
|
links: [{ label: 'Nicotine+', href: LINKS.nicotine }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Want to point automation at the network?',
|
question: 'Want a queue that outlives the command?',
|
||||||
answer: <>slskd does that today. Daemon mode here is unbuilt.</>,
|
answer: (
|
||||||
|
<>
|
||||||
|
Every command runs one-shot and exits: JSON on stdout, an exit code, nothing left
|
||||||
|
running. No resident queue to pause, resume, or drive remotely. slskd has that.
|
||||||
|
</>
|
||||||
|
),
|
||||||
links: [{ label: 'slskd', href: LINKS.slskd }],
|
links: [{ label: 'slskd', href: LINKS.slskd }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -45,8 +50,8 @@ const CITIZENSHIP = [
|
|||||||
title: 'Share, and share real files',
|
title: 'Share, and share real files',
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Point <code>--shared-dir</code> at the collection you actually listen to. Full
|
Point <code>shares add</code> at the collection you actually listen to. Full albums,
|
||||||
albums, honest bitrates, nothing transcoded up from a lossy source and relabelled.
|
honest bitrates, nothing transcoded up from a lossy source and relabelled.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -67,8 +72,8 @@ const CITIZENSHIP = [
|
|||||||
title: 'Stay online',
|
title: 'Stay online',
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Leave it running in a tmux session on the box that is already on. A share that
|
Leave <code>serve --follow</code> running in a tmux session on the box that is
|
||||||
appears for an hour a week is close to no share at all.
|
already on. A share that appears for an hour a week is close to no share at all.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
+39
-13
@@ -15,6 +15,7 @@ import { Terminal } from '@/components/ui/terminal'
|
|||||||
import { LINKS } from '@/lib/links'
|
import { LINKS } from '@/lib/links'
|
||||||
|
|
||||||
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
||||||
|
['s', 'search the network'],
|
||||||
['Space', 'select a result'],
|
['Space', 'select a result'],
|
||||||
['Enter', 'download / send'],
|
['Enter', 'download / send'],
|
||||||
['b', "browse the owner's files"],
|
['b', "browse the owner's files"],
|
||||||
@@ -41,7 +42,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [
|
|||||||
{
|
{
|
||||||
num: 1,
|
num: 1,
|
||||||
title: 'Searches',
|
title: 'Searches',
|
||||||
body: 'Your queries and how many results each returned. Type / to search the network; each search keeps its own result set.',
|
body: 'Your queries and how many results each returned. Press s to search the network; each search keeps its own result set.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: 2,
|
num: 2,
|
||||||
@@ -153,27 +154,40 @@ export const Docs = () => {
|
|||||||
eyebrow="4 · from the shell"
|
eyebrow="4 · from the shell"
|
||||||
title="Every command also runs as a one-off."
|
title="Every command also runs as a one-off."
|
||||||
>
|
>
|
||||||
Skip the TUI when you’re scripting or mid-task.
|
Skip the TUI when you’re scripting or mid-task. stdout is data, stderr is
|
||||||
|
progress, and the exit code is the verdict.
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<Cols start>
|
<Cols start>
|
||||||
<Terminal
|
<Terminal
|
||||||
label="shell"
|
label="shell"
|
||||||
lines={[
|
lines={[
|
||||||
{ t: 'cmd', text: 'soulseek-rs "public domain field recordings"' },
|
{ t: 'cmd', text: 'soulseek-rs get "public domain field recordings"' },
|
||||||
{ t: 'cm', text: '# open the TUI straight onto a search' },
|
{ t: 'cm', text: '# search, pick the best, download it' },
|
||||||
{ t: 'cmd', text: 'soulseek-rs message <username> "hello there"' },
|
{ t: 'cmd', text: 'soulseek-rs search "netlabel 2004" --min-bitrate 320' },
|
||||||
{ t: 'cmd', text: 'soulseek-rs rooms' },
|
{ t: 'cmd', text: 'soulseek-rs browse <username> --json' },
|
||||||
{ t: 'cm', text: '# list public rooms, busiest first' },
|
{ t: 'cmd', text: 'soulseek-rs room list' },
|
||||||
{ t: 'cmd', text: 'soulseek-rs chat <room> "hello room"' },
|
{ t: 'cm', text: '# public rooms, busiest first' },
|
||||||
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
{ t: 'cmd', text: 'soulseek-rs room say <room> "hello room"' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs message send <username> "hello there"' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs serve --follow' },
|
||||||
|
{ t: 'cm', text: '# stay online sharing; every upload as it happens' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs skills install' },
|
||||||
|
{ t: 'cm', text: '# hand all of the above to your coding agent' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Prose>
|
<Prose>
|
||||||
<p>
|
<p>
|
||||||
The subcommands mirror the TUI: <Code>search</Code>, <Code>message</Code>,{' '}
|
The command groups: <Code>search</Code>, <Code>get</Code>,{' '}
|
||||||
<Code>browse</Code>, <Code>rooms</Code>, <Code>chat</Code>, and{' '}
|
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
|
||||||
<Code>portmap</Code>. Pass a room and a message to <Code>chat</Code> to say
|
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
|
||||||
one thing and exit.
|
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
|
||||||
|
<Code>portmap</Code>, <Code>skills</Code>. Records are tab-separated fields,
|
||||||
|
one per line, or NDJSON with <Code>--json</Code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
|
||||||
|
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
|
||||||
|
nothing, 3 could not reach the server, 6 the transfer died.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Code>portmap</Code> tests whether your router will let peers connect back,
|
<Code>portmap</Code> tests whether your router will let peers connect back,
|
||||||
@@ -185,6 +199,18 @@ export const Docs = () => {
|
|||||||
</Prose>
|
</Prose>
|
||||||
</Cols>
|
</Cols>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
<Callout title="give it to your agent">
|
||||||
|
<p>
|
||||||
|
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into
|
||||||
|
every coding agent on the machine — Claude Code and opencode today,{' '}
|
||||||
|
<Code>--dir</Code> for anything else. It carries what <Code>--help</Code>{' '}
|
||||||
|
cannot: the JSON keys of every record, what each exit code means, and the
|
||||||
|
idioms worth reaching for. Run it again after an upgrade to update it, and{' '}
|
||||||
|
<Code>skills uninstall</Code> takes it back out.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
<Callout tone="warn" title="the one limit worth knowing up front">
|
<Callout tone="warn" title="the one limit worth knowing up front">
|
||||||
<p>
|
<p>
|
||||||
If both you and a peer are behind routers with no forwarded port, browsing
|
If both you and a peer are behind routers with no forwarded port, browsing
|
||||||
|
|||||||
+34
-15
@@ -20,22 +20,41 @@ const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const FEATURES = [
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
glyph: '✦',
|
||||||
|
color: 'var(--accent-text)',
|
||||||
|
title: 'Agent native',
|
||||||
|
cmd: 'soulseek-rs skills install',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
One command hands your coding agent the whole surface: the JSON keys every record
|
||||||
|
carries, what each exit code means, and the rule that it always asks for{' '}
|
||||||
|
<code>--json</code>. The skill ships inside the binary, so there is nothing to
|
||||||
|
clone and nothing to configure — it finds Claude Code and opencode on its own.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
glyph: '→',
|
glyph: '→',
|
||||||
color: 'var(--accent-text)',
|
color: 'var(--accent-text)',
|
||||||
title: 'Search & download',
|
title: 'Search & download',
|
||||||
cmd: 'soulseek-rs "field recordings"',
|
cmd: 'soulseek-rs get "field recordings"',
|
||||||
body: <>Search the network, pick results in the TUI, and queue downloads with pause, resume, and retry.</>,
|
body: (
|
||||||
|
<>
|
||||||
|
Queue downloads in the TUI with pause, resume, and retry, or let <code>get</code>{' '}
|
||||||
|
search, pick the best, and download in one command.
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
glyph: '↑',
|
glyph: '↑',
|
||||||
color: 'var(--status-success)',
|
color: 'var(--status-success)',
|
||||||
title: 'Sharing',
|
title: 'Sharing',
|
||||||
cmd: 'soulseek-rs --shared-dir ~/Music',
|
cmd: 'soulseek-rs shares add ~/Music',
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Point <code>--shared-dir</code> at a directory and your files show up in searches;
|
Point <code>shares add</code> at a directory and your files show up in searches;{' '}
|
||||||
peers browse and download them.
|
<code>serve</code> stays online so peers can browse and download them.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -50,14 +69,14 @@ const FEATURES = [
|
|||||||
glyph: '#',
|
glyph: '#',
|
||||||
color: 'var(--status-warning)',
|
color: 'var(--status-warning)',
|
||||||
title: 'Chat rooms',
|
title: 'Chat rooms',
|
||||||
cmd: 'soulseek-rs rooms',
|
cmd: 'soulseek-rs room list',
|
||||||
body: <>List, join, and talk in public rooms, several open at once as tabs.</>,
|
body: <>List, join, and talk in public rooms, several open at once as tabs.</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
glyph: '@',
|
glyph: '@',
|
||||||
color: 'var(--status-info)',
|
color: 'var(--status-info)',
|
||||||
title: 'Private messages',
|
title: 'Private messages',
|
||||||
cmd: 'soulseek-rs message <user> "hi"',
|
cmd: 'soulseek-rs message send <user> "hi"',
|
||||||
body: <>Send and receive PMs, with an inbox and an unread counter in the TUI.</>,
|
body: <>Send and receive PMs, with an inbox and an unread counter in the TUI.</>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,11 +107,11 @@ const FEATURES = [
|
|||||||
glyph: '▮',
|
glyph: '▮',
|
||||||
color: 'var(--accent-text)',
|
color: 'var(--accent-text)',
|
||||||
title: 'TUI and CLI',
|
title: 'TUI and CLI',
|
||||||
cmd: 'soulseek-rs',
|
cmd: 'soulseek-rs search "rjd2" --json',
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
A full terminal interface, plus scriptable subcommands: search, message, browse,
|
A full terminal interface, plus scriptable subcommands: stdout is data, stderr is
|
||||||
rooms, chat, portmap.
|
progress, the exit code is the verdict.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -203,10 +222,10 @@ export const Home = () => {
|
|||||||
</Callout>
|
</Callout>
|
||||||
<Callout title="agents and automation">
|
<Callout title="agents and automation">
|
||||||
<p>
|
<p>
|
||||||
Every command runs as a one-off from the shell: <Code>search</Code>,{' '}
|
Every command runs as a one-off from the shell. Point cron, a script, or an
|
||||||
<Code>browse</Code>, <Code>rooms</Code>, <Code>chat</Code>,{' '}
|
agent at them and read JSON records back off stdout. No daemon, no API to
|
||||||
<Code>message</Code>, <Code>portmap</Code>. Point cron, a script, or an
|
stand up — and <Code>skills install</Code> teaches the agent the surface
|
||||||
agent at them and read the output back. No daemon, no API to stand up.{' '}
|
for you.{' '}
|
||||||
<Link to="/docs" className="text-link hover:text-link-hover">
|
<Link to="/docs" className="text-link hover:text-link-hover">
|
||||||
See the commands
|
See the commands
|
||||||
</Link>
|
</Link>
|
||||||
@@ -243,7 +262,7 @@ export const Home = () => {
|
|||||||
<div className="flex flex-col gap-3.5">
|
<div className="flex flex-col gap-3.5">
|
||||||
<Eyebrow>get started</Eyebrow>
|
<Eyebrow>get started</Eyebrow>
|
||||||
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||||
Install it, then press <span className="text-accent-text">/</span> to search.
|
Install it, then press <span className="text-accent-text">s</span> to search.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-secondary">
|
<p className="text-secondary">
|
||||||
Needs a Rust toolchain. From there it’s one command, then the TUI opens.
|
Needs a Rust toolchain. From there it’s one command, then the TUI opens.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut client = Client::new("username", "password");
|
let mut client = Client::new("username", "password");
|
||||||
client.connect();
|
client.connect()?;
|
||||||
client.login()?;
|
client.login()?;
|
||||||
|
|
||||||
let results = client.search("public domain field recordings", Duration::from_secs(10))?;
|
let results = client.search("public domain field recordings", Duration::from_secs(10))?;
|
||||||
@@ -117,8 +117,8 @@ export const Install = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<p className="text-[13px] text-muted">
|
<p className="text-[13px] text-muted">
|
||||||
Cargo puts the binary on your PATH. Run <Code>soulseek-rs</Code> and the TUI
|
Cargo puts the binary on your PATH. Run <Code>soulseek-rs</Code> in a terminal
|
||||||
opens.
|
and the TUI opens, in a script or a pipe it wants a subcommand instead.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
||||||
@@ -205,7 +205,7 @@ export const Install = () => {
|
|||||||
<Section>
|
<Section>
|
||||||
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
|
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
|
||||||
Browsing and downloading are peer-to-peer, so at least one side has to accept an
|
Browsing and downloading are peer-to-peer, so at least one side has to accept an
|
||||||
incoming connection.
|
incoming connection. <Code>serve</Code> refuses to start with the listener off.
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<Cols start>
|
<Cols start>
|
||||||
<Prose>
|
<Prose>
|
||||||
@@ -218,13 +218,18 @@ export const Install = () => {
|
|||||||
yourself.
|
yourself.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The port is your <Code>--listener-port</Code> (env <Code>LISTENER_PORT</Code>,
|
The port is your <Code>--listener-port</Code> (env{' '}
|
||||||
default <Code>2234</Code>); it’s renewed automatically and removed on
|
<Code>SOULSEEK_LISTENER_PORT</Code>, default <Code>2234</Code>); it’s
|
||||||
exit. Pass <Code>--disable-listener</Code> to turn it off. Check your own
|
renewed automatically and removed on exit. Pass <Code>--no-listener</Code> to
|
||||||
network without launching the client:
|
turn it off. Check your own network without launching the client:
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Terminal lines={[{ t: 'cmd', text: 'soulseek-rs portmap' }]} />
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
||||||
|
{ t: 'cm', text: "# exits 0 when it works, 4 when it doesn't" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Prose>
|
</Prose>
|
||||||
<Callout tone="warn" title="honest about limits">
|
<Callout tone="warn" title="honest about limits">
|
||||||
@@ -288,13 +293,17 @@ export const Install = () => {
|
|||||||
<FeatureCard title="Secrets">
|
<FeatureCard title="Secrets">
|
||||||
Your password goes to the OS keychain, never a plain-text file. If you would
|
Your password goes to the OS keychain, never a plain-text file. If you would
|
||||||
rather it never be stored at all, have <code>password_cmd</code> shell out to
|
rather it never be stored at all, have <code>password_cmd</code> shell out to
|
||||||
your own password manager and print it on demand.
|
your own password manager and print it on demand. For CI and containers,{' '}
|
||||||
|
<code>--password-stdin</code> reads it off a pipe so it never reaches{' '}
|
||||||
|
<code>ps</code> or the environment.
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
<FeatureCard title="Settings and state">
|
<FeatureCard title="Settings and state">
|
||||||
<code>config.toml</code> is layered: flags beat environment variables, which
|
<code>config.toml</code> is layered: flags beat environment variables, which
|
||||||
beat the file, which beats the defaults. State (searches, downloads, rooms) is
|
beat the file, which beats the defaults. <code>config get</code> and{' '}
|
||||||
versioned JSON and restores on restart; anything unreadable is set aside as{' '}
|
<code>config set</code> read and write that file without opening an editor.
|
||||||
<code>.bak</code> instead of being overwritten.
|
State (searches, downloads, rooms) is versioned JSON and restores on restart;
|
||||||
|
anything unreadable is set aside as <code>.bak</code> instead of being
|
||||||
|
overwritten.
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
</Cols>
|
</Cols>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -302,14 +311,15 @@ export const Install = () => {
|
|||||||
<Section band>
|
<Section band>
|
||||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||||
Three things exist on disk: the binary, the config, and the state. Nothing else is
|
Three things exist on disk: the binary, the config, and the state. Nothing else is
|
||||||
written, and there is no telemetry to opt out of.
|
written unless you pointed <Code>--log-file</Code> at a file, and there is no
|
||||||
|
telemetry to opt out of.
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<Cols start>
|
<Cols start>
|
||||||
<Terminal
|
<Terminal
|
||||||
label="macOS · Linux"
|
label="macOS · Linux"
|
||||||
lines={[
|
lines={[
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
{ t: 'cm', text: '# config, state and any cached shares' },
|
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
|
||||||
{
|
{
|
||||||
t: 'cmd',
|
t: 'cmd',
|
||||||
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
||||||
|
|||||||
Reference in New Issue
Block a user