Implement core value modals
This commit is contained in:
@@ -25,6 +25,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
className = "",
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
backdropVariant = "default",
|
||||
}) => {
|
||||
const createRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -132,6 +133,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
ariaLabelledBy={ariaLabelledBy}
|
||||
createRef={createRef}
|
||||
overlayRef={overlayRef}
|
||||
backdropVariant={backdropVariant}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface CreateProps {
|
||||
multiSelect?: boolean;
|
||||
upload?: boolean;
|
||||
proportion?: boolean;
|
||||
/**
|
||||
* Backdrop behind the dialog. `loginYellow` matches the Login modal’s blurred brand overlay.
|
||||
* @default "default"
|
||||
*/
|
||||
backdropVariant?: "default" | "loginYellow";
|
||||
}
|
||||
|
||||
export interface CreateViewProps {
|
||||
@@ -51,4 +56,5 @@ export interface CreateViewProps {
|
||||
ariaLabelledBy?: string;
|
||||
createRef: React.RefObject<HTMLDivElement>;
|
||||
overlayRef: React.RefObject<HTMLDivElement>;
|
||||
backdropVariant: "default" | "loginYellow";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import ModalFooter from "../../utility/ModalFooter";
|
||||
import ModalHeader from "../../utility/ModalHeader";
|
||||
import type { CreateViewProps } from "./Create.types";
|
||||
|
||||
const backdropOverlayClasses: Record<
|
||||
CreateViewProps["backdropVariant"],
|
||||
string
|
||||
> = {
|
||||
default: "fixed inset-0 bg-black/50 z-[9998]",
|
||||
loginYellow:
|
||||
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
|
||||
};
|
||||
|
||||
export function CreateView({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -28,6 +37,7 @@ export function CreateView({
|
||||
ariaLabelledBy,
|
||||
createRef,
|
||||
overlayRef,
|
||||
backdropVariant,
|
||||
}: CreateViewProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -36,7 +46,7 @@ export function CreateView({
|
||||
{/* Overlay */}
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 bg-black/50 z-[9998]"
|
||||
className={backdropOverlayClasses[backdropVariant]}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
readAnonymousCreateFlowState,
|
||||
writeAnonymousCreateFlowState,
|
||||
} from "../utils/anonymousDraftStorage";
|
||||
import {
|
||||
clearCoreValueDetailsLocalStorage,
|
||||
readCoreValueDetailsFromLocalStorage,
|
||||
writeCoreValueDetailsToLocalStorage,
|
||||
} from "../utils/coreValueDetailsLocalStorage";
|
||||
|
||||
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
|
||||
|
||||
@@ -41,9 +46,20 @@ export function CreateFlowProvider({
|
||||
initialStep = null,
|
||||
enableAnonymousPersistence = false,
|
||||
}: CreateFlowProviderProps) {
|
||||
const [state, setState] = useState<CreateFlowState>(() =>
|
||||
enableAnonymousPersistence ? readAnonymousCreateFlowState() : {},
|
||||
);
|
||||
const [state, setState] = useState<CreateFlowState>(() => {
|
||||
const base = enableAnonymousPersistence
|
||||
? readAnonymousCreateFlowState()
|
||||
: {};
|
||||
const storedDetails = readCoreValueDetailsFromLocalStorage();
|
||||
if (Object.keys(storedDetails).length === 0) return base;
|
||||
return {
|
||||
...base,
|
||||
coreValueDetailsByChipId: {
|
||||
...storedDetails,
|
||||
...(base.coreValueDetailsByChipId ?? {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
||||
const prevPersistRef = useRef(enableAnonymousPersistence);
|
||||
@@ -72,6 +88,11 @@ export function CreateFlowProvider({
|
||||
writeAnonymousCreateFlowState(state);
|
||||
}, [state, enableAnonymousPersistence]);
|
||||
|
||||
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
|
||||
useEffect(() => {
|
||||
writeCoreValueDetailsToLocalStorage(state.coreValueDetailsByChipId);
|
||||
}, [state.coreValueDetailsByChipId]);
|
||||
|
||||
const markCreateFlowInteraction = useCallback(() => {
|
||||
setInteractionTouched(true);
|
||||
}, []);
|
||||
@@ -85,6 +106,12 @@ export function CreateFlowProvider({
|
||||
...updates.communityStructureChipSnapshots,
|
||||
};
|
||||
}
|
||||
if (updates.coreValueDetailsByChipId !== undefined) {
|
||||
merged.coreValueDetailsByChipId = {
|
||||
...(prevState.coreValueDetailsByChipId ?? {}),
|
||||
...updates.coreValueDetailsByChipId,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
@@ -97,6 +124,7 @@ export function CreateFlowProvider({
|
||||
setState({});
|
||||
setInteractionTouched(false);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
}, []);
|
||||
|
||||
const contextValue: CreateFlowContextValue = {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import TextArea from "../../../components/controls/TextArea";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||
@@ -11,10 +14,38 @@ import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoCo
|
||||
|
||||
const MAX_CORE_VALUES = 5;
|
||||
|
||||
function chipRowsFromLabels(rows: readonly string[]): ChipOption[] {
|
||||
return rows.map((label, i) => ({
|
||||
type ModalSession = "pending" | "editing";
|
||||
|
||||
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
|
||||
type CoreValuePresetJson =
|
||||
| string
|
||||
| { label: string; meaning?: string; signals?: string };
|
||||
|
||||
type CoreValuePreset = {
|
||||
label: string;
|
||||
meaning: string;
|
||||
signals: string;
|
||||
};
|
||||
|
||||
function normalizeCoreValuePresets(
|
||||
values: readonly CoreValuePresetJson[],
|
||||
): CoreValuePreset[] {
|
||||
return values.map((v) => {
|
||||
if (typeof v === "string") {
|
||||
return { label: v, meaning: "", signals: "" };
|
||||
}
|
||||
return {
|
||||
label: v.label,
|
||||
meaning: typeof v.meaning === "string" ? v.meaning : "",
|
||||
signals: typeof v.signals === "string" ? v.signals : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
||||
return presets.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label,
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
}));
|
||||
}
|
||||
@@ -69,7 +100,11 @@ function snapshotRowsToChipOptions(
|
||||
export function CoreValuesSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cv = m.create.coreValues;
|
||||
const presetLabels = cv.values;
|
||||
const presets = useMemo(
|
||||
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
|
||||
[cv.values],
|
||||
);
|
||||
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
|
||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
|
||||
@@ -77,12 +112,19 @@ export function CoreValuesSelectScreen() {
|
||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(presetLabels),
|
||||
chipRowsFromPresets(presets),
|
||||
state.selectedCoreValueIds,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||
const [draftMeaning, setDraftMeaning] = useState("");
|
||||
const [draftSignals, setDraftSignals] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||
if (fromSnap) {
|
||||
@@ -94,40 +136,121 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
|
||||
|
||||
const persistCoreValues = useCallback(
|
||||
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
|
||||
const syncCoreValuesToDraft = useCallback(
|
||||
(next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setCoreValueOptions(next);
|
||||
updateState({
|
||||
selectedCoreValueIds: selectedIdsFromOptions(next),
|
||||
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
|
||||
});
|
||||
},
|
||||
[markCreateFlowInteraction, updateState],
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const persistCoreValues = useCallback(
|
||||
(next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setCoreValueOptions(next);
|
||||
syncCoreValuesToDraft(next);
|
||||
},
|
||||
[markCreateFlowInteraction, syncCoreValuesToDraft],
|
||||
);
|
||||
|
||||
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
|
||||
const getPresetTexts = useCallback(
|
||||
(valueLabel: string): { meaning: string; signals: string } => {
|
||||
const row = presets.find((p) => p.label === valueLabel);
|
||||
if (!row) return { meaning: "", signals: "" };
|
||||
return { meaning: row.meaning, signals: row.signals };
|
||||
},
|
||||
[presets],
|
||||
);
|
||||
|
||||
const getInitialTexts = useCallback(
|
||||
(chipId: string, valueLabel: string) => {
|
||||
const saved = state.coreValueDetailsByChipId?.[chipId];
|
||||
const preset = getPresetTexts(valueLabel);
|
||||
return {
|
||||
meaning: saved?.meaning ?? preset.meaning,
|
||||
signals: saved?.signals ?? preset.signals,
|
||||
};
|
||||
},
|
||||
[state.coreValueDetailsByChipId, getPresetTexts],
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(chipId: string, session: ModalSession, valueLabel: string) => {
|
||||
const initial = getInitialTexts(chipId, valueLabel);
|
||||
setDraftMeaning(initial.meaning);
|
||||
setDraftSignals(initial.signals);
|
||||
setActiveModalChipId(chipId);
|
||||
setModalSession(session);
|
||||
markCreateFlowInteraction();
|
||||
},
|
||||
[getInitialTexts, markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (activeModalChipId && modalSession === "pending") {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId
|
||||
? { ...opt, state: "Unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
}
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
|
||||
|
||||
const handleModalConfirm = useCallback(() => {
|
||||
if (!activeModalChipId) return;
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
[activeModalChipId]: {
|
||||
meaning: draftMeaning,
|
||||
signals: draftSignals,
|
||||
},
|
||||
},
|
||||
});
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [
|
||||
activeModalChipId,
|
||||
draftMeaning,
|
||||
draftSignals,
|
||||
markCreateFlowInteraction,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||
if (!target || target.state === "Custom") return;
|
||||
|
||||
const willSelect = target.state !== "Selected";
|
||||
const selectedCount = coreValueOptions.filter(
|
||||
(o) => o.state === "Selected",
|
||||
).length;
|
||||
if (willSelect && selectedCount >= MAX_CORE_VALUES) return;
|
||||
|
||||
if (target.state === "Selected") {
|
||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, state: "Unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCount >= MAX_CORE_VALUES) return;
|
||||
|
||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
}
|
||||
? { ...opt, state: "Selected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
openModal(chipId, "pending", target.label);
|
||||
};
|
||||
|
||||
const addHandlers = {
|
||||
@@ -138,24 +261,37 @@ export function CoreValuesSelectScreen() {
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
];
|
||||
updateState({
|
||||
selectedCoreValueIds: selectedIdsFromOptions(next),
|
||||
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
|
||||
});
|
||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setCoreValueOptions((prev) => {
|
||||
const next = prev.map((opt) =>
|
||||
const withLabel = prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
updateState({
|
||||
selectedCoreValueIds: selectedIdsFromOptions(next),
|
||||
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
|
||||
const selectedCount = withLabel.filter(
|
||||
(o) => o.state === "Selected",
|
||||
).length;
|
||||
const canSelect = selectedCount < MAX_CORE_VALUES;
|
||||
const next = canSelect
|
||||
? withLabel.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, state: "Selected" as const }
|
||||
: opt,
|
||||
)
|
||||
: withLabel;
|
||||
|
||||
queueMicrotask(() => {
|
||||
syncCoreValuesToDraft(next);
|
||||
if (canSelect) {
|
||||
openModal(chipId, "pending", value);
|
||||
} else {
|
||||
openModal(chipId, "editing", value);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
@@ -164,15 +300,15 @@ export function CoreValuesSelectScreen() {
|
||||
markCreateFlowInteraction();
|
||||
setCoreValueOptions((prev) => {
|
||||
const next = prev.filter((o) => o.id !== chipId);
|
||||
updateState({
|
||||
selectedCoreValueIds: selectedIdsFromOptions(next),
|
||||
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
|
||||
});
|
||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const modalChipLabel =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||
@@ -192,6 +328,8 @@ export function CoreValuesSelectScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const detailModal = cv.detailModal;
|
||||
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
lgVerticalAlign="start"
|
||||
@@ -214,6 +352,50 @@ export function CoreValuesSelectScreen() {
|
||||
addButton
|
||||
addButtonText={cv.multiSelect.addButtonText}
|
||||
/>
|
||||
|
||||
{detailModal && (
|
||||
<Create
|
||||
isOpen={activeModalChipId !== null}
|
||||
onClose={handleModalDismiss}
|
||||
backdropVariant="loginYellow"
|
||||
headerContent={
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={modalChipLabel}
|
||||
description={detailModal.subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
onNext={handleModalConfirm}
|
||||
nextButtonText={detailModal.addValueButton}
|
||||
ariaLabel={modalChipLabel || "Core value details"}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||
<TextArea
|
||||
label={detailModal.meaningLabel}
|
||||
showHelpIcon
|
||||
appearance="embedded"
|
||||
size="medium"
|
||||
value={draftMeaning}
|
||||
onChange={(e) => setDraftMeaning(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<TextArea
|
||||
label={detailModal.signalsLabel}
|
||||
showHelpIcon
|
||||
appearance="embedded"
|
||||
size="medium"
|
||||
value={draftSignals}
|
||||
onChange={(e) => setDraftSignals(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</Create>
|
||||
)}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export type CommunityStructureChipSnapshotRow = {
|
||||
state?: string;
|
||||
};
|
||||
|
||||
/** Meaning + violation signals copy for a core value chip (draft + publish). */
|
||||
export type CoreValueDetailEntry = {
|
||||
meaning: string;
|
||||
signals: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flow state for inputs across create-flow steps.
|
||||
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||
@@ -75,6 +81,8 @@ export interface CreateFlowState {
|
||||
selectedCoreValueIds?: string[];
|
||||
/** Full chip rows for core values (custom labels). */
|
||||
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
|
||||
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
|
||||
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CoreValueDetailEntry } from "../types";
|
||||
|
||||
/** Persists meaning/signals per chip id across refresh (esp. signed-in create flow, in-memory only). */
|
||||
export const CORE_VALUE_DETAILS_STORAGE_KEY =
|
||||
"create-flow-core-value-details" as const;
|
||||
|
||||
export function readCoreValueDetailsFromLocalStorage(): Record<
|
||||
string,
|
||||
CoreValueDetailEntry
|
||||
> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
const out: Record<string, CoreValueDetailEntry> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (!v || typeof v !== "object") continue;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
|
||||
continue;
|
||||
}
|
||||
out[k] = { meaning: o.meaning, signals: o.signals };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCoreValueDetailsToLocalStorage(
|
||||
value: Record<string, CoreValueDetailEntry> | undefined,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCoreValueDetailsLocalStorage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user