refactor: 收口玩过作品打开意图
This commit is contained in:
@@ -512,6 +512,7 @@ import {
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
||||
resolveMatch3DRuntimeGeneratedItemAssets,
|
||||
} from './platformMatch3DRuntimeProfile';
|
||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||
import {
|
||||
type PlatformPublicCodeSearchStep,
|
||||
resolvePlatformPublicCodeSearchPlan,
|
||||
@@ -13815,145 +13816,59 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const openPlayedWork = useCallback(
|
||||
(work: ProfilePlayedWorkSummary) => {
|
||||
const worldType = (work.worldType ?? '').toLowerCase();
|
||||
const intent = resolvePlatformPlayedWorkOpenIntent(work);
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
|
||||
if (profileId) {
|
||||
void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^match3d:/u, '');
|
||||
if (profileId) {
|
||||
void openMatch3DPublicWorkDetail(profileId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'square-hole' ||
|
||||
worldType === 'square_hole' ||
|
||||
work.worldKey.startsWith('square-hole:')
|
||||
) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^square-hole:/u, '');
|
||||
if (profileId) {
|
||||
void openSquareHolePublicWorkDetail(profileId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'jump-hop' ||
|
||||
worldType === 'jump_hop' ||
|
||||
work.worldKey.startsWith('jump-hop:')
|
||||
) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^jump-hop:/u, '');
|
||||
if (profileId) {
|
||||
void openJumpHopPublicWorkDetail(profileId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'wooden-fish' ||
|
||||
worldType === 'wooden_fish' ||
|
||||
work.worldKey.startsWith('wooden-fish:')
|
||||
) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^wooden-fish:/u, '');
|
||||
if (profileId) {
|
||||
void openWoodenFishPublicWorkDetail(profileId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId =
|
||||
work.profileId ?? work.worldKey.replace(/^big-fish:/u, '');
|
||||
if (!sessionId) {
|
||||
switch (intent.type) {
|
||||
case 'noop':
|
||||
return;
|
||||
}
|
||||
void refreshBigFishGallery()
|
||||
.then((entries) => {
|
||||
const matchedEntry = entries.find(
|
||||
(entry) => entry.sourceSessionId === sessionId,
|
||||
);
|
||||
if (matchedEntry) {
|
||||
openPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail(matchedEntry),
|
||||
);
|
||||
return;
|
||||
}
|
||||
openPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail({
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
case 'open-puzzle':
|
||||
void openPuzzlePublicWorkDetail(intent.profileId, {
|
||||
tab: intent.tab,
|
||||
});
|
||||
return;
|
||||
return;
|
||||
case 'open-match3d':
|
||||
void openMatch3DPublicWorkDetail(intent.profileId);
|
||||
return;
|
||||
case 'open-square-hole':
|
||||
void openSquareHolePublicWorkDetail(intent.profileId);
|
||||
return;
|
||||
case 'open-jump-hop':
|
||||
void openJumpHopPublicWorkDetail(intent.profileId);
|
||||
return;
|
||||
case 'open-wooden-fish':
|
||||
void openWoodenFishPublicWorkDetail(intent.profileId);
|
||||
return;
|
||||
case 'open-big-fish':
|
||||
void refreshBigFishGallery()
|
||||
.then((entries) => {
|
||||
const matchedEntry = entries.find(
|
||||
(entry) => entry.sourceSessionId === intent.sessionId,
|
||||
);
|
||||
if (matchedEntry) {
|
||||
openPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail(matchedEntry),
|
||||
);
|
||||
return;
|
||||
}
|
||||
openPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail(intent.fallbackWork),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
});
|
||||
return;
|
||||
case 'open-rpg':
|
||||
void openRpgPublicWorkDetail(intent.detail);
|
||||
return;
|
||||
default: {
|
||||
const exhaustive: never = intent;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void openRpgPublicWorkDetail({
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
});
|
||||
},
|
||||
[
|
||||
openMatch3DPublicWorkDetail,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||
|
||||
function buildPlayedWork(
|
||||
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: '潮雾列岛',
|
||||
worldSubtitle: '旧灯塔与失控航路',
|
||||
firstPlayedAt: '2026-04-18T12:00:00.000Z',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
lastObservedPlayTimeMs: 12_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPlayedWorkOpenModel', () => {
|
||||
test('opens puzzle played works with profile tab context', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'PUZZLE',
|
||||
profileId: 'puzzle-profile-1',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-puzzle',
|
||||
profileId: 'puzzle-profile-1',
|
||||
tab: 'profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to worldKey prefixes when profile id is absent', () => {
|
||||
const cases = [
|
||||
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
|
||||
['match3d:profile-2', 'open-match3d', 'profile-2'],
|
||||
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
|
||||
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
|
||||
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
|
||||
] as const;
|
||||
|
||||
for (const [worldKey, type, profileId] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey,
|
||||
profileId: null,
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type, profileId });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps explicit profile id ahead of worldKey fallback', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'jump-hop:key-profile',
|
||||
profileId: 'explicit-profile',
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: 'open-jump-hop',
|
||||
profileId: 'explicit-profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('supports played work type aliases for mini-games', () => {
|
||||
const cases = [
|
||||
['match_3d', 'open-match3d'],
|
||||
['square_hole', 'open-square-hole'],
|
||||
['jump_hop', 'open-jump-hop'],
|
||||
['wooden_fish', 'open-wooden-fish'],
|
||||
] as const;
|
||||
|
||||
for (const [worldType, type] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType,
|
||||
profileId: `${worldType}-profile`,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type,
|
||||
profileId: `${worldType}-profile`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('returns noop when a mini-game target is empty', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'puzzle:key-profile',
|
||||
profileId: '',
|
||||
worldType: 'puzzle',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds big fish intent and fallback work for gallery misses', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'big-fish:big-fish-session-1',
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType: 'big_fish',
|
||||
worldTitle: '机械深海',
|
||||
worldSubtitle: '',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-big-fish',
|
||||
sessionId: 'big-fish-session-1',
|
||||
fallbackWork: {
|
||||
workId: 'big-fish:big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: '',
|
||||
authorDisplayName: '玩家',
|
||||
title: '机械深海',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('opens unknown played work types as RPG detail when identity is complete', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'CUSTOM',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-rpg',
|
||||
detail: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'custom:world-1',
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-18T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
authorDisplayName: '旧灯塔与失控航路',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('returns noop for RPG fallback when owner or profile is missing', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
ownerUserId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: '',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type PlatformPlayedWorkOpenIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
reason: 'missing-target';
|
||||
}
|
||||
| {
|
||||
type: 'open-puzzle';
|
||||
profileId: string;
|
||||
tab: 'profile';
|
||||
}
|
||||
| {
|
||||
type: 'open-match3d';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-square-hole';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-jump-hop';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-wooden-fish';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-big-fish';
|
||||
sessionId: string;
|
||||
fallbackWork: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
type: 'open-rpg';
|
||||
detail: CustomWorldGalleryCard;
|
||||
};
|
||||
|
||||
function normalizePlayedWorkWorldType(worldType: string | null) {
|
||||
return (worldType ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function resolvePlayedWorkTargetId(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
worldKeyPrefix: string,
|
||||
) {
|
||||
const prefixedWorldKey = `${worldKeyPrefix}:`;
|
||||
return (
|
||||
work.profileId ??
|
||||
(work.worldKey.startsWith(prefixedWorldKey)
|
||||
? work.worldKey.slice(prefixedWorldKey.length)
|
||||
: work.worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
|
||||
profileId: string,
|
||||
intent: (profileId: string) => TIntent,
|
||||
) {
|
||||
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
|
||||
return {
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedBigFishFallbackWork(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
sessionId: string,
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedRpgDetail(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
profileId: string,
|
||||
ownerUserId: string,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
|
||||
export function resolvePlatformPlayedWorkOpenIntent(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
): PlatformPlayedWorkOpenIntent {
|
||||
const worldType = normalizePlayedWorkWorldType(work.worldType);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-puzzle',
|
||||
profileId: resolvedProfileId,
|
||||
tab: 'profile',
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-match3d',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'square-hole' ||
|
||||
worldType === 'square_hole' ||
|
||||
work.worldKey.startsWith('square-hole:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-square-hole',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'jump-hop' ||
|
||||
worldType === 'jump_hop' ||
|
||||
work.worldKey.startsWith('jump-hop:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-jump-hop',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'wooden-fish' ||
|
||||
worldType === 'wooden_fish' ||
|
||||
work.worldKey.startsWith('wooden-fish:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-wooden-fish',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
|
||||
return sessionId
|
||||
? {
|
||||
type: 'open-big-fish',
|
||||
sessionId,
|
||||
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
|
||||
}
|
||||
: buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'open-rpg',
|
||||
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user