mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 04:04:09 +02:00
hand recorder: final consent text (2026-10-03), residency check, installer
Consent 2026-10-03, after a non-lawyer review: who runs this and how to reach them, the dataset is public (Hugging Face, possibly abroad), the Hugging Face username shows next to the contributor id, purposes (no identification), safety, the maintainer grant passes to whoever maintains Frametop next, withdrawal before and after merge, rights such as the GDPR's, and what a new version means. Residents of Illinois, Texas and Washington can't take part for now (biometric privacy laws): a third checkbox, profile consent.region_ok, checked by validate.py from this consent version on. The DRAFT banners are gone, so uploads no longer need FT_HANDREC_ALLOW_UPLOAD. hands/rec/install.sh installs the recorder on a Frame with Frametop: container packages, hand tracking and panel builds, ft-camd's capabilities, menu entry. test_qml_backend also checks each call's argument count against the slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1eb120e6b1
commit
2351cec310
9 files changed
+158
-32
No files matched your search
+56
-19
@@ -1,60 +1,97 @@
|
||||
**DRAFT: this text hasn't had a legal review yet. Contributions aren't open until it has.**
|
||||
|
||||
# Recording your hands for the Frametop hand dataset
|
||||
|
||||
Version: 2026-10-02
|
||||
Version: 2026-10-03
|
||||
|
||||
Frametop's hand tracking needs a small model that finds hands in the headset's camera images. To train it, we're collecting recordings of many people's hands. This page explains what the hand recorder records, what happens to it, and what you agree to if you take part. Please read all of it.
|
||||
Frametop's hand tracking needs a small model that finds hands in the headset's camera images. To train it, we're collecting recordings of many people's hands into a public dataset. This page explains what the hand recorder records, where it goes, and what you agree to if you take part. Please read all of it.
|
||||
|
||||
## Who runs this
|
||||
|
||||
Frametop is an independent open-source project, maintained by DeeJanuz (<https://github.com/DeeJanuz>). It isn't affiliated with or endorsed by Valve or Hugging Face. Steam and Steam Frame are trademarks of Valve Corporation. This text was written without a lawyer, in good faith; if something in it seems wrong or unclear, please say so before you take part.
|
||||
|
||||
You can reach the maintainer through the Frametop repository's issues (<https://github.com/DeeJanuz/frametop/issues>) or the dataset's discussion page on Hugging Face. Both are public.
|
||||
|
||||
## Who can take part
|
||||
|
||||
You must be 18 or older.
|
||||
- You must be **18 or older**.
|
||||
- For now, you can't take part if you live in **Illinois, Texas or Washington** (USA). Those states have laws about biometric data (such as hand geometry) that need more than this project can provide yet.
|
||||
- Take part only if you're comfortable with images of your hands and the room in front of you being public.
|
||||
|
||||
## What is recorded
|
||||
|
||||
While a session runs, the hand recorder saves:
|
||||
|
||||
- **Camera images.** Infrared images from the headset's 4 tracking cameras, about 10 sets a second. They show your hands, your arms and whatever is in front of you: your room, your desk and the things on it. They're grey, low-detail images, but people and things can be recognised in them.
|
||||
- **Motion.** The position and rotation of the headset and the controllers, many times a second.
|
||||
- **Camera images.** Infrared images from the headset's 4 tracking cameras, about 10 sets a second. They show your hands, your arms, your clothing and whatever is in front of you: your room, your desk and the things on it. They're grey, low-detail images, but people and things can be recognised in them. The size and shape of your hands can be measured from them: that's part of what they're for.
|
||||
- **Motion.** The position and rotation of the headset, many times a second, and of the controllers when you use them in the recording.
|
||||
- **Prompts.** What you were asked to do and when, and what the live hand tracker saw at the time.
|
||||
- **Calibration.** Where the cameras sit on the headset and how their lenses bend the image. Serial numbers and other fields that identify your headset are removed first, and the export lists what was removed.
|
||||
- **Your checklist answers.** Which objects you had, the lighting you chose, whether you wore sleeves, rings or a watch, and any notes you typed in.
|
||||
- **A contributor id.** A random number made on your headset the first time you agree to this page. It isn't linked to your name, your Steam account or your headset. It lets us keep your sessions together and find them if you withdraw.
|
||||
- **Your answers.** Which objects you had, the lighting, whether you wore sleeves, rings or a watch, your handedness if you gave it, and any notes you typed in the checklist or in Review. Notes are uploaded with the recordings and read by the maintainer: don't put anything in them that identifies you.
|
||||
- **Times.** When each session was recorded, with your time zone.
|
||||
- **A contributor id.** A random number made on your headset the first time you agree to this page. It isn't made from your name, your Steam account or your headset. It keeps your sessions together and lets you withdraw them.
|
||||
|
||||
The recorder doesn't record sound, your name, your email address or your account. Your eyes and face aren't recorded: the tracking cameras look outward.
|
||||
The recorder doesn't record sound, your name, your email address or your Steam account. The tracking cameras look outward, so your eyes and face aren't recorded, unless a mirror or something shiny shows them: avoid those.
|
||||
|
||||
**Your Hugging Face account.** You upload with your own Hugging Face account, and your pull request shows its username next to your contributor id, publicly. So anyone can see which Hugging Face account contributed which recordings. Use an account you're happy to have linked to them.
|
||||
|
||||
## What it's used for
|
||||
|
||||
- Training and testing hand-tracking models: finding hands, their keypoints, their shape and how far away they are. Mainly for Frametop's hand cutouts, and by anyone else for non-commercial work under the dataset's license.
|
||||
- It isn't used to recognise or identify people, and the dataset's terms forbid anyone from trying.
|
||||
|
||||
## Nothing leaves your headset unless you send it
|
||||
|
||||
- Recordings stay on your headset, in `~/.local/share/frametop/hands/contrib`. Nothing is uploaded automatically.
|
||||
- Before you share anything, you can watch every recording in the Review page. You can delete any stretch of a recording, a whole take or a whole session.
|
||||
- Export makes a package from what you kept. You upload it yourself, with your own Hugging Face account, following the Upload page. Until you do, nobody else has it.
|
||||
- An upload opens a pull request. The maintainer checks it before it becomes part of the dataset, and may decline it.
|
||||
- Before you share anything, you can watch every recording in the Review page. You can delete any stretch of a recording, a whole take or a whole session. Export leaves out what you deleted.
|
||||
- You upload the export yourself, from the Upload page. That opens a pull request on the dataset. The maintainer checks it before it becomes part of the dataset, and may decline it. Until it's merged, you can close the pull request yourself.
|
||||
|
||||
## Where it goes
|
||||
|
||||
- **The dataset is public.** It's hosted on Hugging Face (<https://huggingface.co/datasets/DeeJanuz/frametop-hands>), whose servers may be outside your country, for example in the USA. Anyone who accepts the dataset's terms can download it.
|
||||
- People who download it agree not to try to identify anyone or anything in it, and to delete recordings that are later withdrawn. We can't enforce that against everyone: assume copies may exist.
|
||||
- Recordings stay in the dataset until they're withdrawn or the dataset is taken down.
|
||||
|
||||
## Keep other people and private things out of view
|
||||
|
||||
While recording, please:
|
||||
|
||||
- face away from other people, mirrors, screens showing private things, papers, letters and anything else you wouldn't want in a public dataset;
|
||||
- make sure nobody else's face or hands are in view.
|
||||
- face away from other people, mirrors, screens showing private things, papers, letters, photos and anything else you wouldn't want public;
|
||||
- make sure nobody else's face or hands are in view, and record only where the people you share the space with are fine with it;
|
||||
- don't record children.
|
||||
|
||||
If something private got into a recording, delete that stretch in Review before you export. If you notice it after uploading, withdraw the session (below).
|
||||
|
||||
## Safety
|
||||
|
||||
The sessions ask you to move your hands and arms around you, out to full reach. Sit where you normally use the headset, clear an arm's reach around you, and stop whenever anything is uncomfortable. You take part at your own risk.
|
||||
|
||||
## The license
|
||||
|
||||
- **The dataset is published under Creative Commons Attribution-NonCommercial 4.0 (CC BY-NC 4.0).** Anyone may use it for non-commercial purposes, with attribution. Your contribution is credited by its contributor id, not your name.
|
||||
- **You also give the maintainer, DeeJanuz, a non-exclusive license to use your contribution for any purpose, including commercially.** That includes copying it, changing it, and training, publishing and selling models made from it, in Frametop and elsewhere. It's non-exclusive: you keep any rights you have in your recordings and can do what you like with your own copies.
|
||||
- **You also give the maintainer of Frametop a non-exclusive, worldwide, royalty-free license to use your contribution for any purpose, including commercially.** That includes copying it, changing it, and training, publishing and selling models made from it, in Frametop and elsewhere. The maintainer today is DeeJanuz. If someone else, or an organization, takes over maintaining Frametop, this license passes to them. It's non-exclusive: you keep any rights you have in your recordings and can do what you like with your own copies. It ends for a recording when you withdraw it, except for models already trained with it.
|
||||
- You confirm that you have the right to give these licenses: the recordings are yours, and nothing in them belongs to someone who hasn't agreed.
|
||||
- There is no payment, and the dataset comes with no warranty.
|
||||
- There is no payment. The dataset and the hand recorder come with no warranty, and as far as the law allows, the maintainer isn't liable for any loss or damage from taking part.
|
||||
|
||||
## Withdrawing
|
||||
|
||||
You can withdraw a contribution at any time. Send your contributor id (shown in the hand recorder) and which sessions to withdraw, or "all", through the dataset's discussion page or the Frametop repository's issues. Then:
|
||||
You can withdraw a contribution at any time, without giving a reason:
|
||||
|
||||
- **Before it's merged:** close your pull request on Hugging Face. The maintainer deletes its files.
|
||||
- **After it's merged:** post on the dataset's discussion page, or in the Frametop repository's issues, with your contributor id (shown in the hand recorder) and which sessions, or "all". Post from the Hugging Face account that uploaded them if you can, so the maintainer can tell it's you; otherwise, say how to check. These requests are public, so don't add anything else that identifies you.
|
||||
|
||||
Then, usually within 30 days:
|
||||
|
||||
- your recordings are deleted from the dataset and purged from its repository's history, so they can't be downloaded from there again;
|
||||
- they're left out of anything trained after that.
|
||||
|
||||
What can't be undone: copies others downloaded before the withdrawal, and models already trained with them.
|
||||
|
||||
## Your rights
|
||||
|
||||
Depending on where you live (for example in the EU or the UK, under the GDPR), the law may give you more rights over this data: to get a copy of it, to have it corrected or deleted, to object to its use, and to complain to your data protection authority. The data is used because you agreed to it, and you can withdraw that agreement at any time, as above. Withdrawing doesn't make earlier use unlawful. To use any of these rights, contact the maintainer as above.
|
||||
|
||||
## Changes to this text
|
||||
|
||||
If this text changes, the hand recorder shows the new version and asks you again before your next session. Each upload records the version you agreed to, and recordings you already uploaded stay under that version, unless you agree to a newer one or withdraw them.
|
||||
|
||||
## Agreeing
|
||||
|
||||
By ticking "I'm 18 or older" and "I agree", you confirm the above. You can still decide not to upload anything. If this text changes, the hand recorder asks you again before your next session.
|
||||
By ticking the three boxes and "Agree and continue", you confirm that you're 18 or older, that you don't live in Illinois, Texas or Washington, and that you agree to the above. You can still decide not to upload anything.
|
||||
@@ -17,6 +17,7 @@ The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md`
|
||||
| `hands/rec/ft-handrec` | The host launcher, like `input-settings/ft-input-settings`. |
|
||||
| `hands/rec/build.sh` | Builds `ft-handpanel` into `hands/rec/build/`, like `gaze/build.sh`. |
|
||||
| `hands/rec/CONSENT.md`, `hands/rec/UPLOAD.md` | The texts the window shows. |
|
||||
| `hands/rec/install.sh` | Installs the recorder on a Frame with Frametop: the dev container's packages, hands/build.sh, the panel, ft-camd's capabilities, and the menu entry (`uninstall` removes the entry). |
|
||||
| ft-hands `--record-hz N` (done) | Records at most N frame sets a second. The recorder uses 10. |
|
||||
| `hands/camcheck.py` (shared, standard library) | Are all four mono cameras running? The recorder runs it before a session and when a step sees no hands (below; `hands/README.md`, "Camera check"). |
|
||||
|
||||
|
||||
+2
-4
@@ -1,10 +1,8 @@
|
||||
**DRAFT: contributions aren't open yet. Please don't upload until this banner is gone.**
|
||||
|
||||
### About uploading
|
||||
|
||||
- **What's sent:** the export in `@EXPORT_PATH@` (@EXPORT_SIZE@), into `contributions/@CONTRIBUTOR@/@SESSION@` in [@DATASET@](https://huggingface.co/datasets/@DATASET@). Upload first checks that every file is complete and matches its checksum, and that nothing identifying is left in.
|
||||
- **Your account:** the pull request comes from your Hugging Face account, and your username shows on it. The dataset credits your contributor id, `@CONTRIBUTOR@`, not your name. The login stays saved on this headset, so you only log in once.
|
||||
- **The dataset's terms** are the same as the consent you agreed to in the hand recorder. Until you've accepted them on the dataset's page, Upload stops with "accept the dataset's terms first".
|
||||
- **Your account:** the pull request comes from your Hugging Face account, and anyone can see its username next to your contributor id, `@CONTRIBUTOR@`. The dataset itself credits the contributor id, not your name. The login stays saved on this headset, so you only log in once.
|
||||
- **The dataset's terms:** the first time, open the dataset's page and accept its terms. Until you have, Upload stops with "accept the dataset's terms first".
|
||||
- **Once your pull request's link shows, plug in the headset and leave it plugged in until this page says Uploaded.** A round is several gigabytes, so this can take a while. You can take the headset off: the Hand Recorder keeps it awake until the upload is done. Keep the Hand Recorder open, since closing it stops the upload.
|
||||
- **If it stops partway** (Cancel, or the network drops), press Upload again. It carries on in the same pull request, and files already sent aren't sent twice.
|
||||
- **The maintainer's review:** they check that the files are complete, and that nobody else and nothing private is in view, before merging. You can follow it and answer questions on the pull request's page. Once it's merged, you can delete the session and its export here to free the space.
|
||||
|
||||
@@ -323,11 +323,12 @@ class Backend(QObject):
|
||||
def handednessChoices(self):
|
||||
return [{"value": k, "text": v} for k, v in HANDEDNESS]
|
||||
|
||||
@Slot(bool, bool, str)
|
||||
def acceptConsent(self, adult, agree, handedness):
|
||||
"""Write profile.json. A contributor id, once made, stays (a new consent version keeps it)."""
|
||||
if not (adult and agree):
|
||||
self.message.emit("Both boxes need ticking to take part", True)
|
||||
@Slot(bool, bool, bool, str)
|
||||
def acceptConsent(self, adult, region, agree, handedness):
|
||||
"""Write profile.json. A contributor id, once made, stays (a new consent version keeps it).
|
||||
region: not living in Illinois, Texas or Washington (CONSENT.md "Who can take part")."""
|
||||
if not (adult and region and agree):
|
||||
self.message.emit("All three boxes need ticking to take part", True)
|
||||
return
|
||||
profile = self.store.profile()
|
||||
optional = profile.get("optional") if isinstance(profile.get("optional"), dict) else {}
|
||||
@@ -336,7 +337,7 @@ class Backend(QObject):
|
||||
profile = {"schema": 1, "contributor": profile.get("contributor") or str(uuid.uuid4()),
|
||||
"consent": {"version": consent_version(),
|
||||
"accepted": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"adult": True},
|
||||
"adult": True, "region_ok": True},
|
||||
"optional": optional}
|
||||
takes.write_json(self.store.profile_path, profile)
|
||||
self.profileChanged.emit()
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the hand recorder on a Frame that has Frametop (get.sh --experimental): bring the dev
|
||||
# container's packages up to date, build hand tracking's camera broker and tracker (hands/build.sh:
|
||||
# the first build fetches and builds ncnn, a few minutes) and the headset panel (hands/rec/build.sh),
|
||||
# give ft-camd its capabilities (hands/run.sh caps: asks for the password, once per build), and add
|
||||
# "Frametop Hand Recorder" to the app menu (for Frametop's desktop: it isn't tested from SteamVR's
|
||||
# "Launch a program", which runs apps outside it; the standalone recorder is for that).
|
||||
# It doesn't turn on Frametop's live hand tracking (that's hands/run.sh install, still deferred).
|
||||
# Usage: hands/rec/install.sh install or update
|
||||
# hands/rec/install.sh uninstall remove the menu entry (recordings stay where they are)
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
entry='~/.local/share/applications/frametop-handrec.desktop'
|
||||
|
||||
case ${1:-install} in
|
||||
install)
|
||||
echo "== 1/4 dev container packages"
|
||||
"$root/setup/dev-container.sh"
|
||||
echo "== 2/4 hand tracking: ft-camd and ft-hands"
|
||||
"$root/hands/build.sh"
|
||||
echo "== 3/4 the headset panel: ft-handpanel"
|
||||
"$root/hands/rec/build.sh"
|
||||
echo "== 4/4 ft-camd's capabilities (asks for your password) and the menu entry"
|
||||
"$root/hands/run.sh" caps
|
||||
fill_template "$root/hands/rec/ft-handrec.desktop" |
|
||||
on_frame "mkdir -p ~/.local/share/applications && cat > $entry"
|
||||
echo
|
||||
echo "Installed. On Frametop's desktop, open \"Frametop Hand Recorder\" from the app menu."
|
||||
echo "Recordings go to ~/.local/share/frametop/hands/contrib."
|
||||
;;
|
||||
uninstall)
|
||||
on_frame "rm -f $entry"
|
||||
echo "Removed the menu entry. Your recordings are still in ~/.local/share/frametop/hands/contrib:"
|
||||
echo "delete that folder to remove them."
|
||||
;;
|
||||
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
|
||||
esac
|
||||
+7
-2
@@ -201,6 +201,11 @@ Kirigami.ApplicationWindow {
|
||||
visible: backend.needsConsent
|
||||
text: "I'm 18 or older"
|
||||
}
|
||||
Controls.CheckBox {
|
||||
id: region
|
||||
visible: backend.needsConsent
|
||||
text: "I don't live in Illinois, Texas or Washington (USA)"
|
||||
}
|
||||
Controls.CheckBox {
|
||||
id: agree
|
||||
visible: backend.needsConsent
|
||||
@@ -219,11 +224,11 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
Controls.Button {
|
||||
visible: backend.needsConsent
|
||||
enabled: adult.checked && agree.checked
|
||||
enabled: adult.checked && region.checked && agree.checked
|
||||
text: "Agree and continue"
|
||||
icon.name: "go-next"
|
||||
onClicked: {
|
||||
backend.acceptConsent(adult.checked, agree.checked, handed.currentValue)
|
||||
backend.acceptConsent(adult.checked, region.checked, agree.checked, handed.currentValue)
|
||||
if (!backend.needsConsent)
|
||||
root.show(checklistPage)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class QmlBackendTest(unittest.TestCase):
|
||||
import ft_handrec
|
||||
except ImportError as e:
|
||||
self.skipTest(f"no PySide6: {e}")
|
||||
meta = ft_handrec.Backend.staticMetaObject
|
||||
meta = self.meta = ft_handrec.Backend.staticMetaObject
|
||||
self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())}
|
||||
self.props = {meta.property(i).name() for i in range(meta.propertyCount())}
|
||||
with open(os.path.join(REC, "main.qml")) as f:
|
||||
@@ -33,6 +33,33 @@ class QmlBackendTest(unittest.TestCase):
|
||||
self.assertTrue(called)
|
||||
self.assertEqual(sorted(called - self.slots), [], "called from main.qml but not a slot")
|
||||
|
||||
def test_call_arity(self):
|
||||
"""Each backend.name(a, b) call passes as many arguments as some slot of that name takes
|
||||
(a decorator left at the old count fails only when the button is pressed)."""
|
||||
arity = {}
|
||||
meta = self.meta
|
||||
for i in range(meta.methodCount()):
|
||||
mm = meta.method(i)
|
||||
arity.setdefault(bytes(mm.name()).decode(), set()).add(mm.parameterCount())
|
||||
bad = []
|
||||
for m in re.finditer(r"\bbackend\.(\w+)\s*\(", self.qml):
|
||||
depth, args, k, seen = 1, 0, m.end(), False
|
||||
while depth and k < len(self.qml):
|
||||
c = self.qml[k]
|
||||
if c in "([{":
|
||||
depth += 1
|
||||
elif c in ")]}":
|
||||
depth -= 1
|
||||
elif c == "," and depth == 1:
|
||||
args += 1
|
||||
elif not c.isspace() and depth >= 1:
|
||||
seen = True
|
||||
k += 1
|
||||
n = args + 1 if seen else 0
|
||||
if m.group(1) in arity and n not in arity[m.group(1)]:
|
||||
bad.append("%s: %d arguments, slots take %s" % (m.group(1), n, sorted(arity[m.group(1)])))
|
||||
self.assertEqual(bad, [])
|
||||
|
||||
def test_reads_exist(self):
|
||||
read = set(re.findall(r"\bbackend\.(\w+)\b(?!\s*\()", self.qml))
|
||||
self.assertTrue(read)
|
||||
|
||||
@@ -222,6 +222,21 @@ class ValidateTest(unittest.TestCase):
|
||||
self.assertError(r, "exported is missing")
|
||||
self.assertError(r, "prompts.jsonl line 3: not valid JSON")
|
||||
|
||||
def test_region(self):
|
||||
"""From consent 2026-10-03, the residency confirmation must be there."""
|
||||
d = self.copy()
|
||||
path = os.path.join(d, "manifest.json")
|
||||
with open(path) as f:
|
||||
m = json.load(f)
|
||||
m["consent_version"] = m["profile"]["consent"]["version"] = "2026-10-03"
|
||||
takes.write_json(path, m)
|
||||
write_sums(d)
|
||||
self.assertError(validate.validate(d), "Illinois, Texas or Washington")
|
||||
m["profile"]["consent"]["region_ok"] = True
|
||||
takes.write_json(path, m)
|
||||
write_sums(d)
|
||||
self.assertEqual(validate.validate(d).errors, [])
|
||||
|
||||
def test_device(self):
|
||||
d = self.copy()
|
||||
bad = json.loads(json.dumps(DEVICE))
|
||||
|
||||
@@ -51,6 +51,7 @@ except ImportError:
|
||||
|
||||
EXPORT_SCHEMA = 1
|
||||
TOP_FILES = {"manifest.json", "calibration.json", "device.json", "SHA256SUMS"}
|
||||
REGION_CONSENT = "2026-10-03" # the consent version that added the residency confirmation
|
||||
TAKE_FILES = {"prompts.jsonl", "poses.jsonl", "take.json", "sets.bin.zst"}
|
||||
TAKE_RE = re.compile(r"^\d{2}-[a-z0-9][a-z0-9-]*$")
|
||||
SESSION_RE = re.compile(r"^\d{8}-\d{6}(?:-\d+)?$")
|
||||
@@ -527,6 +528,9 @@ def check_manifest(m, report, root):
|
||||
report.error("manifest: profile.consent.version differs from consent_version")
|
||||
if pc.get("adult") is not True:
|
||||
report.error("manifest: the contributor didn't confirm being 18 or older")
|
||||
# from consent 2026-10-03: not living in Illinois, Texas or Washington (CONSENT.md)
|
||||
if str(pc.get("version") or "") >= REGION_CONSENT and pc.get("region_ok") is not True:
|
||||
report.error("manifest: the contributor didn't confirm not living in Illinois, Texas or Washington")
|
||||
need(pc, "accepted", str, "profile.consent.", report)
|
||||
optional = profile.get("optional")
|
||||
if isinstance(optional, dict) and str(optional.get("notes") or "").strip():
|
||||
|
||||
Reference in new issue
Block a user