feat(custom): store the reader's games and events, and back them up
Adds the two localStorage stores behind PRD F13 and joins their events to the feed's in App, so they sort, filter, focus, expire and tick through exactly the same code paths rather than a parallel set. Three decisions worth naming: - Export carries customGames and customEvents. These exist in one browser and nowhere else — not in the feed, not on a server — so an export without them would be a backup that loses the half the reader typed. Import merges by id like every other set and never removes. - A date is read in the reader's timezone, and a bare end date means the end of that day. Someone who types 20 Aug means the 20th where they are; the feed's 00:00Z day boundaries are a parser declining to guess a time the source never printed, which is a different situation from being told directly. - An impossible date is refused rather than rolled over, because Date.parse turns 30 February into 2 March and a silently shifted date is the failure this product exists to prevent. Deleting a game is refused while it still holds events, and deleting an event leaves its marks and logged days alone — reaching into three stores on one tap is how a misclick costs somebody a streak. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3702ee7a4c
commit
d046758671
+70
-12
@@ -16,6 +16,7 @@ import { useMarkSet } from "./state/useMarkSet.ts";
|
||||
import { useProgress } from "./state/useProgress.ts";
|
||||
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
|
||||
import { usePrefs } from "./state/usePrefs.ts";
|
||||
import { useCustom } from "./state/useCustom.ts";
|
||||
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
||||
import {
|
||||
advanceFocus,
|
||||
@@ -26,8 +27,14 @@ import {
|
||||
} from "./state/lens.ts";
|
||||
import { clockFor, DAY, formatRemaining } from "../shared/time.ts";
|
||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||
import { useGameMeta } from "./state/gameMeta.tsx";
|
||||
import type { LaneId } from "../shared/custom.ts";
|
||||
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
||||
import {
|
||||
isCustomGameId,
|
||||
type CustomEvents,
|
||||
type CustomGames,
|
||||
type LaneId,
|
||||
} from "../shared/custom.ts";
|
||||
import { metaFor } from "../shared/games.ts";
|
||||
|
||||
type View = "soon" | "calendar";
|
||||
|
||||
@@ -70,13 +77,24 @@ export function App() {
|
||||
// The event most recently ignored, so it can be put back without hunting for
|
||||
// a row that just disappeared.
|
||||
const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null);
|
||||
const gameMeta = useGameMeta();
|
||||
const now = useNow();
|
||||
const online = useOnline();
|
||||
const { prefs, update, toggleGame } = usePrefs();
|
||||
const ignored = useMarkSet(KEYS.ignored);
|
||||
const prog = useProgress();
|
||||
const daily = useDailyLog();
|
||||
const custom = useCustom();
|
||||
/**
|
||||
* How every lane in this tree is named and coloured.
|
||||
*
|
||||
* App owns it because App is the only thing holding the reader's own games,
|
||||
* and hands it down rather than letting components import a lookup that can
|
||||
* only ever answer for the tracked ones.
|
||||
*/
|
||||
const gameMeta = useMemo<MetaResolver>(
|
||||
() => (id) => metaFor(id, custom.games),
|
||||
[custom.games],
|
||||
);
|
||||
// "Completed" is now one status among several; the rest of the UI still asks
|
||||
// this question a lot, so keep a cheap shorthand.
|
||||
const isDone = (id: string) => prog.progress[id]?.status === "done";
|
||||
@@ -144,17 +162,25 @@ export function App() {
|
||||
|
||||
const allRows = useMemo<RowEvent[]>(() => {
|
||||
if (state.status !== "ready") return [];
|
||||
return state.feed.events
|
||||
.filter((e) => e.status === "published")
|
||||
.map((event) => ({ event, clock: clockFor(event, prefs.region, now) }));
|
||||
// The reader's own events are events. They sort, filter, focus, expire and
|
||||
// tick exactly like scraped ones — what sets them apart is only that
|
||||
// nothing is claimed about where their dates came from.
|
||||
return [
|
||||
...state.feed.events.filter((e) => e.status === "published"),
|
||||
...custom.rows,
|
||||
].map((event) => ({ event, clock: clockFor(event, prefs.region, now) }));
|
||||
// `now` intentionally excluded: recomputing every clock each second is
|
||||
// wasteful, and the countdown text re-renders from `now` anyway.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, prefs.region, Math.floor(now / 60_000)]);
|
||||
}, [state, custom.rows, prefs.region, Math.floor(now / 60_000)]);
|
||||
|
||||
// Feed lanes come from rows, the reader's from the games themselves — so a
|
||||
// game they just created shows up in the filters before it holds anything.
|
||||
// It is still not a scraped game with an empty feed: it has no source row, no
|
||||
// freshness badge and no colophon credit.
|
||||
const games = useMemo<LaneId[]>(
|
||||
() => [...new Set(allRows.map((r) => r.event.game))],
|
||||
[allRows],
|
||||
() => [...new Set([...allRows.map((r) => r.event.game), ...custom.lanes])],
|
||||
[allRows, custom.lanes],
|
||||
);
|
||||
|
||||
/** Games the reader plays, in feed order. The focus bar rotates through these. */
|
||||
@@ -251,6 +277,7 @@ export function App() {
|
||||
// up by default rather than staying invisible.
|
||||
if (!prefs.onboarded) {
|
||||
return (
|
||||
<GameMetaProvider value={gameMeta}>
|
||||
<Shell>
|
||||
<Welcome
|
||||
available={games}
|
||||
@@ -262,6 +289,7 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
</Shell>
|
||||
</GameMetaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,6 +298,7 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<GameMetaProvider value={gameMeta}>
|
||||
<Shell>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<div>
|
||||
@@ -336,8 +365,13 @@ export function App() {
|
||||
|
||||
{/* The chores no wiki publishes, and the only thing on this page
|
||||
that expires tonight rather than next patch. */}
|
||||
{/* Standing chores are a tracked-game notion: there is no routine we
|
||||
could name on behalf of a game the reader invented, so their lanes
|
||||
contribute repeating events here but no chore of their own. */}
|
||||
<Dailies
|
||||
games={focus === null ? enabled : [focus]}
|
||||
games={(focus === null ? enabled : [focus]).filter(
|
||||
(id) => !isCustomGameId(id),
|
||||
)}
|
||||
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
||||
region={prefs.region}
|
||||
now={now}
|
||||
@@ -430,10 +464,19 @@ export function App() {
|
||||
onUpdate={update}
|
||||
ignoredCount={Object.keys(ignored.marks).length}
|
||||
onExport={() =>
|
||||
exportProgress(prog.progress, daily.logs, ignored.marks, prefs)
|
||||
exportProgress(prog.progress, daily.logs, ignored.marks, prefs, {
|
||||
games: custom.games,
|
||||
events: custom.events,
|
||||
})
|
||||
}
|
||||
onImport={(file) =>
|
||||
void importProgress(file, prog.merge, daily.merge, ignored.merge)
|
||||
void importProgress(
|
||||
file,
|
||||
prog.merge,
|
||||
daily.merge,
|
||||
ignored.merge,
|
||||
custom.merge,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -484,6 +527,7 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
</Shell>
|
||||
</GameMetaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -565,6 +609,7 @@ function exportProgress(
|
||||
daily: DailyLogMap,
|
||||
ignored: Record<string, { at: string }>,
|
||||
prefs: unknown,
|
||||
own: { games: CustomGames; events: CustomEvents },
|
||||
) {
|
||||
const blob = new Blob(
|
||||
[
|
||||
@@ -578,6 +623,11 @@ function exportProgress(
|
||||
// an export that omitted them would quietly be a lossy backup.
|
||||
daily,
|
||||
ignored,
|
||||
// The reader's own games and events exist nowhere else at all — not
|
||||
// in the feed, not on a server. An export without them is a backup
|
||||
// that quietly loses the half they typed themselves.
|
||||
customGames: own.games,
|
||||
customEvents: own.events,
|
||||
prefs,
|
||||
},
|
||||
null,
|
||||
@@ -599,6 +649,7 @@ async function importProgress(
|
||||
mergeProgress: (c: Record<string, { at: string }>) => void,
|
||||
mergeDaily: (c: DailyLogMap) => void,
|
||||
mergeIgnored: (c: Record<string, { at: string }>) => void,
|
||||
mergeCustom: (games: unknown, events: unknown) => void,
|
||||
) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await file.text());
|
||||
@@ -608,6 +659,8 @@ async function importProgress(
|
||||
completions?: unknown;
|
||||
daily?: unknown;
|
||||
ignored?: unknown;
|
||||
customGames?: unknown;
|
||||
customEvents?: unknown;
|
||||
};
|
||||
if (data.format !== "gacha-tracker-export") {
|
||||
alert("That file isn't an Event Clock export.");
|
||||
@@ -635,6 +688,11 @@ async function importProgress(
|
||||
const d = data.daily;
|
||||
if (typeof d === "object" && d !== null) mergeDaily(d as DailyLogMap);
|
||||
if (i !== null) mergeIgnored(i);
|
||||
// Additive keys: an export written before F13 has neither, which is a file
|
||||
// from a device that had none rather than an error. An event and the game
|
||||
// it belongs to always travel together, so this can never land a lane with
|
||||
// nothing to name it.
|
||||
mergeCustom(data.customGames, data.customEvents);
|
||||
} catch {
|
||||
alert("That file couldn't be read. Export a fresh copy and try again.");
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ export const KEYS = {
|
||||
daily: `${NS}:daily`,
|
||||
ignored: `${NS}:ignored`,
|
||||
prefs: `${NS}:prefs`,
|
||||
/**
|
||||
* Games and events the reader entered themselves (PRD F13).
|
||||
*
|
||||
* Two keys rather than one because they have different lifetimes: a game
|
||||
* outlives the events in it, and deleting one is refused while the other
|
||||
* still references it. Like everything else here, this is the only copy —
|
||||
* there is no server that has ever seen it.
|
||||
*/
|
||||
customGames: `${NS}:customGames`,
|
||||
customEvents: `${NS}:customEvents`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
asDisplayEvent,
|
||||
CustomEvent,
|
||||
CustomGame,
|
||||
mintCustomEventId,
|
||||
mintCustomGameId,
|
||||
precisionOf,
|
||||
type CustomEvents,
|
||||
type CustomGames,
|
||||
type DisplayEvent,
|
||||
type LaneId,
|
||||
} from "../../shared/custom.ts";
|
||||
import type { EventType } from "../../shared/schema.ts";
|
||||
import { KEYS, readJson, writeJson } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* The reader's own games and events (PRD F13).
|
||||
*
|
||||
* Nothing here is fetched, parsed, merged or scored — this is the one part of
|
||||
* the app whose data the reader typed, and the ingest pipeline has no business
|
||||
* touching it. What it does share is everything downstream: the events are
|
||||
* projected into `DisplayEvent` and join the same lists, timeline, sort,
|
||||
* progress, ignore and daily stores as scraped ones.
|
||||
*/
|
||||
|
||||
/** What a form hands over. Instants are already resolved; precision is not. */
|
||||
export interface EventDraft {
|
||||
game: LaneId;
|
||||
title: string;
|
||||
type: EventType;
|
||||
summary: string | null;
|
||||
startsAt: string;
|
||||
startHasTime: boolean;
|
||||
endsAt: string | null;
|
||||
endHasTime: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date the reader typed, as a UTC instant.
|
||||
*
|
||||
* Read in **their** timezone, not UTC: someone who types 20 August means the
|
||||
* 20th where they are, and must see the 20th back. A start with no time is the
|
||||
* beginning of that day and an end with no time is the end of it, which is how
|
||||
* a person reads "20 Aug – 3 Sep" — the feed's own day-precision boundaries sit
|
||||
* at 00:00Z on both sides, but those are a parser declining to guess a time the
|
||||
* source never printed, and this reader is telling us directly.
|
||||
*/
|
||||
export function readerInstant(
|
||||
date: string,
|
||||
time: string | null,
|
||||
boundary: "start" | "end",
|
||||
): string | null {
|
||||
const wall =
|
||||
time !== null && time !== ""
|
||||
? `${date}T${time}`
|
||||
: `${date}T${boundary === "start" ? "00:00:00" : "23:59:59"}`;
|
||||
const ms = Date.parse(wall);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
|
||||
// `Date.parse` rolls an impossible date over rather than refusing it — 30
|
||||
// February becomes 2 March — and a silently shifted date is the one thing
|
||||
// this codebase never ships. `dates.ts` guards its parsers the same way.
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
const at = new Date(ms);
|
||||
if (at.getFullYear() !== y || at.getMonth() + 1 !== m || at.getDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return at.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a store, keeping the records that still parse.
|
||||
*
|
||||
* A record that does not is dropped rather than taking the app down with it,
|
||||
* and says so — the same trade `readJson` makes, one level deeper. Silence is
|
||||
* the thing this codebase does not hand out for free.
|
||||
*/
|
||||
function readValid<T>(
|
||||
key: string,
|
||||
schema: { safeParse: (v: unknown) => { success: boolean; data?: T } },
|
||||
label: string,
|
||||
): Record<string, T> {
|
||||
const raw = readJson<Record<string, unknown>>(key, {});
|
||||
const out: Record<string, T> = {};
|
||||
for (const [id, value] of Object.entries(raw)) {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success && parsed.data !== undefined) out[id] = parsed.data;
|
||||
else console.warn(`dropped an unreadable ${label}: ${id}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function useCustom() {
|
||||
const [games, setGames] = useState<CustomGames>(() =>
|
||||
readValid(KEYS.customGames, CustomGame, "custom game"),
|
||||
);
|
||||
const [events, setEvents] = useState<CustomEvents>(() =>
|
||||
readValid(KEYS.customEvents, CustomEvent, "custom event"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
writeJson(KEYS.customGames, games);
|
||||
}, [games]);
|
||||
useEffect(() => {
|
||||
writeJson(KEYS.customEvents, events);
|
||||
}, [events]);
|
||||
|
||||
const addGame = useCallback((name: string, hue: string): string => {
|
||||
const id = mintCustomGameId(name, Object.keys(games));
|
||||
const game = CustomGame.parse({
|
||||
id,
|
||||
name: name.trim(),
|
||||
hue,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
setGames((prev) => ({ ...prev, [id]: game }));
|
||||
return id;
|
||||
}, [games]);
|
||||
|
||||
const editGame = useCallback((id: string, name: string, hue: string) => {
|
||||
setGames((prev) => {
|
||||
const existing = prev[id];
|
||||
if (existing === undefined) return prev;
|
||||
// The id never follows the name — see docs/DATA-MODEL.md. Renaming a game
|
||||
// must not move the lane its events point at.
|
||||
return { ...prev, [id]: { ...existing, name: name.trim(), hue } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove a game, if nothing of theirs still lives in it.
|
||||
*
|
||||
* Refused rather than cascading: deleting a lane should not quietly take a
|
||||
* fortnight of events with it, and the count is more use than an undo.
|
||||
*/
|
||||
const removeGame = useCallback(
|
||||
(id: string): { removed: boolean; blockedBy: number } => {
|
||||
const holding = Object.values(events).filter((e) => e.game === id).length;
|
||||
if (holding > 0) return { removed: false, blockedBy: holding };
|
||||
setGames((prev) => {
|
||||
const { [id]: _gone, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
return { removed: true, blockedBy: 0 };
|
||||
},
|
||||
[events],
|
||||
);
|
||||
|
||||
const addEvent = useCallback((draft: EventDraft): string => {
|
||||
const now = new Date().toISOString();
|
||||
const event = CustomEvent.parse({
|
||||
id: mintCustomEventId(),
|
||||
game: draft.game,
|
||||
title: draft.title.trim(),
|
||||
type: draft.type,
|
||||
summary: draft.summary === null || draft.summary.trim() === ""
|
||||
? null
|
||||
: draft.summary.trim(),
|
||||
startsAt: draft.startsAt,
|
||||
startPrecision: precisionOf(draft.startHasTime),
|
||||
endsAt: draft.endsAt,
|
||||
// An unannounced end is a supported answer here exactly as it is in the
|
||||
// feed. Nobody is made to invent a date to satisfy a form.
|
||||
endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime),
|
||||
at: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
setEvents((prev) => ({ ...prev, [event.id]: event }));
|
||||
return event.id;
|
||||
}, []);
|
||||
|
||||
const editEvent = useCallback((id: string, draft: EventDraft) => {
|
||||
setEvents((prev) => {
|
||||
const existing = prev[id];
|
||||
if (existing === undefined) return prev;
|
||||
const next = CustomEvent.parse({
|
||||
...existing,
|
||||
game: draft.game,
|
||||
title: draft.title.trim(),
|
||||
type: draft.type,
|
||||
summary: draft.summary === null || draft.summary.trim() === ""
|
||||
? null
|
||||
: draft.summary.trim(),
|
||||
startsAt: draft.startsAt,
|
||||
startPrecision: precisionOf(draft.startHasTime),
|
||||
endsAt: draft.endsAt,
|
||||
endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { ...prev, [id]: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Forget an event the reader entered.
|
||||
*
|
||||
* Their marks and logged days for it stay where they are. Reaching into three
|
||||
* other stores on a single tap is how a misclick costs someone a streak, and
|
||||
* an orphaned mark costs them nothing.
|
||||
*/
|
||||
const removeEvent = useCallback((id: string) => {
|
||||
setEvents((prev) => {
|
||||
const { [id]: _gone, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Import: union by id, never removing what this device already has. */
|
||||
const merge = useCallback(
|
||||
(incomingGames: unknown, incomingEvents: unknown) => {
|
||||
const g = validated(incomingGames, CustomGame);
|
||||
const e = validated(incomingEvents, CustomEvent);
|
||||
if (Object.keys(g).length > 0) setGames((prev) => ({ ...g, ...prev }));
|
||||
if (Object.keys(e).length > 0) setEvents((prev) => ({ ...e, ...prev }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** The reader's events, in the shape every view reads. */
|
||||
const rows = useMemo<DisplayEvent[]>(
|
||||
() => Object.values(events).map(asDisplayEvent),
|
||||
[events],
|
||||
);
|
||||
|
||||
/** Lanes the reader defined, so filters and focus can see them. */
|
||||
const lanes = useMemo<LaneId[]>(() => Object.keys(games), [games]);
|
||||
|
||||
return {
|
||||
games,
|
||||
events,
|
||||
rows,
|
||||
lanes,
|
||||
addGame,
|
||||
editGame,
|
||||
removeGame,
|
||||
addEvent,
|
||||
editEvent,
|
||||
removeEvent,
|
||||
merge,
|
||||
};
|
||||
}
|
||||
|
||||
function validated<T>(
|
||||
input: unknown,
|
||||
schema: { safeParse: (v: unknown) => { success: boolean; data?: T } },
|
||||
): Record<string, T> {
|
||||
if (typeof input !== "object" || input === null) return {};
|
||||
const out: Record<string, T> = {};
|
||||
for (const [id, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success && parsed.data !== undefined) out[id] = parsed.data;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { metaFor } from "../src/shared/games.ts";
|
||||
import { dailiesId } from "../src/shared/daily.ts";
|
||||
import { eventId, GameId } from "../src/shared/schema.ts";
|
||||
import { clockFor } from "../src/shared/time.ts";
|
||||
import { readerInstant } from "../src/client/state/useCustom.ts";
|
||||
|
||||
const AT = "2026-08-17T12:00:00.000Z";
|
||||
|
||||
@@ -244,3 +245,42 @@ describe("knownLane", () => {
|
||||
expect(knownLane("mygame:gone", mine)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readerInstant", () => {
|
||||
// Timezone-independent assertions on purpose: the point of this helper is
|
||||
// that it reads a typed date in the *reader's* zone, so the tests check the
|
||||
// relationships that must hold in any of them rather than pinning UTC.
|
||||
const localDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString("en-CA"); // YYYY-MM-DD in local time
|
||||
|
||||
test("a typed date comes back as that same date where the reader is", () => {
|
||||
// Someone who types 20 August means the 20th where they are, and has to see
|
||||
// the 20th back — not the 19th because a server is five hours behind.
|
||||
for (const boundary of ["start", "end"] as const) {
|
||||
const iso = readerInstant("2026-08-20", null, boundary);
|
||||
expect(iso).not.toBeNull();
|
||||
expect(localDate(iso!)).toBe("2026-08-20");
|
||||
}
|
||||
});
|
||||
|
||||
test("a bare start is the beginning of the day and a bare end is the end of it", () => {
|
||||
// Which is how a person reads "20 Aug – 3 Sep": through the 3rd, not up to
|
||||
// the first second of it.
|
||||
const start = readerInstant("2026-08-20", null, "start")!;
|
||||
const end = readerInstant("2026-08-20", null, "end")!;
|
||||
expect(Date.parse(end) - Date.parse(start)).toBe(86_399_000);
|
||||
});
|
||||
|
||||
test("a stated time is kept", () => {
|
||||
const iso = readerInstant("2026-08-20", "18:30", "start")!;
|
||||
const d = new Date(iso);
|
||||
expect(d.getHours()).toBe(18);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
test("returns null for a date it cannot read, rather than a wrong one", () => {
|
||||
expect(readerInstant("", null, "start")).toBeNull();
|
||||
expect(readerInstant("not-a-date", null, "start")).toBeNull();
|
||||
expect(readerInstant("2026-02-30", null, "start")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user