Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Section } from "widgets.slint";
|
||||
|
||||
// Settings: how much disk the app may spend, and what an export defaults to.
|
||||
//
|
||||
// A full-window page rather than a modal dialogue. Two reasons, and the second
|
||||
// is the load-bearing one. A modal has to be dismissed to check anything it
|
||||
// refers to, and these settings refer to the library constantly — how full the
|
||||
// cache is, where exports land. And every control here saves on change
|
||||
// (see `settings_ui.rs`), so there is no OK/Cancel pair for a modal to host;
|
||||
// a dialogue frame with only a close button is a window pretending to be a
|
||||
// decision.
|
||||
//
|
||||
// It replaces the view rather than overlaying it because the app already
|
||||
// switches views this way — launch, library, develop — and an overlay would be
|
||||
// a fourth mechanism for the same job.
|
||||
|
||||
// One choice out of several, drawn as a row of chips.
|
||||
//
|
||||
// Not a dropdown. Every choice set on this page is short and the options are
|
||||
// worth reading side by side — a photographer picking an output colour space
|
||||
// benefits from seeing that ProPhoto exists next to sRGB, which a collapsed
|
||||
// menu hides behind a click. `FilterChip` in widgets.slint is the same idea for
|
||||
// the library's rating filter, but it carries a count and a filter's
|
||||
// on-off semantics; this is single-selection over a fixed list, so the
|
||||
// behaviour differs where it matters.
|
||||
component ChoiceChip inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> selected: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
|
||||
height: Theme.control-height;
|
||||
// Wide enough that a one-word label is still a comfortable target, which
|
||||
// is what `control-min-width` exists for — but chips sit several to a row,
|
||||
// so they take their own narrower floor rather than the button's.
|
||||
min-width: 64px;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: root.selected ? Theme.active : Theme.rule;
|
||||
// The selected chip fills, matching the checked box and the slider fill:
|
||||
// `active` is the token for an engaged control, and this is the one chip in
|
||||
// the row that is engaged.
|
||||
background: !root.enabled ? transparent
|
||||
: (root.selected ? Theme.active
|
||||
: (touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : transparent)));
|
||||
opacity: root.enabled ? 1.0 : 0.4;
|
||||
|
||||
touch := TouchArea {
|
||||
// FR-UI-3: the drawn chip is `control-height`, so the target grows
|
||||
// past its own bounds rather than the ink growing.
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
// Dark on the fill: `active` is near-white and ink on it is invisible.
|
||||
color: root.selected ? Theme.ground : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// A labelled row of chips, with the label above rather than beside.
|
||||
//
|
||||
// Above, because the chip rows are wide and a left-hand label column would
|
||||
// either crush them or leave the page half empty at narrow widths. Stacked,
|
||||
// every row uses the full width at any window size (FR-UI-1).
|
||||
component ChoiceRow inherits VerticalLayout {
|
||||
in property <string> label;
|
||||
in property <string> hint;
|
||||
in property <[string]> options;
|
||||
in property <int> selected: 0;
|
||||
in property <bool> enabled: true;
|
||||
callback picked(int);
|
||||
|
||||
spacing: 4px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label { text: root.label; body: true; }
|
||||
Caption {
|
||||
text: root.hint;
|
||||
horizontal-alignment: right;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
for option[i] in root.options: ChoiceChip {
|
||||
label: option;
|
||||
selected: i == root.selected;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.picked(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A switch: one setting that is either on or off.
|
||||
//
|
||||
// The tick-box shape is `FormatCheck`'s from launch.slint, which is the
|
||||
// established idiom for a boolean in this codebase. Reproduced rather than
|
||||
// shared because that one is private to the launch screen and lives inside its
|
||||
// format list; lifting it into widgets.slint would be the better move once a
|
||||
// third caller appears, and doing it for the second is how a component ends up
|
||||
// with parameters for every caller's variation.
|
||||
component Switch inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <string> hint;
|
||||
in-out property <bool> checked;
|
||||
callback toggled(bool);
|
||||
|
||||
height: max(row.preferred-height, Theme.control-height);
|
||||
|
||||
touch := TouchArea {
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
}
|
||||
|
||||
row := HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: Theme.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: root.checked ? Theme.active : Theme.rule;
|
||||
background: root.checked ? Theme.active : transparent;
|
||||
|
||||
Text {
|
||||
text: "✓";
|
||||
color: Theme.ground;
|
||||
font-size: 12px;
|
||||
visible: root.checked;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 1px;
|
||||
alignment: center;
|
||||
Label { text: root.label; body: true; emphasised: touch.has-hover; }
|
||||
// The hint carries *why* a default is what it is, for the settings
|
||||
// where that is not obvious from the name — upscaling being off,
|
||||
// location being stripped. A page of bare switches makes the user
|
||||
// guess at the consequence of each.
|
||||
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A text entry with its label above and an optional unit after it.
|
||||
//
|
||||
// `Field` is `touch-target` tall and stretches, which is right for a server
|
||||
// URL on the launch screen and wrong for a byte count — so this constrains the
|
||||
// width rather than restyling the field.
|
||||
component EntryRow inherits VerticalLayout {
|
||||
in property <string> label;
|
||||
in property <string> hint;
|
||||
in-out property <string> text;
|
||||
in property <string> unit;
|
||||
in property <string> placeholder;
|
||||
in property <bool> enabled: true;
|
||||
in property <length> field-width: 140px;
|
||||
callback accepted(string);
|
||||
|
||||
spacing: 4px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label { text: root.label; body: true; }
|
||||
Caption {
|
||||
text: root.hint;
|
||||
horizontal-alignment: right;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
Rectangle {
|
||||
width: root.field-width;
|
||||
height: field.preferred-height;
|
||||
opacity: root.enabled ? 1.0 : 0.4;
|
||||
|
||||
field := Field {
|
||||
width: 100%;
|
||||
text <=> root.text;
|
||||
placeholder: root.placeholder;
|
||||
// Committed on Enter *and* on losing focus. Enter alone loses
|
||||
// an edit the moment the user clicks the next control, which
|
||||
// on a page that saves continuously reads as the setting not
|
||||
// having taken.
|
||||
accepted(t) => { root.accepted(t); }
|
||||
}
|
||||
|
||||
// `Field` reports focus but does not signal losing it, so the
|
||||
// change is watched here.
|
||||
property <bool> focused: field.has-focus;
|
||||
changed focused => {
|
||||
if (!self.focused) {
|
||||
root.accepted(root.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.unit;
|
||||
visible: root.unit != "";
|
||||
vertical-alignment: center;
|
||||
height: Theme.touch-target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsPage inherits Rectangle {
|
||||
// --- cache ---------------------------------------------------------
|
||||
in-out property <string> original-budget;
|
||||
in property <bool> original-unlimited: false;
|
||||
in-out property <string> thumbnail-budget;
|
||||
in property <bool> thumbnail-unlimited: false;
|
||||
in property <bool> keep-opened: true;
|
||||
/// What the cache currently holds. Empty hides the line.
|
||||
in property <string> cache-usage;
|
||||
|
||||
callback original-budget-changed(string);
|
||||
callback original-unlimited-toggled(bool);
|
||||
callback thumbnail-budget-changed(string);
|
||||
callback thumbnail-unlimited-toggled(bool);
|
||||
callback keep-opened-toggled(bool);
|
||||
|
||||
// --- export --------------------------------------------------------
|
||||
in property <[string]> format-labels;
|
||||
in property <int> format-selected: 0;
|
||||
in property <int> quality: 90;
|
||||
in property <bool> quality-enabled: true;
|
||||
in property <[string]> colour-labels;
|
||||
in property <int> colour-selected: 0;
|
||||
in property <[string]> sizing-labels;
|
||||
in property <int> sizing-selected: 0;
|
||||
in-out property <int> sizing-value: 0;
|
||||
in property <bool> sizing-has-value: false;
|
||||
in property <string> sizing-unit: "px";
|
||||
in property <bool> allow-upscaling: false;
|
||||
in property <[string]> sharpening-labels;
|
||||
in property <int> sharpening-selected: 0;
|
||||
in-out property <string> filename-template;
|
||||
in property <[string]> collision-labels;
|
||||
in property <int> collision-selected: 0;
|
||||
in property <bool> strip-location: true;
|
||||
in-out property <string> destination;
|
||||
|
||||
callback format-picked(int);
|
||||
callback quality-changed(int);
|
||||
callback colour-picked(int);
|
||||
callback sizing-picked(int);
|
||||
callback sizing-value-changed(string);
|
||||
callback upscaling-toggled(bool);
|
||||
callback sharpening-picked(int);
|
||||
callback template-changed(string);
|
||||
callback collision-picked(int);
|
||||
callback strip-location-toggled(bool);
|
||||
callback destination-changed(string);
|
||||
|
||||
/// A save failed. The page's whole contract is that what it shows is
|
||||
/// stored, so this cannot be swallowed.
|
||||
in property <string> error;
|
||||
|
||||
callback close();
|
||||
callback reset-defaults();
|
||||
|
||||
background: Theme.ground;
|
||||
|
||||
VerticalLayout {
|
||||
// --- header ----------------------------------------------------
|
||||
//
|
||||
// 44px and `surface`, matching the library's header exactly: this is
|
||||
// the same kind of bar in the same place, and a page that drew its own
|
||||
// height would read as a different application.
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Button {
|
||||
text: "‹ Back";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
|
||||
Value { text: "Settings"; }
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Caption {
|
||||
// Says where the file is, because a settings page that
|
||||
// saves silently gives the user nothing to point a backup
|
||||
// or a support question at.
|
||||
text: "Saved automatically";
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Reset to defaults";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.reset-defaults(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
// A failed write, above the content: it applies to everything below
|
||||
// and the user needs it before they keep editing into a file that is
|
||||
// not being written.
|
||||
if root.error != "": Rectangle {
|
||||
height: 32px;
|
||||
background: Theme.surface;
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
Caption { text: root.error; warn: true; overflow: elide; }
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-height: content.preferred-height;
|
||||
|
||||
content := VerticalLayout {
|
||||
width: 100%;
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap-lg;
|
||||
alignment: start;
|
||||
|
||||
// The column is capped rather than filling the window. A
|
||||
// settings form stretched across a 2560px display puts its
|
||||
// label at one edge and its control at the other; 680px is
|
||||
// about 90 characters of `text`, which is a readable measure.
|
||||
// `min` so a narrow window still uses what it has (FR-UI-1).
|
||||
property <length> column: min(root.width - 2 * Theme.gap-lg, 680px);
|
||||
|
||||
// --- storage ---------------------------------------------
|
||||
Rectangle {
|
||||
width: content.column;
|
||||
height: storage.preferred-height;
|
||||
|
||||
storage := Panel {
|
||||
width: 100%;
|
||||
|
||||
PanelHeading { text: "STORAGE"; }
|
||||
|
||||
Caption {
|
||||
text: "How much of this device's disk DarkRoom may use. "
|
||||
+ "These are per-device and never travel with the library.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// What is actually held, before what is allowed:
|
||||
// a ceiling means nothing without the current figure
|
||||
// to judge it against.
|
||||
if root.cache-usage != "": Value {
|
||||
text: root.cache-usage;
|
||||
compact: true;
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
EntryRow {
|
||||
label: "Cached originals";
|
||||
hint: "evicted oldest-first when full";
|
||||
text <=> root.original-budget;
|
||||
enabled: !root.original-unlimited;
|
||||
placeholder: "8.0 GB";
|
||||
accepted(t) => { root.original-budget-changed(t); }
|
||||
}
|
||||
|
||||
Switch {
|
||||
label: "No limit on cached originals";
|
||||
hint: "Nothing is ever evicted for space. "
|
||||
+ "Pinned photographs are kept regardless.";
|
||||
checked: root.original-unlimited;
|
||||
toggled(on) => { root.original-unlimited-toggled(on); }
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
EntryRow {
|
||||
label: "Thumbnails and previews";
|
||||
hint: "what the grid draws from";
|
||||
text <=> root.thumbnail-budget;
|
||||
enabled: !root.thumbnail-unlimited;
|
||||
placeholder: "2.0 GB";
|
||||
accepted(t) => { root.thumbnail-budget-changed(t); }
|
||||
}
|
||||
|
||||
Switch {
|
||||
label: "No limit on thumbnails";
|
||||
checked: root.thumbnail-unlimited;
|
||||
toggled(on) => { root.thumbnail-unlimited-toggled(on); }
|
||||
}
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
Switch {
|
||||
label: "Keep originals after opening them";
|
||||
hint: "The file was downloaded anyway, so keeping it "
|
||||
+ "costs no bandwidth and saves the transfer next time.";
|
||||
checked: root.keep-opened;
|
||||
toggled(on) => { root.keep-opened-toggled(on); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- export ----------------------------------------------
|
||||
Rectangle {
|
||||
width: content.column;
|
||||
height: export-panel.preferred-height;
|
||||
|
||||
export-panel := Panel {
|
||||
width: 100%;
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "EXPORT DEFAULTS"; }
|
||||
|
||||
Caption {
|
||||
text: "What an export starts from. Every one of these "
|
||||
+ "is still changeable per export.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
ChoiceRow {
|
||||
label: "Format";
|
||||
options: root.format-labels;
|
||||
selected: root.format-selected;
|
||||
picked(i) => { root.format-picked(i); }
|
||||
}
|
||||
|
||||
EntryRow {
|
||||
label: "Quality";
|
||||
// Says why it is greyed rather than leaving the
|
||||
// user to work out that PNG has no quality.
|
||||
hint: root.quality-enabled ? "1 to 100"
|
||||
: "the chosen format is lossless";
|
||||
text: root.quality;
|
||||
enabled: root.quality-enabled;
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.quality-changed(t.to-float()); }
|
||||
}
|
||||
|
||||
ChoiceRow {
|
||||
label: "Colour space";
|
||||
hint: "profile embedded on export";
|
||||
options: root.colour-labels;
|
||||
selected: root.colour-selected;
|
||||
picked(i) => { root.colour-picked(i); }
|
||||
}
|
||||
|
||||
ChoiceRow {
|
||||
label: "Size";
|
||||
options: root.sizing-labels;
|
||||
selected: root.sizing-selected;
|
||||
picked(i) => { root.sizing-picked(i); }
|
||||
}
|
||||
|
||||
// Only where the chosen mode carries a number:
|
||||
// "Original" has none, and a field showing 0 beside it
|
||||
// would invite the reading "zero pixels".
|
||||
if root.sizing-has-value: EntryRow {
|
||||
label: "Size value";
|
||||
text: root.sizing-value;
|
||||
unit: root.sizing-unit;
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.sizing-value-changed(t); }
|
||||
}
|
||||
|
||||
Switch {
|
||||
label: "Allow upscaling";
|
||||
hint: "Off, a request larger than the source exports "
|
||||
+ "at source size rather than failing.";
|
||||
checked: root.allow-upscaling;
|
||||
toggled(on) => { root.upscaling-toggled(on); }
|
||||
}
|
||||
|
||||
ChoiceRow {
|
||||
label: "Output sharpening";
|
||||
hint: "scaled by the resize factor";
|
||||
options: root.sharpening-labels;
|
||||
selected: root.sharpening-selected;
|
||||
picked(i) => { root.sharpening-picked(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- naming and destination ------------------------------
|
||||
//
|
||||
// Its own panel rather than more of the export one: format and
|
||||
// size describe the image, these describe the file. The export
|
||||
// panel was long enough that the boundary was worth drawing.
|
||||
Rectangle {
|
||||
width: content.column;
|
||||
height: naming.preferred-height;
|
||||
|
||||
naming := Panel {
|
||||
width: 100%;
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "FILES AND METADATA"; }
|
||||
|
||||
EntryRow {
|
||||
label: "Filename template";
|
||||
hint: "{name} {seq} {date} {dimensions} {preset}";
|
||||
text <=> root.filename-template;
|
||||
field-width: 260px;
|
||||
placeholder: "{name}";
|
||||
accepted(t) => { root.template-changed(t); }
|
||||
}
|
||||
|
||||
ChoiceRow {
|
||||
label: "If the file exists";
|
||||
options: root.collision-labels;
|
||||
selected: root.collision-selected;
|
||||
picked(i) => { root.collision-picked(i); }
|
||||
}
|
||||
|
||||
EntryRow {
|
||||
label: "Destination";
|
||||
hint: "empty asks each time";
|
||||
text <=> root.destination;
|
||||
field-width: 320px;
|
||||
placeholder: "Ask each time";
|
||||
accepted(t) => { root.destination-changed(t); }
|
||||
}
|
||||
|
||||
Switch {
|
||||
label: "Strip location and personal metadata";
|
||||
hint: "On. An export is usually the copy that leaves "
|
||||
+ "this machine, and a location embedded in a "
|
||||
+ "published photograph cannot be recalled.";
|
||||
checked: root.strip-location;
|
||||
toggled(on) => { root.strip-location-toggled(on); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user