pS remediation: legacy lift terminates on stale-id proof; displayName in v10 refs; refs-reader range fallbacks; load path keeps owned refs
This commit is contained in:
@@ -117,12 +117,19 @@ void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
|
||||
drawWaveform(bmp, toKitBox(r), env);
|
||||
}
|
||||
|
||||
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
|
||||
// resolves — e.g. a zone naming a deleted sample).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||
// A display name for a bank sample id: the snapshotted bank list first, then the
|
||||
// instance-OWNED ref's displayName (pS — the label survives with the extension absent /
|
||||
// bank unreadable, mirroring the waveform + loop-marker ref fallback). "?" only when
|
||||
// neither source knows the id (a stale zone naming a deleted sample, or a pre-displayName
|
||||
// refs table not yet back-filled by a bank refresh).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const SampleRefs& refs,
|
||||
const std::string& id) {
|
||||
for (const SampleChoice& c : samples) {
|
||||
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
||||
}
|
||||
for (const SampleRefEntry& e : refs) {
|
||||
if (e.sampleId == id && !e.displayName.empty()) return e.displayName;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
@@ -1220,10 +1227,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// signal.
|
||||
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
if (samples_.empty()) title += " [bank empty]";
|
||||
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
|
||||
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
|
||||
// The instance's OWN loaded state outranks bank availability (pS: the bank is a
|
||||
// browser source, not the instrument's identity) — a self-contained instance names
|
||||
// its sound (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
@@ -1980,7 +1991,10 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
|
||||
sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary);
|
||||
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
|
||||
z.sampleId)
|
||||
.c_str(),
|
||||
Font::Label, Role::TextPrimary);
|
||||
// Three fields laid out left-to-right after the sample label. A focused field lifts to
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
|
||||
@@ -155,6 +155,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
|
||||
// sample refs — it needs no bank read, so it plays regardless of whether the
|
||||
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
|
||||
//
|
||||
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
|
||||
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
|
||||
// refreshRefsFromBank copies the refs in when the bank blob is readable by
|
||||
// activation time, so an upgraded project plays on load without the instrument
|
||||
// ever being opened (and the next save is self-contained). Residual load-order
|
||||
// race, DAW-verifiable only: if the host activates this instance BEFORE the
|
||||
// project's ext-state lines parse, the lift misses here and — with no editor open —
|
||||
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
|
||||
// pre-v10 instrument once after upgrading if it restores silent.
|
||||
reloadInstrument();
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
@@ -248,6 +258,9 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||
sampleRefs_ = cs.sampleRefs;
|
||||
}
|
||||
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
||||
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadInstrument();
|
||||
return kResultOk;
|
||||
@@ -481,9 +494,11 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
std::lock_guard<std::mutex> rl(refsMutex_);
|
||||
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
||||
// Hygiene: the owned table tracks exactly what the instance currently plays, so a
|
||||
// de-referenced sample's entry drops here (never grows with browsing history).
|
||||
retainRefs(sampleRefs_, ids);
|
||||
// The LOAD path never prunes the owned table: dropping entries here on a transient
|
||||
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
|
||||
// copy that survives with the extension absent. Entries for de-referenced ids stay
|
||||
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
|
||||
// where getState filters its snapshot via retainRefs to what the instance plays.
|
||||
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
@@ -672,6 +687,25 @@ void ReaSamplerProcessor::retireIdleDrain() {
|
||||
graveyard_.end());
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
|
||||
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
|
||||
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
|
||||
// no bank read, no parse, no reload churn.
|
||||
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
||||
const LegacyLiftDecision decision = legacyLiftDecision(
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
||||
referencedSampleIds(selectedSampleId(), performanceMap()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale (the bank parses and knows none of the referenced ids): give up
|
||||
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
|
||||
// and the genChanged reload refreshes the refs without consulting this latch.
|
||||
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
|
||||
return false;
|
||||
}
|
||||
return true; // Retry (blob not readable yet) or Lift (a ref can be copied in)
|
||||
}
|
||||
|
||||
ReaSamplerProcessor::BankSyncResult
|
||||
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
|
||||
@@ -759,13 +793,16 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
|
||||
// when readable, after which the table is non-empty and this never fires again (the
|
||||
// next save is then self-contained). A deliberately-empty instance has no intent and
|
||||
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a
|
||||
// cheap null publish on the editor cadence only. This is a MIGRATION convenience for
|
||||
// old projects, NOT a playback dependency — a v10 blob plays from its refs with no
|
||||
// poll at all (pS).
|
||||
// never churns; a bank that is not readable YET retries a cheap null publish on the
|
||||
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
|
||||
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
|
||||
// nothing to lift, so the lift concludes permanently instead of churning a full bank
|
||||
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
|
||||
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
|
||||
bool legacyLift = false;
|
||||
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||
legacyLift = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
legacyLift = hasIntent && legacyLiftShouldRun();
|
||||
}
|
||||
|
||||
if (genChanged || result.applied || legacyLift) {
|
||||
|
||||
@@ -168,6 +168,9 @@ public:
|
||||
// a non-target instance neither applies nor advances its marker.
|
||||
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
|
||||
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
|
||||
// TERMINATING: once the blob parses and NO referenced id resolves, the ids are
|
||||
// provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of
|
||||
// churning a full bank read + reload every tick forever.
|
||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||
@@ -286,6 +289,12 @@ private:
|
||||
// the new params bake into the next real reload. Off the audio thread only.
|
||||
void rebuildVoiceEngine();
|
||||
|
||||
// The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make
|
||||
// progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the
|
||||
// pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only
|
||||
// (bridge read + bank parse).
|
||||
bool legacyLiftShouldRun();
|
||||
|
||||
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||
@@ -383,11 +392,20 @@ private:
|
||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
|
||||
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
|
||||
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
|
||||
// depends on this poll — a v10 blob plays from its own refs at setState time. The only
|
||||
// poll-driven reload besides a generation change is the pre-v10 LEGACY LIFT (see
|
||||
// pollBankSync). NOT read on the audio thread.
|
||||
// depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
|
||||
// generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
|
||||
// pre-v10 LEGACY LIFT. NOT read on the audio thread.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
|
||||
// The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves
|
||||
// the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) —
|
||||
// there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed
|
||||
// load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted
|
||||
// by the genChanged/applied reload paths, so a later bank change that re-introduces an id
|
||||
// (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift.
|
||||
// Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState).
|
||||
std::atomic<bool> legacyLiftConcluded_{false};
|
||||
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
|
||||
// user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view
|
||||
// velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and
|
||||
|
||||
+36
-5
@@ -134,12 +134,32 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const SelectedSample distilled = distill(*found);
|
||||
bool updated = false;
|
||||
for (SampleRefEntry& e : refs) {
|
||||
if (e.sampleId == id) { e.ref = distilled; updated = true; break; }
|
||||
if (e.sampleId == id) {
|
||||
e.ref = distilled;
|
||||
e.displayName = found->displayName; // rename sync rides the same refresh
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!updated) refs.push_back(SampleRefEntry{id, distilled});
|
||||
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
|
||||
}
|
||||
}
|
||||
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids) {
|
||||
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
|
||||
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
|
||||
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
|
||||
for (const std::string& id : ids) {
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (b.index.query(id)) return LegacyLiftDecision::Lift;
|
||||
}
|
||||
}
|
||||
// The blob parses and knows none of the referenced ids (or there are none): provably
|
||||
// stale — a lift can never make progress against this bank.
|
||||
return LegacyLiftDecision::Stale;
|
||||
}
|
||||
|
||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
|
||||
refs.erase(std::remove_if(refs.begin(), refs.end(),
|
||||
[&ids](const SampleRefEntry& e) {
|
||||
@@ -723,7 +743,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
|
||||
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
||||
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
|
||||
// written), channelCount.
|
||||
// written), channelCount, displayName (length-prefixed; display-only).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
@@ -736,6 +756,8 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putU64le(out, asU64(e.ref.loop.end));
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
@@ -883,11 +905,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
e.sampleId = r.str(refIdLen);
|
||||
const std::uint32_t pathLen = r.u32();
|
||||
e.ref.relativePath = r.str(pathLen);
|
||||
e.ref.rootNote = r.i32();
|
||||
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
|
||||
// corrupt field must degrade to the field's default, never poison playback —
|
||||
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
|
||||
// to the middle-C default distill() uses; a negative channel count falls back
|
||||
// to 0 = unknown (the GA auto-default then skips it).
|
||||
const std::int32_t root = r.i32();
|
||||
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
|
||||
e.ref.loop.hasLoop = (r.u8() != 0);
|
||||
e.ref.loop.start = r.i64();
|
||||
e.ref.loop.end = r.i64();
|
||||
e.ref.channelCount = r.i32();
|
||||
const std::int32_t channels = r.i32();
|
||||
e.ref.channelCount = channels >= 0 ? channels : 0;
|
||||
const std::uint32_t nameLen = r.u32();
|
||||
e.displayName = r.str(nameLen);
|
||||
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
|
||||
out.sampleRefs.push_back(std::move(e));
|
||||
}
|
||||
|
||||
+31
-6
@@ -90,6 +90,11 @@ struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans bot
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
|
||||
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
|
||||
// fallback); never consulted by resolution. Empty for a table written before the field
|
||||
// existed in-session (it back-fills on the next bank refresh).
|
||||
std::string displayName;
|
||||
};
|
||||
using SampleRefs = std::vector<SampleRefEntry>;
|
||||
|
||||
@@ -102,11 +107,26 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
|
||||
// distillation selectSample performs). A miss leaves any existing entry untouched — the
|
||||
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
// distillation selectSample performs), copying the bank display name alongside the decode
|
||||
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
|
||||
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
|
||||
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
|
||||
// instance references?
|
||||
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
|
||||
// project's ext-state may simply not have parsed).
|
||||
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
|
||||
// refs table then goes non-empty and the lift never re-fires).
|
||||
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
|
||||
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
|
||||
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
|
||||
enum class LegacyLiftDecision { Retry, Lift, Stale };
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
|
||||
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
||||
@@ -341,8 +361,12 @@ struct ResolvedPerformance {
|
||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
||||
// then falls back to Tier-0 — see reloadInstrument).
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
|
||||
//
|
||||
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
|
||||
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
|
||||
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
|
||||
// foldZone, so the drift test is what keeps the shared fold honest.
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
|
||||
@@ -515,7 +539,7 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||
// instance-owned path + intrinsics per referenced sample; wire shape at
|
||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below), then a 4-byte LE
|
||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
@@ -595,7 +619,8 @@ inline constexpr std::uint32_t kComponentStateVersion = 10;
|
||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement).
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
|
||||
Reference in New Issue
Block a user