From a5139209d438e14b56a10f1bdbb7c4f50ff3a71a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 3 Aug 2026 20:23:02 -0700 Subject: [PATCH 1/2] feat: add Skin market moderation controls --- src/skin-market-web/src/App.tsx | 1 + src/skin-market-web/src/DetailPage.test.tsx | 163 ++++++++++++++++++ src/skin-market-web/src/DetailPage.tsx | 173 ++++++++++++++++++-- src/skin-market-web/src/api.test.ts | 26 +++ src/skin-market-web/src/api.ts | 12 ++ src/skin-market-web/src/i18n.ts | 22 +++ src/skin-market-web/src/styles.css | 123 ++++++++++++++ 7 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 src/skin-market-web/src/DetailPage.test.tsx diff --git a/src/skin-market-web/src/App.tsx b/src/skin-market-web/src/App.tsx index 097fefb0e..00c6a8f3a 100644 --- a/src/skin-market-web/src/App.tsx +++ b/src/skin-market-web/src/App.tsx @@ -220,6 +220,7 @@ export default function App() { ) : route.kind === 'detail' && route.slug ? ( ({ + detail: vi.fn(), + yankRelease: vi.fn(), + unpublishListing: vi.fn(), +})); + +vi.mock('./api', async (importOriginal) => ({ + ...await importOriginal(), + skinMarketApi: { + detail: mocks.detail, + yankRelease: mocks.yankRelease, + unpublishListing: mocks.unpublishListing, + }, +})); + +vi.mock('./PosterImage', () => ({ + PosterImage: ({ alt }: { alt: string }) =>
, +})); + +const detail: AppearanceListingDetail = { + ...listingFixture, + mode: 'dark', +}; + +const t = ((key: string) => key) as Translate; + +describe('DetailPage moderation', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + mocks.detail.mockReset().mockResolvedValue(detail); + mocks.yankRelease.mockReset().mockResolvedValue(undefined); + mocks.unpublishListing.mockReset().mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('lets an admin permanently remove a release after supplying a reason', async () => { + const onNavigate = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + const yankButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('yankRelease')); + const unpublishButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('unpublishListing')); + expect(yankButton?.disabled).toBe(true); + expect(unpublishButton).toBeDefined(); + + const reason = container.querySelector('#moderation-reason'); + await act(async () => { + if (!reason) throw new Error('moderation reason missing'); + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + valueSetter?.call(reason, 'Unsafe package content'); + reason.dispatchEvent(new Event('input', { bubbles: true })); + }); + + expect(yankButton?.disabled).toBe(false); + await act(async () => yankButton?.click()); + + await vi.waitFor(() => expect(mocks.yankRelease).toHaveBeenCalledWith( + 'appearance-release-fixture-2', + 'Unsafe package content', + )); + expect(window.confirm).toHaveBeenCalledWith('yankConfirm'); + expect(onNavigate).toHaveBeenCalledWith('/skin/'); + }); + + it('lets an admin unpublish the whole Skin listing after confirmation', async () => { + const onNavigate = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + const reason = container.querySelector('#moderation-reason'); + await act(async () => { + if (!reason) throw new Error('moderation reason missing'); + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + valueSetter?.call(reason, 'Repeated policy violations'); + reason.dispatchEvent(new Event('input', { bubbles: true })); + }); + + const unpublishButton = [...container.querySelectorAll('button')] + .find((button) => button.textContent?.includes('unpublishListing')); + expect(unpublishButton?.disabled).toBe(false); + await act(async () => unpublishButton?.click()); + + await vi.waitFor(() => expect(mocks.unpublishListing).toHaveBeenCalledWith( + 'appearance-listing-fixture-1', + 'Repeated policy violations', + )); + expect(window.confirm).toHaveBeenCalledWith('unpublishConfirm'); + expect(onNavigate).toHaveBeenCalledWith('/skin/?q=ocean'); + }); + + it('does not render moderation controls for a regular visitor', async () => { + await act(async () => { + root.render( + undefined} + slug="ocean-night" + t={t} + />, + ); + }); + await vi.waitFor(() => expect(container.textContent).toContain('Ocean Night')); + + expect(container.querySelector('#moderation-reason')).toBeNull(); + expect(container.textContent).not.toContain('yankRelease'); + expect(container.textContent).not.toContain('unpublishListing'); + }); +}); diff --git a/src/skin-market-web/src/DetailPage.tsx b/src/skin-market-web/src/DetailPage.tsx index f31fd1f28..203f4b95b 100644 --- a/src/skin-market-web/src/DetailPage.tsx +++ b/src/skin-market-web/src/DetailPage.tsx @@ -4,7 +4,9 @@ import { CheckCircle, Desktop, DownloadSimple, + Prohibit, ShieldCheck, + WarningCircle, } from '@phosphor-icons/react'; import { useEffect, useMemo, useState } from 'react'; import { downloadUrl, skinMarketApi, SkinMarketApiError } from './api'; @@ -24,6 +26,7 @@ import type { interface DetailPageProps { catalogSearch: string; + isAdmin: boolean; locale: Locale; onNavigate: (path: string) => void; slug: string; @@ -34,11 +37,14 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError'; } -export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: DetailPageProps) { +export function DetailPage({ catalogSearch, isAdmin, locale, onNavigate, slug, t }: DetailPageProps) { const [detail, setDetail] = useState(); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [retryKey, setRetryKey] = useState(0); + const [moderationReason, setModerationReason] = useState(''); + const [moderationBusy, setModerationBusy] = useState(); + const [moderationError, setModerationError] = useState(); const catalogPath = `/skin/${catalogSearch}`; useEffect(() => { @@ -107,6 +113,42 @@ export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: Detai const modeLabel = detail.mode === 'light' ? t('lightMode') : t('darkMode'); const customLicenseUrl = safeExternalUrl(detail.license.customUrl); const repositoryUrl = safeExternalUrl(detail.repositoryUrl); + const trimmedModerationReason = moderationReason.trim(); + + const yankRelease = async (release: AppearanceMarketRelease) => { + if (!trimmedModerationReason || moderationBusy) return; + if (!window.confirm(t('yankConfirm', { version: release.packageVersion }))) return; + setModerationBusy(release.releaseId); + setModerationError(undefined); + try { + await skinMarketApi.yankRelease(release.releaseId, trimmedModerationReason); + setModerationReason(''); + const remainingReleaseCount = detail.releases.filter((item) => ( + !item.yanked && item.releaseId !== release.releaseId + )).length; + if (remainingReleaseCount === 0) onNavigate(catalogPath); + else setRetryKey((value) => value + 1); + } catch (actionError) { + setModerationError(toError(actionError)); + } finally { + setModerationBusy(undefined); + } + }; + + const unpublishListing = async () => { + if (!trimmedModerationReason || moderationBusy) return; + if (!window.confirm(t('unpublishConfirm', { name: detail.name }))) return; + setModerationBusy(detail.listingId); + setModerationError(undefined); + try { + await skinMarketApi.unpublishListing(detail.listingId, trimmedModerationReason); + onNavigate(catalogPath); + } catch (actionError) { + setModerationError(toError(actionError)); + } finally { + setModerationBusy(undefined); + } + }; return (
@@ -166,7 +208,56 @@ export function DetailPage({ catalogSearch, locale, onNavigate, slug, t }: Detai

{detail.changelog || t('notDeclared')}

- + + + {isAdmin ? ( +
+
+
+