mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
fix: offer a drive by the folder its system reads
RetroArch's folder question numbered a Batocera SHARE drive and filled its root. A drive is now offered by its own system's BIOS folder, one nothing names by its bios folder, and another system's is left out and named with the options that reach it. A list or pack card setups read too opens on those setups, in both modes.
This commit is contained in:
1 parent
19bd88131d
commit
5704dd6793
6 files changed
+201
-37
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea"
|
||||
$defaultInstallSha256 = "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+94
-21
@@ -6691,12 +6691,66 @@ def share_targets(plat: str) -> list[tuple[str, Path]]:
|
||||
targets = []
|
||||
for mount in _mounted_cards():
|
||||
found = card_system(mount)
|
||||
if found is not None and found[0] != plat:
|
||||
doubt = share_doubt(mount)[0]
|
||||
if (found is not None and found[0] != plat) or (doubt and plat not in doubt):
|
||||
continue
|
||||
targets.append((f"{_mount_name(mount)}: bios", mount / "bios"))
|
||||
return targets
|
||||
|
||||
|
||||
def drive_targets(plat: str) -> list[tuple[str, Path]]:
|
||||
"""(row label, BIOS folder) of each drive mounted here offered at the
|
||||
folder question of a setup that runs from no card (RetroArch here): a
|
||||
card of a system that reads plat's list by its own BIOS folder, a drive
|
||||
nothing names by its bios folder. A drive's root is no answer: no setup
|
||||
reads its BIOS files there, and RetroArch's list landed beside the roms/
|
||||
and system/ of a Batocera SHARE drive (code_audit_18 P2-2, tui_audit_11
|
||||
P1-2). Another system's card or drive, and one two systems fit, are
|
||||
reached through their own setup (drives_left_out)."""
|
||||
targets = []
|
||||
for mount in _mounted_cards():
|
||||
found = card_system(mount)
|
||||
if (found is not None and found[0] != plat) or share_doubt(mount)[0]:
|
||||
continue
|
||||
name = _mount_name(mount)
|
||||
if found is not None:
|
||||
below = found[1].relative_to(mount).as_posix() if found[1] != mount else ""
|
||||
what = f"{found[2]} {_card_word(found)}"
|
||||
targets.append((f"{name}: {below} ({what})" if below else f"{name} ({what})",
|
||||
found[1]))
|
||||
else:
|
||||
targets.append((f"{name}: bios (card or drive)", mount / "bios"))
|
||||
return targets
|
||||
|
||||
|
||||
def _card_word(system: tuple[str, Path, str]) -> str:
|
||||
"""A mounted drive's kind as the first screen writes it: SD card, or
|
||||
SHARE drive."""
|
||||
return "SD card" if drive_kind(system) == "card" else drive_kind(system)
|
||||
|
||||
|
||||
def drives_left_out(plat: str) -> str:
|
||||
"""Why the drives mounted here that another system reads are not offered
|
||||
at plat's folder question, in words, "" for none."""
|
||||
others = []
|
||||
for mount in _mounted_cards():
|
||||
found = card_system(mount)
|
||||
doubt = share_doubt(mount)[0]
|
||||
# The options that reach it, which work wherever this is asked: the
|
||||
# setup list is not offered once a platform is named.
|
||||
if doubt:
|
||||
names = " or ".join(f"--platform {name}" for name in doubt)
|
||||
others.append(f"{_mount_name(mount)} ({_doubt_kinds(doubt)}: {names} "
|
||||
f"--dest {shown_path(mount)})")
|
||||
elif found is not None and found[0] != plat:
|
||||
word = _name_key(found[2]) or found[0]
|
||||
others.append(f"{_mount_name(mount)} ({found[2]} {_card_word(found)}: "
|
||||
f"--platform {word} --dest {shown_path(mount)})")
|
||||
if not others:
|
||||
return ""
|
||||
return (f"Not offered here, each reads its own system's files: {', '.join(others)}.")
|
||||
|
||||
|
||||
def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
|
||||
check: bool = True, found: Path | None = None) -> Path:
|
||||
"""The BIOS folder for a setup chosen from the list, asked as the
|
||||
@@ -6718,15 +6772,15 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
|
||||
elif default is not None:
|
||||
state = "" if _exists(default) else " (it does not exist yet and will be created)"
|
||||
print(f" {name}'s default BIOS folder: {shown_path(default)}{state}.")
|
||||
if shared:
|
||||
targets = share_targets(plat)
|
||||
for index, (label, folder) in enumerate(targets, 1):
|
||||
made = "" if _is_dir(folder) else ", created"
|
||||
print(f" {index}) {label} ({shown_path(folder)}{made})")
|
||||
else:
|
||||
targets = [(card_line(mount), mount) for mount in _mounted_cards()]
|
||||
for index, (label, _mount) in enumerate(targets, 1):
|
||||
print(f" {index}) {label}")
|
||||
# A drive is offered by the BIOS folder its system reads, the screen's
|
||||
# rows: never its root, and never for a system that reads another list.
|
||||
targets = share_targets(plat) if shared else drive_targets(plat)
|
||||
for index, (label, folder) in enumerate(targets, 1):
|
||||
made = "" if _is_dir(folder) else ", created"
|
||||
print(f" {index}) {label} ({shown_path(folder)}{made})")
|
||||
left_out = "" if shared else drives_left_out(plat)
|
||||
if left_out:
|
||||
print(f" {left_out}")
|
||||
numbers = "" if not targets else (" or 1" if len(targets) == 1
|
||||
else f" or 1 to {len(targets)}")
|
||||
why = ("" if default is not None or shared
|
||||
@@ -6741,10 +6795,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
|
||||
if raw is None:
|
||||
return default
|
||||
if raw.isdigit() and 1 <= int(raw) <= len(targets):
|
||||
folder = targets[int(raw) - 1][1]
|
||||
if shared:
|
||||
# The screen's row, taken as it takes it.
|
||||
return folder
|
||||
# The screen's row, taken as it takes it.
|
||||
return targets[int(raw) - 1][1]
|
||||
else:
|
||||
# Named in full on BIOS folder:, Created and Location:, where
|
||||
# "q" alone said nothing of the folder it was in.
|
||||
@@ -10077,7 +10129,10 @@ def _open_local_sources(args: argparse.Namespace) -> None:
|
||||
f"{display_name(plat)}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif len(spelled) == 1:
|
||||
# Named by the pack, not by the user: the setups that read its list
|
||||
# are offered, as for a --manifest.
|
||||
args.platform = spelled[0]
|
||||
args.platform_from_list = True
|
||||
else:
|
||||
among = (" (" + ", ".join(spelled) + ")") if spelled else ""
|
||||
print(f"Error: name the platform {pack.name} is installed for with "
|
||||
@@ -11277,6 +11332,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
platforms = [(args.platform, card_install_dest(
|
||||
named, args.platform, interactive,
|
||||
not (args.check or args.dry_run or args.yes)))]
|
||||
elif interactive and getattr(args, "platform_from_list", False) and len(
|
||||
_line_setups(setups, mounted, os_type)) > 1:
|
||||
# A list or a pack that card setups read too: they are offered,
|
||||
# RetroArch first, as the screen offers them. Its folder question
|
||||
# alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2).
|
||||
print(f" {display_name(args.platform)} was not found here.")
|
||||
platforms = _prompt_manual_platform(
|
||||
os_type, not (args.check or args.dry_run or args.yes), True,
|
||||
f"Which setup is the {display_name(args.platform)} list for?", setups, mounted)
|
||||
elif interactive:
|
||||
# Asked as the screen asks, never taken: the default folder was
|
||||
# filled with a card mounted in the reader, and with nothing
|
||||
@@ -13064,10 +13128,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
|
||||
for label, folder in share_targets(state.platform)]
|
||||
if not bound:
|
||||
# The drives mounted here, as line mode numbers them at the same
|
||||
# question: a stick in the reader was offered there and not here.
|
||||
# question, each by the BIOS folder its system reads.
|
||||
drives = [] if state.folder_kind == "share" else [
|
||||
TuiRow("drive", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
|
||||
for mount in _mounted_cards()]
|
||||
TuiRow("drive", label, value=folder)
|
||||
for label, folder in drive_targets(state.platform)]
|
||||
return rows + [TuiRow("browse", "Browse for a folder"),
|
||||
TuiRow("type", "Type a path")] + drives
|
||||
# Only the cards whose own files say they run the system: A on the
|
||||
@@ -13891,10 +13955,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
|
||||
if screen == "folder":
|
||||
if action == "default":
|
||||
return _choose(state, state.platform, state.folder_default, proposed=True)
|
||||
if action == "share":
|
||||
if action in ("share", "drive"):
|
||||
return _choose(state, state.platform, row.value, proposed=True)
|
||||
if action == "drive":
|
||||
return _choose_card(state, row.value)
|
||||
if action in ("card", "rescan"):
|
||||
if action == "rescan":
|
||||
state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform)
|
||||
@@ -15571,6 +15633,9 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple:
|
||||
else:
|
||||
title = "Which folder?"
|
||||
before = [f"No usual {name} folder here: browse for it or type it."]
|
||||
if not bound and state.folder_kind != "share":
|
||||
left_out = drives_left_out(state.platform)
|
||||
before += [left_out] if left_out else []
|
||||
return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state),
|
||||
pinned=state.refused),
|
||||
("A/Enter Choose", "B/Esc Back", "? Help"))
|
||||
@@ -16486,7 +16551,7 @@ def found_setups(os_type: str) -> tuple[list[tuple[str, Path, str]], set]:
|
||||
# than the old one, comes first (tui_audit_11 P3-15).
|
||||
taken.sort(key=lambda item: (systems.index(item[0][2]), counts.get(item[1], -1)))
|
||||
for card, mount in taken:
|
||||
kind = "SHARE drive" if drive_kind(card) != "card" else "SD card"
|
||||
kind = _card_word(card)
|
||||
held = counts.get(mount)
|
||||
what = ("" if held is None else ", no BIOS folder" if held < 0
|
||||
else f", {_plural(held, 'BIOS file')}")
|
||||
@@ -16572,6 +16637,14 @@ def tui_start(
|
||||
if matched:
|
||||
state.setups = matched
|
||||
return state, []
|
||||
if (getattr(args, "platform_from_list", False) and not bound and len(setup_list(
|
||||
state.setups, state.mounted_setups, "", os_type, LOCAL.serves())) > 1):
|
||||
# A list or a pack card setups read too: the setup list, filtered
|
||||
# to them and RetroArch first, as line mode asks (tui_audit_11
|
||||
# P1-2). The folder question alone left them out of reach.
|
||||
state.stack = ["platforms"]
|
||||
state.focus["platforms"] = _platform_focus(state, plat)
|
||||
return state, []
|
||||
state.platform = plat
|
||||
state.card_bound = bound
|
||||
state.stack = []
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea"
|
||||
DEFAULT_INSTALL_SHA256="f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 786759,
|
||||
"sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea",
|
||||
"size": 790778,
|
||||
"sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 839308,
|
||||
"sha256": "4fef6336d600ff82f1b0d5040d537775df190c23d9ae97e022160e60189e127a"
|
||||
"size": 843327,
|
||||
"sha256": "edf0948281063d1b9abf271f778a4486adf83b18924965eff68308e1540f25ab"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 786759,
|
||||
"sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea",
|
||||
"size": 790778,
|
||||
"sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 786759,
|
||||
"sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea",
|
||||
"size": 790778,
|
||||
"sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6704,6 +6704,86 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
|
||||
self.assertIn("a run without questions installs for one", text)
|
||||
self.assertRegex(text, r"--platform \w+ --dest ")
|
||||
|
||||
def share(self, name: str = "SHARE") -> Path:
|
||||
share = self.root / "media" / name
|
||||
(share / "system").mkdir(parents=True)
|
||||
(share / "roms").mkdir()
|
||||
(share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n")
|
||||
return share
|
||||
|
||||
def test_a_drive_is_offered_by_the_folder_its_system_reads(self):
|
||||
"""code_audit_18 P2-2, tui_audit_11 P1-2: RetroArch's folder question
|
||||
numbered a Batocera SHARE drive and a KNULLI card, and one press put
|
||||
RetroArch's list at their root, beside roms/ and system/. A drive
|
||||
nothing names went to its root too. Now another system's drive is
|
||||
left out and said, in both modes, and a drive is its bios folder."""
|
||||
share, knulli = self.share(), self.card("KNULLI", "KNULLI")
|
||||
stick = self.root / "media" / "EASYROMS"
|
||||
(stick / "bios").mkdir(parents=True)
|
||||
onion = self.card("ONION", "Onion")
|
||||
mounts = [share, knulli, stick, onion]
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out, \
|
||||
mock.patch.object(install, "_ask", side_effect=["q"]), \
|
||||
self.assertRaises(SystemExit):
|
||||
install.ask_platform_folder("linux", "retroarch")
|
||||
text = out.getvalue()
|
||||
self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text)
|
||||
self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text)
|
||||
self.assertNotIn("3)", text)
|
||||
self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera "
|
||||
f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD "
|
||||
f"card: --platform knulli --dest {knulli}).", text)
|
||||
state = install.TuiState(os_type="linux", home=HOME)
|
||||
state.platform, state.stack = "retroarch", ["folder"]
|
||||
state.folder_default, state.folder_kind = SYSTEM_DIR, "usual"
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()):
|
||||
rows = install.tui_rows(state)
|
||||
screen = " ".join(install.tui_view(state, 120, 40))
|
||||
state.focus["folder"] = next(index for index, row in enumerate(rows)
|
||||
if row.action == "drive")
|
||||
install.tui_update(state, Key("enter"))
|
||||
self.assertEqual([row.value for row in rows if row.action == "drive"],
|
||||
[stick / "bios", onion / "BIOS"])
|
||||
self.assertIn("Not offered here", screen)
|
||||
self.assertEqual(state.dest, stick / "bios")
|
||||
|
||||
def test_a_list_card_setups_read_offers_them_first(self):
|
||||
"""tui_audit_11 P1-2: with a RetroArch pack or list and nothing found,
|
||||
the first screen was RetroArch's folder question: ArkOS, muOS and
|
||||
JELOS, which read the same list, were out of reach. Both modes now
|
||||
open on the setups that read it, RetroArch first."""
|
||||
stick = self.root / "media" / "EASYROMS"
|
||||
(stick / "bios").mkdir(parents=True)
|
||||
args = dict(manifest=self.list, declared_only=False, offline=False)
|
||||
text, asked = self.line_run(_args(**args), [], mounts=[stick])
|
||||
self.assertIn("Which setup is the RetroArch list for?", text)
|
||||
self.assertIn("ArkOS", text)
|
||||
self.assertNotIn("Batocera", text)
|
||||
self.assertIn("[Enter for RetroArch]", asked[0])
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \
|
||||
mock.patch.object(install, "_mount_points", return_value=frozenset()), \
|
||||
mock.patch.object(install, "detect_platforms", return_value=[]), \
|
||||
mock.patch.object(install, "detect_frontends", return_value=[]):
|
||||
install._DETECTED_OS.clear()
|
||||
state = screen_keys(_args(**args), [])
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
install._open_local_sources(_args(**args))
|
||||
rows = install.tui_rows(state)
|
||||
labels = [row.label for row in rows]
|
||||
focused = rows[state.focus["platforms"]]
|
||||
install._DETECTED_OS.clear()
|
||||
ark = screen_keys(_args(**args), ["end", Key("char", "/"), *[
|
||||
Key("char", c) for c in "arkos"], "enter", "enter"])
|
||||
self.assertEqual(state.screen, "platforms")
|
||||
self.assertEqual(focused.value, "retroarch")
|
||||
self.assertTrue(any(label.startswith("ArkOS") for label in labels), labels)
|
||||
self.assertFalse(any(label.startswith("Batocera") for label in labels), labels)
|
||||
self.assertEqual(ark.screen, "folder")
|
||||
self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS")
|
||||
|
||||
def test_line_mode_says_the_card_it_found_and_names_its_folder(self):
|
||||
onion = self.card("ONION", "Onion")
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \
|
||||
@@ -6741,7 +6821,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
|
||||
mock.patch.object(install, "detect_os", return_value="linux"), \
|
||||
mock.patch.object(install.sys.stdin, "isatty", return_value=True), \
|
||||
mock.patch.object(install, "_is_terminal", return_value=True), \
|
||||
mock.patch.object(install, "_ask", return_value=str(self.here)), \
|
||||
mock.patch.object(install, "_ask", side_effect=lambda prompt: (
|
||||
"" if prompt.startswith(">") else str(self.here))), \
|
||||
mock.patch.object(install, "_prompt_custom_selection", menu), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out, \
|
||||
self.assertRaises(SystemExit):
|
||||
@@ -7089,13 +7170,16 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
|
||||
mock.patch.object(install, "_ask", side_effect=["1"]), \
|
||||
mock.patch.object(install, "_missing_dest", return_value=True):
|
||||
folder = install.ask_platform_folder("linux", "retroarch")
|
||||
self.assertEqual(folder, stick)
|
||||
# tui_audit_11 P1-2: a drive is offered by its bios folder, never its
|
||||
# root, which no setup reads.
|
||||
self.assertEqual(folder, stick / "bios")
|
||||
state = install.TuiState(os_type="linux", home=HOME)
|
||||
state.platform, state.stack = "retroarch", ["folder"]
|
||||
state.folder_default, state.folder_kind = SYSTEM_DIR, "usual"
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=mounts):
|
||||
rows = install.tui_rows(state)
|
||||
self.assertEqual([row.value for row in rows if row.action == "drive"], [stick])
|
||||
self.assertEqual([row.value for row in rows if row.action == "drive"],
|
||||
[stick / "bios"])
|
||||
|
||||
def test_the_systems_are_listed_by_title_in_the_screen_order(self):
|
||||
"""tui_audit_10 P3-7: line mode listed ids, nintendo-3ds before
|
||||
|
||||
+10
-3
@@ -1031,9 +1031,16 @@ and `--plain` gives the same list numbered, in the same order, with `--dest`
|
||||
too. Both find the SD cards mounted here, the screen under **Found on this
|
||||
machine**. With a setup found, the setup screen lists it with **Another setup
|
||||
or folder**, and `--plain` asks the same: Enter takes the first, `o` opens the
|
||||
numbered list and then the folder, `q` quits. A platform named, or read from
|
||||
a list or a pack, and found nowhere gets its folder asked, its usual one under
|
||||
Enter, the mounted drives numbered. A folder typed is read from the current
|
||||
numbered list and then the folder, `q` quits. A platform named and found
|
||||
nowhere gets its folder asked, its usual one under Enter, the mounted drives
|
||||
numbered. A list or a pack read by card setups too (RetroArch's, read by Onion,
|
||||
muOS, ArkOS and the others) opens the setup list instead, filtered to the
|
||||
setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At
|
||||
that question a drive is numbered by the folder its system reads (`ONION:
|
||||
BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never
|
||||
by its root, which no setup reads. Another system's card or SHARE drive, and a
|
||||
drive two systems fit, are left out, and a line names them: each is reached
|
||||
through its own setup. A folder typed is read from the current
|
||||
one when it is not a full path, and named in full after. `q` quits there too
|
||||
(a folder named `q` is typed `./q`), and Enter with no default says what to
|
||||
type. On a computer Batocera and Recalbox have no default: the drives are
|
||||
|
||||
Reference in new issue
Block a user