mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C) (#1570)
C1: getElementTimings/setElementTiming typed session methods + setHold typed wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start (fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches a sparse map as one batch → one patch event → one undo step. setHold mirrors setVariableValue pattern. Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was absent, causing undo of duration changes to silently no-op. Added the duration branch so inverse patches restore data-duration correctly. C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings() consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word- anchored elements get enterAt = wordTimings[k].start + offset; elastic hold = max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never timescales animated content. Un-anchored elements keep authored timing (align-on- adjust). Deterministic + pure: no Date.now, no Math.random, no DOM. extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for the getElementTimings labels field. Tests: timingResolver.test.ts (10 pure-function tests including preview==render parity golden test); session.timings.test.ts (15 session-layer tests covering duration-authored, end-authored, label extraction, batching, undo, and setHold regression). Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ · oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new inline functions, duplication warn-only pre-existing) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d0e520dbd9
commit
f65e229663
@@ -1,3 +1,15 @@
|
||||
// Timing resolver — shared pure resolver for word-anchored elastic timing (WS-C).
|
||||
// Consumed by both preview (sdk) and render (timingCompiler) paths.
|
||||
export {
|
||||
resolveTimings,
|
||||
type WordTiming,
|
||||
type ElementAnchor,
|
||||
type AuthoredTiming,
|
||||
type ResolvedTiming,
|
||||
type ResolveTimingsInput,
|
||||
type ResolveTimingsResult,
|
||||
} from "./timingResolver";
|
||||
|
||||
// Timing compiler (browser-safe)
|
||||
export {
|
||||
compileTimingAttrs,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* WS-C — timingResolver tests.
|
||||
*
|
||||
* The resolver is pure (no DOM, no Date.now, no Math.random) so it can be
|
||||
* unit-tested directly. These tests also serve as the preview==render parity
|
||||
* fixture: the resolver produces the exact same output regardless of whether
|
||||
* it is called from the preview path (session layer) or the render path
|
||||
* (timingCompiler). A golden parity test at the end confirms both paths
|
||||
* produce identical enter/exit from the same resolver call.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveTimings,
|
||||
type AuthoredTiming,
|
||||
type WordTiming,
|
||||
type ElementAnchor,
|
||||
} from "./timingResolver.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function authored(hfId: string, start: number, duration: number): AuthoredTiming {
|
||||
return { hfId, start, duration };
|
||||
}
|
||||
|
||||
function word(index: number, start: number, end: number): WordTiming {
|
||||
return { index, start, end };
|
||||
}
|
||||
|
||||
function anchor(
|
||||
hfId: string,
|
||||
wordIndex: number,
|
||||
enterDuration: number,
|
||||
exitDuration: number,
|
||||
slotEnd: number,
|
||||
enterOffset?: number,
|
||||
): ElementAnchor {
|
||||
return { hfId, wordIndex, enterDuration, exitDuration, slotEnd, enterOffset };
|
||||
}
|
||||
|
||||
// ─── Un-anchored elements keep authored timing ────────────────────────────────
|
||||
|
||||
describe("resolveTimings — un-anchored elements", () => {
|
||||
it("returns authored start/duration unchanged when no anchors supplied", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-a", 1, 2), authored("hf-b", 3, 1.5)],
|
||||
wordTimings: [],
|
||||
anchors: [],
|
||||
});
|
||||
expect(result["hf-a"]).toEqual({ enterAt: 1, exitAt: 3, holdDuration: 0 });
|
||||
expect(result["hf-b"]).toEqual({ enterAt: 3, exitAt: 4.5, holdDuration: 0 });
|
||||
});
|
||||
|
||||
it("align-on-adjust: anchored and un-anchored elements in same call", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-anchored", 0, 3), authored("hf-free", 4, 2)],
|
||||
wordTimings: [word(0, 1.0, 1.5)],
|
||||
anchors: [anchor("hf-anchored", 0, 0.5, 0.5, 3.0)],
|
||||
});
|
||||
|
||||
// Anchored: enters at word 0 start (1.0), enterDuration=0.5, exitDuration=0.5
|
||||
// slot=3.0 → holdDuration = max(0, 3.0 - (1.0 + 0.5 + 0.5)) = 1.0
|
||||
// exitAt = 1.0 + 0.5 + 1.0 + 0.5 = 3.0
|
||||
expect(result["hf-anchored"]).toEqual({ enterAt: 1.0, exitAt: 3.0, holdDuration: 1.0 });
|
||||
|
||||
// Un-anchored: keeps authored timing
|
||||
expect(result["hf-free"]).toEqual({ enterAt: 4, exitAt: 6, holdDuration: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Word-anchored elements ───────────────────────────────────────────────────
|
||||
|
||||
describe("resolveTimings — word-anchored elements", () => {
|
||||
it("anchors element enterAt to word start", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-x", 0, 2)],
|
||||
wordTimings: [word(0, 0.5, 1.0), word(1, 1.5, 2.0)],
|
||||
anchors: [anchor("hf-x", 1, 0.3, 0.2, 2.5)],
|
||||
});
|
||||
// enterAt = wordTimings[1].start = 1.5; enterDuration=0.3, exitDuration=0.2
|
||||
// holdDuration = max(0, 2.5 - (1.5 + 0.3 + 0.2)) = max(0, 0.5) = 0.5
|
||||
// exitAt = 1.5 + 0.3 + 0.5 + 0.2 = 2.5
|
||||
expect(result["hf-x"]).toEqual({ enterAt: 1.5, exitAt: 2.5, holdDuration: 0.5 });
|
||||
});
|
||||
|
||||
it("enterOffset shifts enterAt relative to word start", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-y", 0, 1)],
|
||||
wordTimings: [word(0, 2.0, 2.5)],
|
||||
anchors: [anchor("hf-y", 0, 0.2, 0.1, 4.0, 0.3)],
|
||||
});
|
||||
// enterAt = 2.0 + 0.3 = 2.3
|
||||
// holdDuration = max(0, 4.0 - (2.3 + 0.2 + 0.1)) = 1.4
|
||||
// exitAt = 2.3 + 0.2 + 1.4 + 0.1 = 4.0
|
||||
expect(result["hf-y"]?.enterAt).toBeCloseTo(2.3);
|
||||
expect(result["hf-y"]?.exitAt).toBeCloseTo(4.0);
|
||||
expect(result["hf-y"]?.holdDuration).toBeCloseTo(1.4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Elastic hold math ────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveTimings — elastic hold math", () => {
|
||||
it("holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-z", 0, 1)],
|
||||
wordTimings: [word(0, 0.0, 0.5)],
|
||||
anchors: [anchor("hf-z", 0, 0.5, 0.5, 3.0)],
|
||||
});
|
||||
// enterAt=0, holdDuration = max(0, 3.0 - (0 + 0.5 + 0.5)) = 2.0
|
||||
expect(result["hf-z"]).toEqual({ enterAt: 0, exitAt: 3.0, holdDuration: 2.0 });
|
||||
});
|
||||
|
||||
it("clamps holdDuration >= 0 when slot is too tight", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-tight", 0, 2)],
|
||||
wordTimings: [word(0, 5.0, 5.5)],
|
||||
// enter=1.0, exit=1.0, slotEnd=5.5 → slot=5.5-(5.0+1.0+1.0)=-1.5 → clamp to 0
|
||||
anchors: [anchor("hf-tight", 0, 1.0, 1.0, 5.5)],
|
||||
});
|
||||
expect(result["hf-tight"]?.holdDuration).toBe(0);
|
||||
// exitAt = 5.0 + 1.0 + 0 + 1.0 = 7.0 (element exits after its natural duration)
|
||||
expect(result["hf-tight"]?.exitAt).toBe(7.0);
|
||||
});
|
||||
|
||||
it("holdDuration is zero (not negative) when exactly at slot boundary", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-exact", 0, 1)],
|
||||
wordTimings: [word(0, 1.0, 1.5)],
|
||||
// enterAt=1.0, slotEnd=1.0+0.3+0.2=1.5 → holdDuration=0
|
||||
anchors: [anchor("hf-exact", 0, 0.3, 0.2, 1.5)],
|
||||
});
|
||||
expect(result["hf-exact"]?.holdDuration).toBe(0);
|
||||
expect(result["hf-exact"]?.exitAt).toBeCloseTo(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Missing word index falls back gracefully ────────────────────────────────
|
||||
|
||||
describe("resolveTimings — missing word index", () => {
|
||||
it("falls back to wordStart=0 when word index is not in wordTimings", () => {
|
||||
const result = resolveTimings({
|
||||
elements: [authored("hf-missing", 5, 2)],
|
||||
wordTimings: [word(0, 1.0, 1.5)],
|
||||
// wordIndex 99 doesn't exist → wordStart defaults to 0
|
||||
anchors: [anchor("hf-missing", 99, 0.5, 0.5, 2.0)],
|
||||
});
|
||||
// enterAt = 0 + 0 = 0
|
||||
// holdDuration = max(0, 2.0 - (0 + 0.5 + 0.5)) = 1.0
|
||||
expect(result["hf-missing"]?.enterAt).toBe(0);
|
||||
expect(result["hf-missing"]?.holdDuration).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Determinism ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveTimings — determinism", () => {
|
||||
it("produces identical output for identical input (no hidden state)", () => {
|
||||
const input = {
|
||||
elements: [authored("hf-det", 0, 2), authored("hf-free2", 3, 1)],
|
||||
wordTimings: [word(0, 0.5, 1.0)],
|
||||
anchors: [anchor("hf-det", 0, 0.3, 0.2, 2.0)],
|
||||
};
|
||||
const r1 = resolveTimings(input);
|
||||
const r2 = resolveTimings(input);
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Preview == render parity golden test ────────────────────────────────────
|
||||
//
|
||||
// This test mirrors the contract of time_control.py in the backend render path.
|
||||
// Both the preview path (SDK session) and the render path (timingCompiler
|
||||
// consumer) call resolveTimings() with the same input and MUST produce the
|
||||
// same output. Since there is exactly one implementation, calling it twice with
|
||||
// the same args is the parity test: they cannot diverge.
|
||||
//
|
||||
// NOTE: happy-dom cannot do GSAP layout/seek operations so the GSAP-seek path
|
||||
// (smart-seek) is exercised by timingCompiler.test.ts (Node.js) separately.
|
||||
// The purity of resolveTimings() means this test fully covers resolver logic.
|
||||
|
||||
describe("preview == render parity golden test", () => {
|
||||
it("resolver output is identical for preview and render call sites (shared single impl)", () => {
|
||||
// Golden fixture: 3 elements, 2 words, 1 anchored, 2 free.
|
||||
const elements: AuthoredTiming[] = [
|
||||
authored("hf-title", 0, 2.0), // anchored
|
||||
authored("hf-sub", 3.0, 1.5), // free
|
||||
authored("hf-cta", 5.0, 1.0), // free
|
||||
];
|
||||
const wordTimings: WordTiming[] = [word(0, 0.0, 0.5), word(1, 1.0, 1.8)];
|
||||
const anchors: ElementAnchor[] = [
|
||||
anchor("hf-title", 1, 0.4, 0.3, 3.5), // anchored to word 1
|
||||
];
|
||||
|
||||
// Simulate preview call (same input as would arrive from session layer)
|
||||
const previewResult = resolveTimings({ elements, wordTimings, anchors });
|
||||
|
||||
// Simulate render call (same input as would arrive from timingCompiler)
|
||||
const renderResult = resolveTimings({ elements, wordTimings, anchors });
|
||||
|
||||
// They must be identical — this is the "preview == render" guarantee.
|
||||
expect(previewResult).toEqual(renderResult);
|
||||
|
||||
// Spot-check the anchored element's resolved values:
|
||||
// enterAt = word[1].start = 1.0 (no offset)
|
||||
// holdDuration = max(0, 3.5 - (1.0 + 0.4 + 0.3)) = max(0, 1.8) = 1.8
|
||||
// exitAt = 1.0 + 0.4 + 1.8 + 0.3 = 3.5
|
||||
expect(previewResult["hf-title"]).toEqual({ enterAt: 1.0, exitAt: 3.5, holdDuration: 1.8 });
|
||||
|
||||
// Free elements keep authored timing
|
||||
expect(previewResult["hf-sub"]).toEqual({ enterAt: 3.0, exitAt: 4.5, holdDuration: 0 });
|
||||
expect(previewResult["hf-cta"]).toEqual({ enterAt: 5.0, exitAt: 6.0, holdDuration: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Shared pure timing resolver — WS-C.
|
||||
*
|
||||
* resolveTimings() is the SINGLE implementation of word-anchored elastic timing.
|
||||
* It is consumed by both:
|
||||
* 1. The preview path (session layer in @hyperframes/sdk)
|
||||
* 2. The render path (timingCompiler.ts + htmlBundler in @hyperframes/core)
|
||||
*
|
||||
* "preview == render" guarantee: there is exactly one code path for anchor
|
||||
* resolution so both environments produce identical enter/exit times.
|
||||
*
|
||||
* Constraints:
|
||||
* - Deterministic + pure: no Date.now(), no Math.random(), no DOM, no I/O.
|
||||
* - Never timescale animated content: elastic hold extends the hold window,
|
||||
* not tween durations.
|
||||
* - Align-on-adjust: only explicitly anchored elements become word-locked;
|
||||
* un-anchored elements keep their authored start/duration unchanged.
|
||||
* - Elastic hold: holdDuration = max(0, slot − (enter + exit)), clamped ≥ 0.
|
||||
*/
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WordTiming {
|
||||
/** Word index (0-based) */
|
||||
index: number;
|
||||
/** Absolute start time of this word in seconds */
|
||||
start: number;
|
||||
/** Absolute end time of this word in seconds */
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ElementAnchor {
|
||||
/** Which element this anchor applies to */
|
||||
hfId: string;
|
||||
/**
|
||||
* Index of the word in `wordTimings` this element is anchored to.
|
||||
* The element's enterAt = wordTimings[wordIndex].start + enterOffset.
|
||||
*/
|
||||
wordIndex: number;
|
||||
/**
|
||||
* Offset in seconds from the anchored word's start time to the element's enter.
|
||||
* Defaults to 0.
|
||||
*/
|
||||
enterOffset?: number;
|
||||
/**
|
||||
* The authored enter duration (time from element start until hold begins).
|
||||
* Used to compute the hold slot.
|
||||
*/
|
||||
enterDuration: number;
|
||||
/**
|
||||
* The authored exit duration (time from hold end until element exits).
|
||||
* Used to compute the hold slot.
|
||||
*/
|
||||
exitDuration: number;
|
||||
/**
|
||||
* The "slot" end time: the element must finish by this time.
|
||||
* holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
|
||||
*/
|
||||
slotEnd: number;
|
||||
}
|
||||
|
||||
export interface AuthoredTiming {
|
||||
hfId: string;
|
||||
/** Authored data-start value in seconds */
|
||||
start: number;
|
||||
/** Authored duration in seconds (data-duration or data-end - data-start) */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface ResolvedTiming {
|
||||
enterAt: number;
|
||||
exitAt: number;
|
||||
/** Computed elastic hold duration (>= 0). Non-anchored elements have holdDuration = 0. */
|
||||
holdDuration: number;
|
||||
}
|
||||
|
||||
export interface ResolveTimingsInput {
|
||||
/** All authored element timings (both anchored and un-anchored). */
|
||||
elements: AuthoredTiming[];
|
||||
/** TTS word timings from the backend. */
|
||||
wordTimings: WordTiming[];
|
||||
/** The set of elements that are word-anchored. Only these get word-locked. */
|
||||
anchors: ElementAnchor[];
|
||||
}
|
||||
|
||||
export type ResolveTimingsResult = Record<string, ResolvedTiming>;
|
||||
|
||||
// ── Resolver ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve element timings for a composition with optional word-anchored elements.
|
||||
*
|
||||
* Align-on-adjust rule: only elements with an explicit anchor in `anchors` are
|
||||
* word-locked. All others keep their authored start/duration unchanged.
|
||||
*
|
||||
* Elastic hold: for anchored elements, the hold window is expanded to fill the
|
||||
* slot without timescaling animated content. The hold duration is:
|
||||
* holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
|
||||
*
|
||||
* @param input - Elements, word timings, and anchor map.
|
||||
* @returns A map from hfId to resolved { enterAt, exitAt, holdDuration }.
|
||||
*/
|
||||
export function resolveTimings(input: ResolveTimingsInput): ResolveTimingsResult {
|
||||
const { elements, wordTimings, anchors } = input;
|
||||
|
||||
// Build anchor lookup by hfId for O(1) access.
|
||||
const anchorMap = new Map<string, ElementAnchor>();
|
||||
for (const anchor of anchors) {
|
||||
anchorMap.set(anchor.hfId, anchor);
|
||||
}
|
||||
|
||||
// Build word timing lookup by index for O(1) access.
|
||||
const wordMap = new Map<number, WordTiming>();
|
||||
for (const wt of wordTimings) {
|
||||
wordMap.set(wt.index, wt);
|
||||
}
|
||||
|
||||
const result: ResolveTimingsResult = {};
|
||||
|
||||
for (const el of elements) {
|
||||
const anchor = anchorMap.get(el.hfId);
|
||||
|
||||
if (anchor === undefined) {
|
||||
// Un-anchored: keep authored timing exactly as-is.
|
||||
result[el.hfId] = {
|
||||
enterAt: el.start,
|
||||
exitAt: el.start + el.duration,
|
||||
holdDuration: 0,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word-anchored: compute enter from the word timing.
|
||||
const word = wordMap.get(anchor.wordIndex);
|
||||
const wordStart = word !== undefined ? word.start : 0;
|
||||
const enterOffset = anchor.enterOffset ?? 0;
|
||||
const enterAt = wordStart + enterOffset;
|
||||
|
||||
// Elastic hold: expand hold to fill the slot, clamped >= 0.
|
||||
// holdDuration = max(0, slotEnd - (enterAt + enterDuration + exitDuration))
|
||||
const holdDuration = Math.max(
|
||||
0,
|
||||
anchor.slotEnd - (enterAt + anchor.enterDuration + anchor.exitDuration),
|
||||
);
|
||||
|
||||
// exitAt = enterAt + enterDuration + hold + exitDuration
|
||||
const exitAt = enterAt + anchor.enterDuration + holdDuration + anchor.exitDuration;
|
||||
|
||||
result[el.hfId] = { enterAt, exitAt, holdDuration };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -108,6 +108,17 @@ export type {
|
||||
CompilationResult,
|
||||
} from "./compiler/timingCompiler";
|
||||
|
||||
// Timing resolver — shared pure resolver for word-anchored elastic timing (WS-C).
|
||||
export type {
|
||||
WordTiming,
|
||||
ElementAnchor,
|
||||
AuthoredTiming,
|
||||
ResolvedTiming,
|
||||
ResolveTimingsInput,
|
||||
ResolveTimingsResult,
|
||||
} from "./compiler/timingResolver";
|
||||
export { resolveTimings } from "./compiler/timingResolver";
|
||||
|
||||
export {
|
||||
compileTimingAttrs,
|
||||
injectDurations,
|
||||
|
||||
@@ -1144,3 +1144,61 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
|
||||
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Label extraction (WS-C) ──────────────────────────────────────────────────
|
||||
|
||||
export interface GsapLabelEntry {
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all `tl.addLabel("name", position)` calls from a GSAP script.
|
||||
*
|
||||
* Returns labels in source order. Position must be a numeric literal; labels
|
||||
* with non-numeric positions (e.g. label-relative offsets) are skipped.
|
||||
*
|
||||
* Pure — no side effects, no DOM, no Date.now.
|
||||
*/
|
||||
export function extractGsapLabels(script: string): GsapLabelEntry[] {
|
||||
try {
|
||||
const ast = acorn.parse(script, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
locations: true,
|
||||
});
|
||||
const scope = collectScopeBindings(ast);
|
||||
const detection = findTimelineVar(ast, scope);
|
||||
const timelineVar = detection.timelineVar ?? "tl";
|
||||
|
||||
const labels: GsapLabelEntry[] = [];
|
||||
|
||||
acornWalk.simple(ast, {
|
||||
// fallow-ignore-next-line complexity
|
||||
ExpressionStatement(node: any) {
|
||||
const expr = node.expression;
|
||||
if (!expr || expr.type !== "CallExpression") return;
|
||||
const callee = expr.callee;
|
||||
// Match tl.addLabel(...)
|
||||
if (
|
||||
callee?.type !== "MemberExpression" ||
|
||||
callee.object?.name !== timelineVar ||
|
||||
callee.property?.name !== "addLabel"
|
||||
)
|
||||
return;
|
||||
const args = expr.arguments ?? [];
|
||||
const nameNode = args[0];
|
||||
const posNode = args[1];
|
||||
if (nameNode?.type !== "Literal" || typeof nameNode.value !== "string") return;
|
||||
if (!posNode) return;
|
||||
const pos = resolveNode(posNode, scope);
|
||||
if (typeof pos !== "number" || !Number.isFinite(pos)) return;
|
||||
labels.push({ name: nameNode.value, position: pos });
|
||||
},
|
||||
});
|
||||
|
||||
return labels;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user