merge: 최신 main을 베팅 핵심 능력치 작업에 반영
This commit is contained in:
@@ -47,6 +47,7 @@ type NavigationFixture = {
|
|||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
accessLimitAfterCalls?: number;
|
accessLimitAfterCalls?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
|
draftCommandTable?: boolean;
|
||||||
refCommandCategories?: boolean;
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
@@ -154,8 +155,104 @@ const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({
|
const draftCommandGroups = [
|
||||||
general: refCategories
|
{
|
||||||
|
category: '국가',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_건국',
|
||||||
|
name: '건국',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput' as const,
|
||||||
|
inputFields: [
|
||||||
|
{ key: 'nationName', label: '국가명', kind: 'text' as const, required: true, min: 1, max: 18 },
|
||||||
|
{
|
||||||
|
key: 'nationType',
|
||||||
|
label: '국가 성향',
|
||||||
|
kind: 'select' as const,
|
||||||
|
required: true,
|
||||||
|
optionSource: 'nationTypes' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'colorType',
|
||||||
|
label: '국기 색상',
|
||||||
|
kind: 'select' as const,
|
||||||
|
required: true,
|
||||||
|
optionSource: 'colors' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_물자원조',
|
||||||
|
name: '물자 원조',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput' as const,
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'destNationId',
|
||||||
|
label: '대상 국가',
|
||||||
|
kind: 'select' as const,
|
||||||
|
required: true,
|
||||||
|
optionSource: 'nations' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amountList',
|
||||||
|
label: '지원 물자',
|
||||||
|
kind: 'numberTuple' as const,
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
tupleLabels: ['금', '쌀'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_군량매매',
|
||||||
|
name: '군량 매매',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput' as const,
|
||||||
|
inputFields: [
|
||||||
|
{ key: 'buyRice', label: '거래', kind: 'boolean' as const, required: true },
|
||||||
|
{ key: 'amount', label: '수량', kind: 'number' as const, required: true, min: 100, step: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'che_장비매매',
|
||||||
|
name: '장비 매매',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput' as const,
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'itemType',
|
||||||
|
label: '장비 종류',
|
||||||
|
kind: 'select' as const,
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'horse', label: '명마' },
|
||||||
|
{ value: 'weapon', label: '무기' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'itemCode',
|
||||||
|
label: '장비',
|
||||||
|
kind: 'select' as const,
|
||||||
|
required: true,
|
||||||
|
optionSource: 'items' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({
|
||||||
|
general: draftCommands
|
||||||
|
? draftCommandGroups
|
||||||
|
: refCategories
|
||||||
? refCommandCategoryFixture
|
? refCommandCategoryFixture
|
||||||
: large
|
: large
|
||||||
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
||||||
@@ -185,13 +282,43 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
|
|||||||
nation: [],
|
nation: [],
|
||||||
inputOptions: {
|
inputOptions: {
|
||||||
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
||||||
nations: [],
|
nations: draftCommands
|
||||||
|
? [
|
||||||
|
{ value: 1, label: '아국', color: '#008000' },
|
||||||
|
{ value: 2, label: '적국', color: '#800000' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
generals: [],
|
generals: [],
|
||||||
crewTypes: [],
|
crewTypes: [],
|
||||||
armTypes: [],
|
armTypes: [],
|
||||||
nationTypes: [],
|
nationTypes: draftCommands ? [{ value: 'che_도적', label: '도적' }] : [],
|
||||||
colors: [],
|
colors: draftCommands
|
||||||
items: {},
|
? [
|
||||||
|
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||||||
|
{ value: 15, label: '색상 16', color: '#6495ED' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
items: draftCommands
|
||||||
|
? {
|
||||||
|
horse: [
|
||||||
|
{ value: 'None', label: '없음' },
|
||||||
|
{ value: '적토마', label: '적토마' },
|
||||||
|
],
|
||||||
|
weapon: [
|
||||||
|
{ value: 'None', label: '없음' },
|
||||||
|
{ value: '청룡언월도', label: '청룡언월도' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
context: draftCommands
|
||||||
|
? {
|
||||||
|
actorGold: 10_000,
|
||||||
|
actorRice: 20_000,
|
||||||
|
nationGold: 30_000,
|
||||||
|
nationRice: 40_000,
|
||||||
|
nationLevel: 3,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,7 +582,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
data: commandTableFixture(
|
data: commandTableFixture(
|
||||||
state.largeCommandTable === true,
|
state.largeCommandTable === true,
|
||||||
state.commandBlockedCount,
|
state.commandBlockedCount,
|
||||||
state.refCommandCategories === true
|
state.refCommandCategories === true,
|
||||||
|
state.draftCommandTable === true
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: input.known.commandTable === currentCommandTableRevision
|
: input.known.commandTable === currentCommandTableRevision
|
||||||
@@ -2776,6 +2904,104 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
|||||||
expect(state.operations).not.toContain('auth.logout');
|
expect(state.operations).not.toContain('auth.logout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
] as const) {
|
||||||
|
test(`preserves every command argument draft during realtime activity refreshes on ${viewport.name}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
draftCommandTable: true,
|
||||||
|
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
|
||||||
|
};
|
||||||
|
await installRealtimeHarness(page);
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await waitForMain(page);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||||||
|
|
||||||
|
let refreshIndex = 0;
|
||||||
|
const refreshActivityAndCommands = async () => {
|
||||||
|
const callsBefore = state.generalMeCalls;
|
||||||
|
const operationsBefore = state.operations.length;
|
||||||
|
refreshIndex += 1;
|
||||||
|
state.commandTableRevision = String.fromCharCode(80 + refreshIndex).repeat(22);
|
||||||
|
state.commandTableOperations = [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/inputOptions/context/actorGold',
|
||||||
|
value: 10_000 + refreshIndex,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await emitReadModelInvalidation(
|
||||||
|
page,
|
||||||
|
readModelInvalidation({ commands: true, records: true, frontStatus: true })
|
||||||
|
);
|
||||||
|
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBefore + 1);
|
||||||
|
await expect
|
||||||
|
.poll(() => state.operations.slice(operationsBefore).sort(), { timeout: 4_000 })
|
||||||
|
.toEqual(
|
||||||
|
['dashboard.getContextBundleDelta', 'general.getFrontStatus', 'general.getRecentRecords'].sort()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '건국', exact: true }).click();
|
||||||
|
await picker.getByLabel('국가명').fill('초안보존국');
|
||||||
|
await picker.getByLabel('국기 색상').selectOption('15');
|
||||||
|
await refreshActivityAndCommands();
|
||||||
|
await expect(picker.getByLabel('국가명')).toHaveValue('초안보존국');
|
||||||
|
await expect(picker.getByLabel('국기 색상')).toHaveValue('15');
|
||||||
|
await expect(picker.getByLabel('국기 색상')).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||||||
|
await picker.screenshot({ path: test.info().outputPath(`command-draft-${viewport.name}.png`) });
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '물자 원조', exact: true }).click();
|
||||||
|
await picker.getByLabel('대상 국가').selectOption('2');
|
||||||
|
await picker.getByLabel('금').fill('111');
|
||||||
|
await picker.getByLabel('쌀').fill('222');
|
||||||
|
await refreshActivityAndCommands();
|
||||||
|
await expect(picker.getByLabel('대상 국가')).toHaveValue('2');
|
||||||
|
await expect(picker.getByLabel('금')).toHaveValue('111');
|
||||||
|
await expect(picker.getByLabel('쌀')).toHaveValue('222');
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '군량 매매', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '쌀 판매', exact: true }).click();
|
||||||
|
await picker.getByLabel('수량').fill('700');
|
||||||
|
await refreshActivityAndCommands();
|
||||||
|
await expect(picker.getByRole('button', { name: '쌀 판매', exact: true })).toHaveClass(/selected/u);
|
||||||
|
await expect(picker.getByLabel('수량')).toHaveValue('700');
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||||
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
|
await picker.getByLabel('장비', { exact: true }).selectOption('청룡언월도');
|
||||||
|
await refreshActivityAndCommands();
|
||||||
|
await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon');
|
||||||
|
await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도');
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||||
|
viewport.width
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
|
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { CommandInputField, CommandOption } from './types';
|
||||||
|
|
||||||
|
export type CommandArgumentFieldContract = Pick<CommandInputField, 'kind' | 'optionSource'>;
|
||||||
|
|
||||||
|
export const commandArgumentFieldContract = (field: CommandInputField): CommandArgumentFieldContract => ({
|
||||||
|
kind: field.kind,
|
||||||
|
optionSource: field.optionSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const shouldPreserveCommandArgumentValue = (
|
||||||
|
field: CommandInputField,
|
||||||
|
previousField: CommandArgumentFieldContract | undefined,
|
||||||
|
values: Readonly<Record<string, unknown>>,
|
||||||
|
options: readonly CommandOption[]
|
||||||
|
): boolean => {
|
||||||
|
if (field.kind === 'hidden' || !Object.prototype.hasOwnProperty.call(values, field.key)) return false;
|
||||||
|
if (!previousField || previousField.kind !== field.kind || previousField.optionSource !== field.optionSource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (field.kind !== 'select') return true;
|
||||||
|
return options.some((option) => option.value === values[field.key]);
|
||||||
|
};
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||||
import MapViewer from './MapViewer.vue';
|
import MapViewer from './MapViewer.vue';
|
||||||
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||||
|
import {
|
||||||
|
commandArgumentFieldContract,
|
||||||
|
shouldPreserveCommandArgumentValue,
|
||||||
|
type CommandArgumentFieldContract,
|
||||||
|
} from '../command/commandArgumentDraft';
|
||||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||||
import type {
|
import type {
|
||||||
CommandInputContext,
|
CommandInputContext,
|
||||||
@@ -28,6 +33,8 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const values = reactive<Record<string, unknown>>({});
|
const values = reactive<Record<string, unknown>>({});
|
||||||
|
const previousFieldContracts = new Map<string, CommandArgumentFieldContract>();
|
||||||
|
let initializedCommandKey: string | null = null;
|
||||||
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
|
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
|
||||||
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
|
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
|
||||||
|
|
||||||
@@ -87,11 +94,39 @@ const defaultValue = (field: CommandInputField): unknown => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialize = () => {
|
const synchronizeValues = () => {
|
||||||
for (const key of Object.keys(values)) delete values[key];
|
const commandChanged = initializedCommandKey !== props.commandKey;
|
||||||
for (const field of props.fields) values[field.key] = defaultValue(field);
|
initializedCommandKey = props.commandKey;
|
||||||
|
const activeKeys = new Set(props.fields.map((field) => field.key));
|
||||||
|
for (const key of Object.keys(values)) {
|
||||||
|
if (!activeKeys.has(key)) delete values[key];
|
||||||
|
}
|
||||||
|
for (const field of props.fields) {
|
||||||
|
const preserve =
|
||||||
|
!commandChanged &&
|
||||||
|
shouldPreserveCommandArgumentValue(
|
||||||
|
field,
|
||||||
|
previousFieldContracts.get(field.key),
|
||||||
|
values,
|
||||||
|
optionsFor(field)
|
||||||
|
);
|
||||||
|
if (!preserve) values[field.key] = defaultValue(field);
|
||||||
|
}
|
||||||
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
||||||
if (itemCodeField) values.itemCode = defaultValue(itemCodeField);
|
if (
|
||||||
|
itemCodeField &&
|
||||||
|
(commandChanged ||
|
||||||
|
!shouldPreserveCommandArgumentValue(
|
||||||
|
itemCodeField,
|
||||||
|
previousFieldContracts.get(itemCodeField.key),
|
||||||
|
values,
|
||||||
|
optionsFor(itemCodeField)
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
values.itemCode = defaultValue(itemCodeField);
|
||||||
|
}
|
||||||
|
previousFieldContracts.clear();
|
||||||
|
for (const field of props.fields) previousFieldContracts.set(field.key, commandArgumentFieldContract(field));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectValue = (field: CommandInputField, rawValue: string) => {
|
const setSelectValue = (field: CommandInputField, rawValue: string) => {
|
||||||
@@ -352,7 +387,11 @@ const isValid = computed(() =>
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(() => [props.commandKey, props.fields, props.options] as const, initialize, { immediate: true, deep: true });
|
watch(
|
||||||
|
() => [props.commandKey, props.fields, props.options, props.mapData?.myCity, props.mapData?.myNation] as const,
|
||||||
|
synchronizeValues,
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
watch(
|
watch(
|
||||||
() => ({ ...values }),
|
() => ({ ...values }),
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
commandArgumentFieldContract,
|
||||||
|
shouldPreserveCommandArgumentValue,
|
||||||
|
} from '../src/components/command/commandArgumentDraft.ts';
|
||||||
|
import type { CommandInputField, CommandOption } from '../src/components/command/types.ts';
|
||||||
|
|
||||||
|
const field = (kind: CommandInputField['kind'], optionSource?: CommandInputField['optionSource']): CommandInputField => ({
|
||||||
|
key: 'value',
|
||||||
|
label: '값',
|
||||||
|
kind,
|
||||||
|
required: true,
|
||||||
|
optionSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('preserves every user-editable non-select command argument across data refreshes', () => {
|
||||||
|
for (const [kind, value] of [
|
||||||
|
['text', '작성 중인 국명'],
|
||||||
|
['number', 777],
|
||||||
|
['boolean', false],
|
||||||
|
['numberTuple', [111, 222]],
|
||||||
|
] as const) {
|
||||||
|
const currentField = field(kind);
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(
|
||||||
|
currentField,
|
||||||
|
commandArgumentFieldContract(currentField),
|
||||||
|
{ value },
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('preserves a select only while its field contract and selected option remain available', () => {
|
||||||
|
const currentField = field('select', 'cities');
|
||||||
|
const options: CommandOption[] = [
|
||||||
|
{ value: 1, label: '업' },
|
||||||
|
{ value: 2, label: '허창' },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(
|
||||||
|
currentField,
|
||||||
|
commandArgumentFieldContract(currentField),
|
||||||
|
{ value: 2 },
|
||||||
|
options
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(
|
||||||
|
currentField,
|
||||||
|
commandArgumentFieldContract(currentField),
|
||||||
|
{ value: 3 },
|
||||||
|
options
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(currentField, { kind: 'select', optionSource: 'nations' }, { value: 2 }, options),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('reinitializes hidden, new, and changed-kind fields', () => {
|
||||||
|
const hidden = field('hidden');
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(hidden, commandArgumentFieldContract(hidden), { value: 'old' }, []),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const text = field('text');
|
||||||
|
assert.equal(shouldPreserveCommandArgumentValue(text, undefined, { value: 'old' }, []), false);
|
||||||
|
assert.equal(shouldPreserveCommandArgumentValue(text, { kind: 'number' }, { value: 'old' }, []), false);
|
||||||
|
assert.equal(
|
||||||
|
shouldPreserveCommandArgumentValue(text, commandArgumentFieldContract(text), {}, []),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user