Make the new UI's grid and feed two views of the same list

The feed was its own shuffled list (museum favorites plus random skins).
Now it continues through whatever the grid shows, the museum or a search:
tapping a skin opens the feed on it with the list before and after it,
and going back lands the grid on the skin you were on. Feed URLs are
plain permalinks; the list lives in the tab (listPosition.ts), and a
permalink opened fresh is followed by the museum. /feed redirects to the
museum's first skin.

- Event logging v3: one session per tab visit spanning both views, with
  view_switch events and each feed view's list, list index and depth.
  Older events are still accepted.
- The feed loads readmes and like counts on demand.
- Keyboard paging steps from the skin actually on screen, so it can't get
  stuck after a scroll is interrupted.
- Going back to the grid animates the skin into its tile. React's
  <ViewTransition> can't animate Back with Next's router, so it's done
  by hand.
- Grid tiles and view transition snapshots render pixelated.
- Download saves the skin under its file name, not its md5.
- A feed URL for a missing skin returns a 404 status.
- The list position is kept apart from the server actions, so pages with
  only the menu bar don't each compile another copy of the server code;
  this kept the build within the server's memory.
- Declare reselect (used by legacy-client) and give knexfile.ts named
  exports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
This commit is contained in:
Jordan EldredgeandClaude Opus 5.5 committed 2026-10-15 10:05:25 -07:00
1 parent 37523b3e2e
commit 9999094dde
36 files changed
+1733 -1020

No files matched your search

@@ -127,8 +127,9 @@ export async function search_classic_skins(
.limit(first)
.offset(offset)
.whereRaw("skin_search MATCH ?", match);
if (nsfw === true) search.whereIn("skin_search.skin_md5", nsfwMd5s);
if (nsfw === false) search.whereNotIn("skin_search.skin_md5", nsfwMd5s);
if (nsfw != null) {
search[nsfw ? "whereIn" : "whereNotIn"]("skin_search.skin_md5", nsfwMd5s);
}
skins = await search;
} else if (nsfw != null) {
// Nothing to match, just the filter.
@@ -4,19 +4,22 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { returnToGrid } from "./gridReturn";
import { gridPath } from "./paths";
import { useListPosition } from "./listPosition";
import { isModifiedClick } from "./BottomMenuBar";
// Shown to the left of each skin's title in the feed.
export default function BackToGrid() {
const router = useRouter();
const position = useListPosition();
return (
<Link
href="/"
href={gridPath(position?.query ?? "")}
className="skin-back"
aria-label="Back to grid"
title="Back to grid"
onClick={(e) => {
// Let modified clicks (e.g. open in a new tab) through.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (isModifiedClick(e)) return;
e.preventDefault();
returnToGrid(router);
}}
@@ -10,8 +10,11 @@ import {
Github,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { setFeedEntry } from "./eventLogger";
import { usePathname, useRouter } from "next/navigation";
import { setNextViewVia } from "./eventLogger";
import { notePathname, returnToGrid } from "./gridReturn";
import { gridPath, skinPath } from "./paths";
import { useListPosition } from "./listPosition";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
import {
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
@@ -46,6 +49,13 @@ export default function BottomMenuBar() {
};
}, []);
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
notePathname(pathname);
}, [pathname]);
// The grid and the feed open on the skin you were last on in either.
const position = useListPosition();
const onFeed = pathname.startsWith("/skin/");
const toggleHamburger = () => {
setIsHamburgerOpen((open) => !open);
@@ -155,17 +165,32 @@ export default function BottomMenuBar() {
}}
>
<MenuButton
href="/"
href={gridPath(position?.query ?? "")}
icon={<Grid3x3 size={24} />}
label="Grid"
isActive={pathname === "/"}
onClick={(e) => {
if (!onFeed) {
setNextViewVia("menu");
} else if (!isModifiedClick(e)) {
// As the feed's back arrow does.
e.preventDefault();
returnToGrid(router, "menu");
}
}}
/>
<MenuButton
href="/feed"
href={
position == null
? "/feed"
: skinPath(position.skin.md5, position.skin.fileName)
}
icon={<Smartphone size={24} />}
label="Feed"
isActive={pathname === "/feed" || pathname.startsWith("/skin/")}
onClick={() => setFeedEntry("feed_tab")}
isActive={onFeed}
onClick={() => {
if (!onFeed) setNextViewVia("menu");
}}
/>
<MenuButton
href="/upload"
@@ -194,9 +219,14 @@ type MenuButtonProps = {
isButton?: boolean;
isActive?: boolean;
isExpanded?: boolean;
onClick?: () => void;
onClick?: (e: React.MouseEvent) => void;
};
// A click meant for the browser, e.g. to open the link in a new tab.
export function isModifiedClick(e: React.MouseEvent): boolean {
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
}
function MenuButton({
href,
icon,
@@ -2,7 +2,7 @@ import { knex } from "../../../db";
import SessionModel from "../../../data/SessionModel";
import type { LoggedEvent } from "./eventTypes";
// Server-only: stores a batch of /scroll feed events (see events/route.ts).
// Server-only: stores a batch of the new museum UI's events (see events/route.ts).
// Client code sends events through eventLogger.ts instead of importing this.
export async function storeEvents(
sessionId: string,
@@ -10,13 +10,8 @@ export async function storeEvents(
): Promise<void> {
const receivedAt = Date.now();
// The first batch from a browser creates the session (see SessionModel),
// and session_start carries the skins the page was rendered with.
const sessionStart = events.find((event) => event.type === "session_start");
await SessionModel.addSkins(
sessionId,
sessionStart?.type === "session_start" ? sessionStart.skinMd5s : []
);
// The first batch from a browser creates the session.
await SessionModel.ensure(sessionId);
await knex("user_log_events").insert(
events.map((event) => ({
@@ -7,7 +7,7 @@ import React, {
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useEffect,
useLayoutEffect,
useSyncExternalStore,
} from "react";
import Link from "next/link";
@@ -18,22 +18,19 @@ import {
SKIN_RATIO,
MOBILE_MAX_WIDTH,
} from "../../../legacy-client/src/constants";
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
import {
createEventLogger,
flushWhenHidden,
getVisitContext,
setFeedEntry,
} from "./eventLogger";
import {
algoliaSearch,
graphqlSearch,
USE_ALGOLIA,
} from "../../../legacy-client/src/algolia";
import { GridSkin } from "./gridSkin";
import { enterView, setNextViewVia, useEventLogger } from "./eventLogger";
import { getSearchDebounceMs } from "../../../legacy-client/src/searchConfig";
import { screenshotUrlFromHash } from "../../../legacy-client/src/utils";
import { skinPath } from "./paths";
import { normalizeSkinFileName } from "./skinFileName";
import { gridPath, skinPath } from "./paths";
import {
cacheSearch,
getCachedSearch,
loadMuseumRange,
loadSearch,
seedMuseum,
useMuseum,
} from "./skinList";
import { getListPosition, setListPosition } from "./listPosition";
import { corsScreenshotUrl } from "./skinTint";
import HintPill, { markHintDone } from "./HintPill";
import { EyeOff, Pointer } from "lucide-react";
@@ -42,30 +39,20 @@ import { rememberSkinNsfw, useShowNsfw } from "./nsfwConsent";
// Remembered once the visitor has tapped a skin (see HintPill).
const GRID_HINT_KEY = "grid-hint-dismissed";
const PAGE_SIZE = 50;
// Same backends as the legacy museum search. If Algolia is enabled but
// failing (e.g. out of quota) fall back to our own SQLite FTS search.
async function runSearch(query: string): Promise<{
hits: Array<{ objectID: string; fileName: string; nsfw?: any }>;
}> {
if (USE_ALGOLIA) {
try {
return await algoliaSearch(query);
} catch (error) {
console.warn("Algolia search failed, falling back to SQLite", error);
}
}
return graphqlSearch(query);
}
// Where each visit to the grid was scrolled to (its first visible skin), by
// history entry (see useHistoryEntryKey), so coming Back to it returns there
// if the feed hasn't moved you on since (see restoreIndex). An index rather
// than pixels, so it survives a change in column count. In memory only: a
// reload starts at the top.
const scrollPositions = new Map<string, number>();
type CellData = {
// Sparse: pages are written at their offset as they arrive.
skins: Array<GridSkin | undefined>;
skins: ReadonlyArray<GridSkin | undefined>;
columnCount: number;
width: number;
height: number;
onSkinClick: (skinMd5: string, position: number) => void;
onSkinClick: (skin: GridSkin, position: number) => void;
};
function Cell({
@@ -98,7 +85,7 @@ function Cell({
href={skinPath(skin.md5, skin.fileName)}
onClick={() => {
rememberSkinNsfw(skin.md5, skin.nsfw);
data.onSkinClick(skin.md5, index);
data.onSkinClick(skin, index);
}}
>
<ViewTransition name={`skin-${skin.md5}`}>
@@ -110,11 +97,6 @@ function Cell({
alt={skin.fileName}
// Opening it shows the choice to see it (see NsfwCover).
className={skin.nsfw && !showNsfw ? "nsfw-blurred" : undefined}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</ViewTransition>
</Link>
@@ -141,24 +123,29 @@ type SkinTableProps = {
initialSearchSkins: GridSkin[] | null;
};
// The window's size, or null until mounted. The grid is laid out to fit the
// window, so it isn't rendered on the server (or the first client render,
// which has to match it).
function useMountedWindowSize(): { width: number; height: number } | null {
const [size, setSize] = useState<{ width: number; height: number } | null>(
null
);
useLayoutEffect(() => {
const update = () =>
setSize({
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
});
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return size;
// The window's size, or null while hydrating. The grid is laid out to fit
// the window, so it isn't rendered on the server (or the first client render,
// which has to match it). Navigating to the grid renders it straight away, so
// a view transition into it sees its tiles.
type WindowSize = { width: number; height: number };
let windowSize: WindowSize | null = null;
function getWindowSize(): WindowSize {
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
if (windowSize?.width !== width || windowSize.height !== height) {
windowSize = { width, height };
}
return windowSize;
}
function subscribeToResize(onChange: () => void): () => void {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
}
function useMountedWindowSize(): WindowSize | null {
return useSyncExternalStore(subscribeToResize, getWindowSize, () => null);
}
export default function SkinTable({
@@ -171,44 +158,42 @@ export default function SkinTable({
const windowWidth = windowSize?.width ?? 0;
const windowHeight = windowSize?.height ?? 0;
// Each grid visit is its own event-logging session. A feed session reached
// from here records this ID, so a search and the skins opened from it can be
// joined up.
const [gridSessionId] = useState(() => crypto.randomUUID());
const [logger] = useState(() => createEventLogger(gridSessionId));
const logger = useEventLogger();
// Search input state - separate input value from actual search query
// From the live URL rather than only the server's render: coming Back to a
// The search box's text. From the live URL rather than only the server's render: coming Back to a
// search, the page can be the one first rendered without it.
const searchParams = useSearchParams();
const [inputValue, setInputValue] = useState(
() => searchParams.get("query") ?? initialQuery
);
// The server's results, if they're for the search we're starting with.
const [serverResults] = useState(() =>
initialSearchSkins != null && inputValue.trim() === initialQuery.trim()
? { query: initialQuery.trim(), skins: initialSearchSkins }
: null
);
// Results for the search we're starting with, if we have them: the
// server's, or ones from earlier in this visit.
const [initialResults] = useState(() => {
const query = inputValue.trim();
const skins =
initialSearchSkins != null && query === initialQuery.trim()
? initialSearchSkins
: getCachedSearch(query);
if (query === "" || skins == null) return null;
// Not a new search if we're coming back to it.
const isNew = getCachedSearch(query) == null;
cacheSearch(query, skins);
return { query, skins, isNew };
});
// State for browsing mode. A sparse array indexed by position in the museum,
// so pages land in the right place even if they load out of order.
const [browseSkins, setBrowseSkins] =
useState<Array<GridSkin | undefined>>(initialSkins);
const loadedPagesRef = useRef<Set<number>>(
new Set(initialSkins.length > 0 ? [0] : [])
);
const inFlightPagesRef = useRef<Set<number>>(new Set());
// Browsing: the museum, as far as it's loaded (see skinList.ts).
useState(() => seedMuseum(0, initialSkins, initialTotal));
const browseSkins = useMuseum().skins;
// State for search mode
const [searchSkins, setSearchSkins] = useState<GridSkin[]>(
serverResults?.skins ?? []
initialResults?.skins ?? []
);
const [searchError, setSearchError] = useState<string | null>(null);
// The query that `searchSkins`/`searchError` are the outcome of. Until it
// matches the input, a search is pending (debouncing or in flight).
const [resultsQuery, setResultsQuery] = useState<string | null>(
serverResults?.query ?? null
initialResults?.query ?? null
);
const resultsQueryRef = useRef(resultsQuery);
resultsQueryRef.current = resultsQuery;
@@ -222,19 +207,17 @@ export default function SkinTable({
const showSearchResults = isSearchMode && resultsQuery !== null;
const skins = showSearchResults ? searchSkins : browseSkins;
const total = showSearchResults ? searchSkins.length : initialTotal;
// Handle search input change
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
// The list on screen.
const listQuery = showSearchResults ? resultsQuery! : "";
useEffect(() => {
const query = inputValue.trim();
const newUrl =
query === "" ? "/" : `/?${new URLSearchParams({ query: inputValue })}`;
replaceHistoryEntry({}, newUrl);
const urlQuery = new URLSearchParams(location.search).get("query") ?? "";
if (urlQuery !== inputValue) {
replaceHistoryEntry({}, query === "" ? "/" : gridPath(inputValue));
}
// Already showing this search's results (e.g. the server's).
// Already showing this search's results (the server's, or cached).
if (query.length > 0 && query === resultsQueryRef.current) {
return;
}
@@ -251,24 +234,12 @@ export default function SkinTable({
let cancelled = false;
const timer = setTimeout(async () => {
try {
const result = await runSearch(query);
const skins = await loadSearch(query);
if (cancelled) return;
setSearchSkins(
result.hits.map((hit) => ({
md5: hit.objectID,
screenshotUrl: screenshotUrlFromHash(hit.objectID),
fileName: normalizeSkinFileName(hit.fileName),
// Some records still have float scores rather than booleans.
nsfw: hit.nsfw === true || hit.nsfw === 1,
}))
);
setSearchSkins(skins);
setSearchError(null);
setResultsQuery(query);
logger.log({
type: "grid_search",
query,
resultCount: result.hits.length,
});
logger.log({ type: "grid_search", query, resultCount: skins.length });
} catch (err) {
if (cancelled) return;
console.error("Search failed:", err);
@@ -291,48 +262,13 @@ export default function SkinTable({
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
const loadPage = useCallback(async (pageNumber: number) => {
if (
loadedPagesRef.current.has(pageNumber) ||
inFlightPagesRef.current.has(pageNumber)
) {
return;
}
inFlightPagesRef.current.add(pageNumber);
try {
const offset = pageNumber * PAGE_SIZE;
const newSkins = await getMuseumPageSkins(offset, PAGE_SIZE);
setBrowseSkins((prev) => {
const next = prev.slice();
newSkins.forEach((skin, i) => {
next[offset + i] = skin;
});
return next;
});
loadedPagesRef.current.add(pageNumber);
} catch (error) {
// Leave the page unloaded so it's retried next time it's rendered.
console.error("Failed to load skins:", error);
} finally {
inFlightPagesRef.current.delete(pageNumber);
}
}, []);
// The rows the grid last rendered (including overscan).
const renderedRowsRef = useRef<[number, number]>([0, 0]);
const loadPagesForRows = useCallback(
(firstRow: number, lastRow: number) => {
const firstIndex = firstRow * columnCount;
const lastIndex = (lastRow + 1) * columnCount - 1;
for (
let page = Math.floor(firstIndex / PAGE_SIZE);
page <= Math.floor(lastIndex / PAGE_SIZE);
page++
) {
loadPage(page);
}
loadMuseumRange(firstRow * columnCount, (lastRow + 1) * columnCount - 1);
},
[columnCount, loadPage]
[columnCount]
);
const onItemsRendered = useCallback(
@@ -369,83 +305,72 @@ export default function SkinTable({
[columnCount, skins]
);
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
// Coming back to this grid (e.g. with the feed's back arrow) returns to the
// skin you were looking at. The first visible skin is remembered per history
// entry, as an index so it survives a change in column count.
// Coming back to the grid (e.g. with the feed's back arrow) returns to the
// skin you were on: the one in view in the feed, if you were in the feed
// showing this list, otherwise where this visit to the grid was scrolled
// to. The grid is first rendered already scrolled there, so the skin you
// came back from is on screen for the view transition to land on.
const scrollKey = useHistoryEntryKey();
const restoreIndexRef = useRef<number | null>(null);
useEffect(() => {
if (scrollKey == null) return;
try {
const saved = sessionStorage.getItem(`grid-scroll:${scrollKey}`);
if (saved != null) restoreIndexRef.current = Number(saved);
} catch {
// Start at the top.
const [restoreIndex] = useState(() => {
if (typeof window === "undefined") return undefined;
const position = getListPosition();
const startQuery = initialResults?.query ?? "";
if (position != null && position.query === startQuery) {
return position.index;
}
}, [scrollKey]);
return scrollPositions.get(window.history.state?.gridEntryKey);
});
const onScroll = useMemo(() => {
const half = Math.round(columnCount / 2);
let saveTimer: ReturnType<typeof setTimeout> | undefined;
return (scrollData: { scrollTop: number }) => {
const firstRow = Math.round(scrollData.scrollTop / rowHeight);
itemRef.current = firstRow * columnCount + half;
if (scrollKey == null || restoreIndexRef.current != null) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
sessionStorage.setItem(
`grid-scroll:${scrollKey}`,
String(firstRow * columnCount)
);
} catch {
// Not remembered; fine.
}
}, 150);
};
}, [columnCount, rowHeight, scrollKey]);
const initialScrollTop =
restoreIndex == null
? 0
: Math.floor(restoreIndex / columnCount) * rowHeight;
// Restore once the skins we were scrolled to are what the grid shows: the
// search's results, or the browse grid (whose pages load as they're
// scrolled to).
const showingRestoredContent = isSearchMode
? showSearchResults && !searchPending
: true;
const onScroll = useCallback(
({
scrollTop,
scrollUpdateWasRequested,
}: {
scrollTop: number;
scrollUpdateWasRequested: boolean;
}) => {
if (rowHeight === 0) return;
const index = Math.round(scrollTop / rowHeight) * columnCount;
if (scrollKey != null) scrollPositions.set(scrollKey, index);
// Only the visitor's own scrolling moves them on from the skin they
// came back to (not where the grid starts, or scrolls itself to).
if (scrollUpdateWasRequested || scrollTop === initialScrollTop) return;
const skin = skins[index];
if (skin != null) setListPosition({ query: listQuery, index, skin });
},
[columnCount, rowHeight, scrollKey, skins, listQuery, initialScrollTop]
);
// Strict Mode runs this effect twice in development; log one arrival.
const enteredRef = useRef(false);
useEffect(() => {
const index = restoreIndexRef.current;
if (index == null || windowSize == null || !showingRestoredContent) return;
restoreIndexRef.current = null;
gridRef.current?.scrollToItem({
rowIndex: Math.floor(index / columnCount),
align: "start",
});
}, [showingRestoredContent, windowSize, columnCount, scrollKey]);
useEffect(() => {
logger.log({
type: "grid_open",
if (enteredRef.current) return;
enteredRef.current = true;
const position = getListPosition();
const cameBackTo =
restoreIndex != null && position?.index === restoreIndex
? position
: null;
enterView("grid", {
query: inputValue.trim() || null,
...getVisitContext(),
skinMd5: cameBackTo?.skin.md5 ?? null,
listIndex: cameBackTo?.index ?? null,
});
if (serverResults != null) {
if (initialResults?.isNew) {
logger.log({
type: "grid_search",
query: serverResults.query,
resultCount: serverResults.skins.length,
query: initialResults.query,
resultCount: initialResults.skins.length,
});
}
return flushWhenHidden(logger);
// Only on mount: the search the grid was opened with.
}, [logger]);
// The search the visible results came from, for click events. A ref so
// itemData (and every cell) doesn't change when it does.
const activeQueryRef = useRef<string | null>(null);
activeQueryRef.current = showSearchResults ? resultsQuery : null;
// Keyboard shortcuts to jump to the search box: "/" (when not already typing
// somewhere) and Cmd/Ctrl+K (from anywhere).
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -473,17 +398,19 @@ export default function SkinTable({
}, []);
const onSkinClick = useCallback(
(skinMd5: string, position: number) => {
(skin: GridSkin, position: number) => {
logger.log({
type: "grid_skin_click",
skinMd5,
skinMd5: skin.md5,
position,
query: activeQueryRef.current,
query: listQuery || null,
});
setFeedEntry("grid", gridSessionId);
// The feed continues through this list from here.
setListPosition({ query: listQuery, index: position, skin });
setNextViewVia("grid");
markHintDone(GRID_HINT_KEY);
},
[logger, gridSessionId]
[logger, listQuery]
);
const itemData: CellData = useMemo(
@@ -508,25 +435,16 @@ export default function SkinTable({
>
Tap a skin to try it in Webamp
</HintPill>
{/* Floating Search Bar */}
<div
style={{
position: "fixed",
bottom: "4.25rem",
left: "50%",
transform: "translateX(-50%)",
width: "calc(100% - 2rem)",
maxWidth: MOBILE_MAX_WIDTH,
padding: "0 1rem",
zIndex: 998,
}}
>
<div className="grid-search" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
<div style={{ position: "relative" }}>
<input
className={
searchPending ? "grid-search-input pending" : "grid-search-input"
}
type="search"
enterKeyHint="search"
value={inputValue}
onChange={handleSearchChange}
onChange={(e) => setInputValue(e.target.value)}
ref={searchInputRef}
onKeyDown={(e) => {
// Search runs as you type; Enter just dismisses the keyboard,
@@ -540,30 +458,6 @@ export default function SkinTable({
aria-keyshortcuts="/ Meta+K Control+K"
title="Search skins (/ or ⌘K)"
aria-busy={searchPending}
style={{
width: "100%",
padding: "0.75rem 1rem",
// Make room for the pending spinner.
paddingRight: searchPending ? "2.75rem" : "1rem",
fontSize: "1rem",
backgroundColor: "rgba(26, 26, 26, 0.55)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "9999px",
color: "#fff",
outline: "none",
fontFamily: "inherit",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
transition: "padding-right 0.2s ease",
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
/>
{searchPending && (
<div
@@ -575,32 +469,11 @@ export default function SkinTable({
</div>
</div>
{/* Error State */}
{showSearchResults && searchError && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ff6b6b",
}}
>
{searchError}
</div>
<div className="grid-message grid-message-error">{searchError}</div>
)}
{/* Empty Results */}
{showSearchResults && !searchError && skins.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ccc",
}}
>
<div className="grid-message">
No results found for &quot;{resultsQuery}&quot;
</div>
)}
@@ -609,7 +482,6 @@ export default function SkinTable({
{windowSize != null &&
(!showSearchResults || (!searchError && skins.length > 0)) && (
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
@@ -620,6 +492,7 @@ export default function SkinTable({
width={windowWidth}
overscanRowCount={5}
onScroll={onScroll}
initialScrollTop={initialScrollTop}
onItemsRendered={onItemsRendered}
style={{ overflowY: "scroll" }}
>
@@ -646,21 +519,28 @@ function useHistoryEntryKey(): string | null {
}
/**
* Update the current history entry's state (keeping what we've stored there)
* and optionally its URL. Next.js's own keys are left out: given those, Next
* assumes the update is its own and doesn't learn the new URL, then puts its
* old one back on the entry, so Back would lose the search.
* Update the current history entry's state (keeping our key), and its URL if
* given.
*
* With a new URL, only our own state is passed: Next.js adds its own back, and
* given its keys it would assume the update was its own and not learn the new
* URL, then put its old one back on the entry (so Back would lose the search).
* Without one, Next's keys are kept: there's nothing for it to learn, and this
* can run before Next is listening (child effects run first), when it
* couldn't add them back.
*/
function replaceHistoryEntry(
state: Record<string, unknown>,
state: { gridEntryKey?: string },
url?: string
): void {
const {
__NA,
__PRIVATE_NEXTJS_INTERNALS_TREE,
as: _as,
url: _url,
...ours
} = window.history.state ?? {};
window.history.replaceState({ ...ours, ...state }, "", url);
const current = window.history.state;
if (url == null) {
window.history.replaceState({ ...current, ...state }, "");
} else {
window.history.replaceState(
{ gridEntryKey: current?.gridEntryKey, ...state },
"",
url
);
}
}
@@ -10,7 +10,7 @@ import {
} from "react";
import { FixedSizeGrid as Grid } from "react-window";
import { useRouter } from "next/navigation";
import { ClientSkin } from "./SkinScroller";
import { ClientSkin } from "./gridSkin";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, ReactNode } from "react";
import { Heart, Share2, Flag, Download, Check } from "lucide-react";
import { ClientSkin } from "./SkinScroller";
import { ClientSkin } from "./gridSkin";
import { useEventLogger } from "./eventLogger";
import { fetchGraphql } from "./graphql";
@@ -70,17 +70,13 @@ type LikeButtonProps = {
function LikeButton({ skin }: LikeButtonProps) {
const logger = useEventLogger();
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(skin.likeCount);
// Counting our like (the count can arrive after the skin, see skinList.ts).
const likeCount = skin.likeCount + (isLiked ? 1 : 0);
const handleLike = async () => {
const newLikedState = !isLiked;
setIsLiked(newLikedState);
// Optimistically update the like count
setLikeCount((prevCount) =>
newLikedState ? prevCount + 1 : prevCount - 1
);
logger.log({
type: "skin_like",
skinMd5: skin.md5,
@@ -255,6 +251,32 @@ function FlagButton({ skin }: FlagButtonProps) {
);
}
// Browsers ignore a link's `download` file name for files from another origin
// (the skins' storage), so the skin would be saved under its md5. They respect
// it for an object URL, so fetch the file (already cached, as Webamp loaded
// it) and save that. The same trick as the classic UI's DownloadLink.
async function downloadAs(url: string, fileName: string): Promise<void> {
let objectUrl: string;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
objectUrl = URL.createObjectURL(await response.blob());
} catch (error) {
console.error("Failed to fetch the skin to download:", error);
// Saved under its md5, but saved.
window.location.href = url;
return;
}
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
// Long enough for the browser to start saving it.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
type DownloadButtonProps = {
skin: ClientSkin;
};
@@ -267,8 +289,7 @@ function DownloadButton({ skin }: DownloadButtonProps) {
skinMd5: skin.md5,
});
// Trigger download
window.location.href = skin.downloadUrl;
await downloadAs(skin.downloadUrl, skin.fileName);
};
return (
@@ -5,18 +5,17 @@ import {
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
} from "react";
import { ClientSkin } from "./SkinScroller";
import { ClientSkin } from "./gridSkin";
import SkinActionIcons from "./SkinActionIcons";
import SkinReadme from "./SkinReadme";
import BackToGrid from "./BackToGrid";
import { corsScreenshotUrl } from "./skinTint";
import SkinScreenshot from "./SkinScreenshot";
import { useShowNsfw } from "./nsfwConsent";
import NsfwCover from "./NsfwCover";
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
type Props = {
skin: ClientSkin;
index: number;
// Whether this is the skin currently scrolled into view.
focused: boolean;
// Receives the element the shared Webamp instance is moved into, which only
@@ -26,18 +25,13 @@ type Props = {
// Memoized: the feed keeps every page it has loaded mounted, and only the
// pages gaining or losing focus need to re-render as you scroll.
export default memo(function SkinPage({
skin,
index,
focused,
webampSlotRef,
}: Props) {
export default memo(function SkinPage({ skin, focused, webampSlotRef }: Props) {
// An NSFW skin stays blurred, and Webamp isn't loaded over it, until the
// visitor chooses to see NSFW skins.
const showNsfw = useShowNsfw();
const hidden = skin.nsfw && !showNsfw;
return (
<div data-skin-index={index} className="scroller skin-page">
<div data-skin-md5={skin.md5} className="skin-page">
<div className="skin-column" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
<div className="skin-title-row">
<BackToGrid />
@@ -49,14 +43,10 @@ export default memo(function SkinPage({
<div className="skin-frame">
<div style={{ position: "relative" }}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
className={
hidden ? "skin-screenshot nsfw-blurred" : "skin-screenshot"
}
// So its colors can be read for the backdrop tint.
crossOrigin="anonymous"
src={corsScreenshotUrl(skin.screenshotUrl)}
<SkinScreenshot
md5={skin.md5}
alt={skin.fileName}
blurred={hidden}
/>
{focused && !hidden && (
<div
@@ -2,7 +2,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { ClientSkin } from "./SkinScroller";
import { ClientSkin } from "./gridSkin";
import { useEventLogger } from "./eventLogger";
type Props = {
@@ -221,7 +221,8 @@ export default function SkinReadme({ skin, focused }: Props) {
}
>
<div ref={contentRef} className="skin-readme-content">
{skin.readme !== "" && (
{/* Nothing while it loads (null). */}
{skin.readme != null && skin.readme !== "" && (
<div ref={textRef} className="skin-readme-text">
{/*
Floated into the text's top-right corner (and stuck there while
@@ -0,0 +1,34 @@
import { screenshotUrlFromHash } from "../../../legacy-client/src/utils";
import { corsScreenshotUrl } from "./skinTint";
type Props = {
md5: string;
alt: string;
// Flagged NSFW and not yet chosen to be seen.
blurred: boolean;
// Takes its space without showing anything.
invisible?: boolean;
};
/**
* A skin's screenshot in the feed, and on the loading screen before it, which
* must match so the page replaces it in place.
*/
export default function SkinScreenshot({
md5,
alt,
blurred,
invisible = false,
}: Props) {
return (
<img
className={blurred ? "skin-screenshot nsfw-blurred" : "skin-screenshot"}
style={invisible ? { visibility: "hidden" } : undefined}
// So its colors can be read for the backdrop tint (and the same URL and
// request mode everywhere, so the browser caches one copy).
crossOrigin="anonymous"
src={corsScreenshotUrl(screenshotUrlFromHash(md5))}
alt={alt}
/>
);
}
@@ -1,58 +1,82 @@
"use client";
import { useState, useLayoutEffect, useEffect, useRef } from "react";
import { useState, useLayoutEffect, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import SkinPage from "./SkinPage";
import { FeedNavButtons, ScrollHintPill } from "./FeedNavigation";
import { useSharedWebamp } from "./Webamp";
import { showScreenshotInsteadOfWebamp, useSharedWebamp } from "./Webamp";
import { skinPath } from "./paths";
import { returnToGrid, setCameFromGrid } from "./gridReturn";
import { returnToGrid } from "./gridReturn";
import { backdropColors, corsScreenshotUrl, useSkinTint } from "./skinTint";
import {
TINT_INTENSITY,
backdropColors,
corsScreenshotUrl,
useSkinTint,
} from "./skinTint";
import {
createEventLogger,
EventLoggerContext,
getVisitContext,
takeFeedEntry,
enterView,
onSessionEnd,
onSessionResume,
setFeedSkin,
useEventLogger,
} from "./eventLogger";
import type { NavMethod, SkinSource } from "./eventTypes";
export type ClientSkin = {
screenshotUrl: string;
skinUrl: string;
fileName: string;
md5: string;
// Where it came from, for logging which kinds of skins hold attention.
source: SkinSource;
readme: string;
downloadUrl: string;
shareUrl: string;
nsfw: boolean;
likeCount: number;
};
import type { NavMethod } from "./eventTypes";
import { ClientSkin, GridSkin, SkinDetails, toClientSkin } from "./gridSkin";
import {
getDetails,
loadDetails,
loadMuseumRange,
loadSearch,
seedDetails,
useDetailsVersion,
useMuseum,
useSearch,
} from "./skinList";
import { followingList, setListPosition } from "./listPosition";
type Props = {
initialSkins: ClientSkin[];
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
// The route was for a specific skin (the first one), e.g. a shared link.
landedOnSkin?: boolean;
// The skin the page is for: the one the feed opens on.
skin: GridSkin;
details: SkinDetails;
};
// After a failed fetch, wait this long before trying again, doubling on each
// consecutive failure.
const FETCH_RETRY_BASE_MS = 2000;
const FETCH_RETRY_MAX_MS = 60000;
// A skin in the feed, and its place in the list (null for the skin the feed
// opened on, when it isn't in the list).
type FeedItem = { skin: GridSkin; listIndex: number | null };
// Show this many more of the list once the skin in view is within
// EXTEND_WITHIN of the last (or first) one shown.
const SHOW_MORE = 10;
const EXTEND_WITHIN = 4;
// Skins are added above the one in view (which moves everything) only once
// scrolling has stopped for this long, or this long after the feed opens (so
// its transition from the grid has finished).
const SCROLL_IDLE_MS = 150;
const OPEN_SETTLE_MS = 600;
function feedPages(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>("[data-skin-md5]"));
}
// The page nearest the top of the feed.
function pageInView(container: HTMLElement): HTMLElement | null {
const top = container.getBoundingClientRect().top;
let nearest: HTMLElement | null = null;
let nearestDistance = Infinity;
for (const page of feedPages(container)) {
const distance = Math.abs(page.getBoundingClientRect().top - top);
if (distance < nearestDistance) {
nearest = page;
nearestDistance = distance;
}
}
return nearest;
}
// Load readmes and like counts for this many skins either side of the one in
// view.
const DETAILS_BEHIND = 2;
const DETAILS_AHEAD = 5;
// The skin currently in view, for timing how long it holds attention.
type OpenView = {
skinMd5: string;
position: number;
source: SkinSource;
listIndex: number | null;
depth: number;
// performance.now() when it came into view.
startedAt: number;
// Time the page was visible while it was in view, not counting the current
@@ -68,62 +92,148 @@ type OpenView = {
// if it comes within this long; anything else is the visitor scrolling.
const NAV_ATTRIBUTION_MS = 1500;
/**
* The feed: one skin at a time, full screen, scrolling through a list (see
* skinList.ts). Opened from the grid it shows the grid's list from the skin
* you tapped, in both directions; opened on a link it shows that skin
* followed by the museum.
*/
export default function SkinScroller({
initialSkins,
getSkins,
sessionId,
landedOnSkin = false,
skin: linkedSkin,
details: linkedDetails,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false);
// Set once the server has no more skins for this session.
const [exhausted, setExhausted] = useState(false);
const failureCountRef = useRef(0);
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [webampSlot, setWebampSlot] = useState<HTMLDivElement | null>(null);
const [hasEverScrolled, setHasEverScrolled] = useState(false);
const [logger] = useState(() => createEventLogger(sessionId));
const logger = useEventLogger();
const router = useRouter();
const visibleSkin = skins[visibleSkinIndex];
// Which list this visit to the feed shows, and where the skin it opened on
// is in it (null: shown first, followed by the list from the top).
const [{ query, index: anchor }] = useState(() => {
seedDetails(linkedSkin.md5, linkedDetails);
return typeof window === "undefined"
? { query: "", index: null }
: followingList(linkedSkin.md5);
});
const museum = useMuseum();
const search = useSearch(query);
const list = query === "" ? museum.skins : search;
// How long the list is, once known.
const listLength = query === "" ? museum.total : search?.length ?? null;
useEffect(() => {
// Normally already loaded by the grid we came from.
if (query !== "") {
loadSearch(query).catch((error) => console.error(error));
}
}, [query]);
// The part of the list shown, from `first` to `last` (inclusive). It grows
// in both directions as you get near either end.
const [shownBefore, setShownBefore] = useState(0);
const [shownAfter, setShownAfter] = useState(SHOW_MORE);
const first = anchor == null ? 0 : Math.max(0, anchor - shownBefore);
const last = (anchor ?? -1) + shownAfter;
useEffect(() => {
if (query === "") loadMuseumRange(first, last);
}, [query, first, last]);
const items = useMemo(() => {
const at = (i: number): GridSkin | undefined =>
i === anchor ? linkedSkin : list?.[i];
// Only the run of loaded skins around the anchor, so there are no gaps.
const shown: FeedItem[] = [];
for (let i = (anchor ?? 0) - 1; i >= first && at(i) != null; i--) {
shown.unshift({ skin: at(i)!, listIndex: i });
}
if (anchor == null) shown.push({ skin: linkedSkin, listIndex: null });
const end = listLength == null ? last : Math.min(last, listLength - 1);
for (let i = anchor ?? 0; i <= end && at(i) != null; i++) {
shown.push({ skin: at(i)!, listIndex: i });
}
// The skin a feed opened on can also be in the list after it; show each
// skin once.
const seen = new Set<string>();
return shown.filter(
(item) => !seen.has(item.skin.md5) && seen.add(item.skin.md5)
);
}, [anchor, linkedSkin, list, listLength, first, last]);
const lastShown = items.at(-1)?.listIndex ?? -1;
const listEnded =
query !== "" && listLength != null && lastShown === listLength - 1;
const [visibleMd5, setVisibleMd5] = useState(linkedSkin.md5);
const visiblePos = Math.max(
0,
items.findIndex((item) => item.skin.md5 === visibleMd5)
);
const visibleItem = items[visiblePos];
// Each skin with its details (once loaded), reused until they change so
// only the pages that change re-render.
const detailsVersion = useDetailsVersion();
const clientSkinsRef = useRef(
new Map<
string,
{ skin: GridSkin; details: SkinDetails | undefined; client: ClientSkin }
>()
);
const clientSkins = useMemo(
() =>
items.map(({ skin }) => {
const details =
getDetails(skin.md5) ??
(skin.md5 === linkedSkin.md5 ? linkedDetails : undefined);
const cached = clientSkinsRef.current.get(skin.md5);
if (cached?.skin === skin && cached.details === details) {
return cached.client;
}
const client = toClientSkin(skin, details);
clientSkinsRef.current.set(skin.md5, { skin, details, client });
return client;
}),
[items, detailsVersion, linkedSkin, linkedDetails]
);
const visibleSkin = clientSkins[visiblePos];
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [webampSlot, setWebampSlot] = useState<HTMLDivElement | null>(null);
// The backdrop is tinted with the visible skin's colors, and morphs to the
// next skin's as you scroll. Until a skin's tint is known it keeps the
// previous one.
const tint = useSkinTint(corsScreenshotUrl(visibleSkin.screenshotUrl));
const backdrop = tint == null ? null : backdropColors(tint, TINT_INTENSITY);
const backdrop = tint == null ? null : backdropColors(tint);
// For event handlers that outlive a render.
const visibleRef = useRef({ skin: visibleSkin, index: visibleSkinIndex });
visibleRef.current = { skin: visibleSkin, index: visibleSkinIndex };
const visibleRef = useRef(visibleItem);
visibleRef.current = visibleItem;
// View timing. Views are opened and closed imperatively (rather than in
// effect cleanups) because how a view ends matters: moving on to another
// skin, or the session ending on it.
// skin, leaving the feed, or the session ending on it.
const openViewRef = useRef<OpenView | null>(null);
const hasOpenedViewRef = useRef(false);
const depthRef = useRef(0);
function openView(skin: ClientSkin, position: number, via: NavMethod) {
function openView(item: FeedItem, via: NavMethod) {
const now = performance.now();
openViewRef.current = {
skinMd5: skin.md5,
position,
source: skin.source,
const view: OpenView = {
skinMd5: item.skin.md5,
listIndex: item.listIndex,
depth: depthRef.current++,
startedAt: now,
activeMs: 0,
activeSince: document.visibilityState === "visible" ? now : null,
webampInteractions: 0,
};
openViewRef.current = view;
logger.log({
type: "skin_view_start",
skinMd5: skin.md5,
position,
source: skin.source,
skinMd5: view.skinMd5,
query: query || null,
listIndex: view.listIndex,
depth: view.depth,
via,
});
}
function closeView(exit: boolean) {
function closeView(endedBy: "next_skin" | "left_feed" | "session_end") {
const view = openViewRef.current;
if (view == null) return;
openViewRef.current = null;
@@ -133,11 +243,12 @@ export default function SkinScroller({
logger.log({
type: "skin_view_end",
skinMd5: view.skinMd5,
position: view.position,
source: view.source,
query: query || null,
listIndex: view.listIndex,
depth: view.depth,
durationMs: Math.round(now - view.startedAt),
activeMs: Math.round(activeMs),
exit,
endedBy,
webampInteractions: view.webampInteractions,
});
}
@@ -177,24 +288,53 @@ export default function SkinScroller({
}
);
// Track if user has ever scrolled to another skin
// Leaving with the browser's Back (or Forward) button: as with the back
// arrow, have the screenshot rather than Webamp make the transition. This
// has to happen before Next.js renders the grid, which it does before any
// popstate listener runs, so this uses the Navigation API's navigate event,
// which comes first. (Without it, Back crossfades instead.)
useEffect(() => {
if (visibleSkinIndex > 0) {
setHasEverScrolled(true);
const navigation = (window as { navigation?: EventTarget }).navigation;
if (navigation == null) return;
function onNavigate(e: Event) {
if (
(e as Event & { navigationType: string }).navigationType === "traverse"
) {
showScreenshotInsteadOfWebamp();
}
}
}, [visibleSkinIndex]);
navigation.addEventListener("navigate", onNavigate);
return () => navigation.removeEventListener("navigate", onNavigate);
}, []);
function scrollToSkin(index: number, via: NavMethod) {
// The skin we're scrolling to, until scrolling stops, so pressing again
// mid-scroll goes one further.
const scrollTargetRef = useRef<string | null>(null);
// Scroll `delta` skins from the one in view. Counted from the pages where
// they are, not visibleMd5: that's only updated as pages cross the middle,
// so it can be off by one after an interrupted scroll (e.g. by skins being
// added below), and moving to the skin already in view would then never
// scroll to correct it.
function scrollBySkins(delta: number, via: NavMethod) {
if (containerRef == null) return;
const pages = feedPages(containerRef);
let from = pages.findIndex(
(page) => page.dataset.skinMd5 === scrollTargetRef.current
);
if (from === -1) from = pages.indexOf(pageInView(containerRef)!);
const target = pages[from + delta];
if (target == null) return;
scrollTargetRef.current = target.dataset.skinMd5!;
pendingNavRef.current = { via, at: performance.now() };
containerRef
?.querySelector(`[data-skin-index="${index}"]`)
?.scrollIntoView({ behavior: "smooth" });
target.scrollIntoView({ behavior: "smooth" });
}
// Keyboard navigation: J / Down / Space for the next skin, K / Up /
// Shift+Space for the previous one, Escape back to the grid. Webamp's hotkeys use Up/Down for volume;
// the feed takes them instead (listening on window in the capture phase so we
// see them first, and stopping them there). Left/Right still seek in Webamp.
// Shift+Space for the previous one, Escape back to the grid. Webamp's
// hotkeys use Up/Down for volume; the feed takes them instead (listening on
// window in the capture phase so we see them first, and stopping them
// there). Left/Right still seek in Webamp.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
@@ -225,13 +365,12 @@ export default function SkinScroller({
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.stopPropagation();
}
const index = visibleSkinIndex + delta;
if (index >= 0 && index < skins.length) scrollToSkin(index, "key");
scrollBySkins(delta, "key");
}
window.addEventListener("keydown", onKeyDown, { capture: true });
return () =>
window.removeEventListener("keydown", onKeyDown, { capture: true });
}, [visibleSkinIndex, skins.length, containerRef]);
}, [containerRef]);
useLayoutEffect(() => {
if (containerRef == null) {
@@ -244,11 +383,8 @@ export default function SkinScroller({
entries.forEach((entry) => {
// When an element becomes mostly visible (> 50% intersecting)
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const index = parseInt(
(entry.target as HTMLElement).dataset.skinIndex || "0",
10
);
setVisibleSkinIndex(index);
const md5 = (entry.target as HTMLElement).dataset.skinMd5;
if (md5 != null) setVisibleMd5(md5);
}
});
},
@@ -258,173 +394,169 @@ export default function SkinScroller({
}
);
// Observe all skin page elements
const skinElements = containerRef.querySelectorAll("[data-skin-index]");
skinElements.forEach((element) => observer.observe(element));
containerRef
.querySelectorAll("[data-skin-md5]")
.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
};
}, [containerRef, skins.length]);
}, [containerRef, items]);
// Session lifecycle. Declared before the view effect below so session_start
// is the session's first event.
// Taken once per feed, not per run of the effect below (which runs twice in
// development's Strict Mode, and would find it already taken).
const feedEntryRef = useRef<ReturnType<typeof takeFeedEntry> | undefined>(
undefined
);
// Keep the skin in view where it is when skins are added above it. They're
// only added while the feed is at rest on a skin, so that's where it goes.
// (Set outright, rather than moved by what was added: some browsers already
// re-snap to the same skin themselves.)
const shownAboveRef = useRef({ md5: visibleMd5, count: visiblePos });
useLayoutEffect(() => {
const previous = shownAboveRef.current;
shownAboveRef.current = { md5: visibleMd5, count: visiblePos };
if (
containerRef == null ||
previous.md5 !== visibleMd5 ||
previous.count === visiblePos
) {
return;
}
const page = containerRef.querySelector<HTMLElement>(
`[data-skin-md5="${visibleMd5}"]`
);
if (page != null) {
containerRef.scrollTop +=
page.getBoundingClientRect().top -
containerRef.getBoundingClientRect().top;
}
});
// Once scrolling stops: make sure visibleMd5 is the skin actually in view
// (the observer can miss one), and near the top of what's shown, show more
// of the list above.
const onScrollIdleRef = useRef(() => {});
onScrollIdleRef.current = () => {
scrollTargetRef.current = null;
const md5 = containerRef && pageInView(containerRef)?.dataset.skinMd5;
if (md5 != null) setVisibleMd5(md5);
if (first > 0 && visiblePos < EXTEND_WITHIN) {
setShownBefore((n) => n + SHOW_MORE);
}
};
useEffect(() => {
if (feedEntryRef.current === undefined) {
feedEntryRef.current = takeFeedEntry();
if (containerRef == null) return;
let timer = setTimeout(() => onScrollIdleRef.current(), OPEN_SETTLE_MS);
function onScroll() {
clearTimeout(timer);
timer = setTimeout(() => onScrollIdleRef.current(), SCROLL_IDLE_MS);
}
const pendingEntry = feedEntryRef.current;
setCameFromGrid(pendingEntry?.entry === "grid");
logger.log({
type: "session_start",
entry: pendingEntry?.entry ?? (landedOnSkin ? "link" : "direct"),
fromSessionId: pendingEntry?.fromSessionId ?? null,
...getVisitContext(),
skinMd5s: initialSkins.map((skin) => skin.md5),
containerRef.addEventListener("scroll", onScroll, { passive: true });
return () => {
clearTimeout(timer);
containerRef.removeEventListener("scroll", onScroll);
};
}, [containerRef]);
// Near the bottom of what's shown, show more of the list below (once the
// ones already asked for have loaded).
useEffect(() => {
if (items.length - 1 - visiblePos >= EXTEND_WITHIN) return;
if (lastShown < last) return;
if (listLength != null && last >= listLength - 1) return;
setShownAfter((n) => n + SHOW_MORE);
}, [items.length, visiblePos, lastShown, last, listLength]);
// Readmes and like counts for the skins around the one in view.
useEffect(() => {
const md5s = items
.slice(
Math.max(0, visiblePos - DETAILS_BEHIND),
visiblePos + DETAILS_AHEAD + 1
)
.map((item) => item.skin.md5)
.filter((md5) => getDetails(md5) == null);
if (md5s.length === 0) return;
loadDetails(md5s).catch((error) => {
logger.log({
type: "details_fetch_failure",
errorMessage: String(error?.message ?? error),
});
});
// Send right away: this is what records the session and its first skins.
logger.flush();
}, [items, visiblePos, logger]);
let ended = false;
function endSession(reason: "pagehide" | "unmount") {
if (ended) return;
ended = true;
closeView(true);
logger.log({ type: "session_end", reason });
logger.flush({ unloading: true });
// This visit to the feed. Declared before the view effect below so its
// arrival is logged before its first view. How it was reached is taken once
// per visit, not per run of the effect (which runs twice in development's
// Strict Mode).
const arrivedViaRef = useRef<NavMethod | null>(null);
useEffect(() => {
if (arrivedViaRef.current == null) {
arrivedViaRef.current = enterView("feed", {
query: query || null,
skinMd5: linkedSkin.md5,
listIndex: anchor,
});
}
// Count only time the page is actually visible as dwell, and send what we
// have whenever it's hidden: mobile browsers often discard backgrounded
// pages without a pagehide.
// Count only time the page is actually visible as dwell.
function onVisibilityChange() {
const view = openViewRef.current;
if (view == null) return;
const now = performance.now();
if (document.visibilityState === "hidden") {
if (view != null && view.activeSince != null) {
if (view.activeSince != null) {
view.activeMs += now - view.activeSince;
view.activeSince = null;
}
logger.log({ type: "page_hidden", skinMd5: view?.skinMd5 ?? null });
logger.flush({ unloading: true });
} else {
if (view != null && view.activeSince == null) {
view.activeSince = now;
}
logger.log({ type: "page_visible", skinMd5: view?.skinMd5 ?? null });
} else if (view.activeSince == null) {
view.activeSince = now;
}
}
function onPageHide() {
endSession("pagehide");
}
// Restored from the back/forward cache after a pagehide: pick up where
// the session left off.
function onPageShow(e: PageTransitionEvent) {
if (!e.persisted || !ended) return;
ended = false;
logger.log({ type: "session_resume" });
openView(visibleRef.current.skin, visibleRef.current.index, "resume");
}
document.addEventListener("visibilitychange", onVisibilityChange);
addEventListener("pagehide", onPageHide);
addEventListener("pageshow", onPageShow);
const stopEnd = onSessionEnd(() => closeView("session_end"));
// Restored from the back/forward cache: pick up where we left off.
const stopResume = onSessionResume(() =>
openView(visibleRef.current, "resume")
);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
removeEventListener("pagehide", onPageHide);
removeEventListener("pageshow", onPageShow);
stopEnd();
stopResume();
// Client-side navigation away from the feed.
endSession("unmount");
closeView("left_feed");
setFeedSkin(null);
};
}, [logger]);
const visibleSkinMd5 = visibleSkin.md5;
const visibleSkinFileName = visibleSkin.fileName;
const visibleFileName = visibleItem.skin.fileName;
const hasOpenedViewRef = useRef(false);
useEffect(() => {
// We want the URL and title to update as you scroll, but
// we can't trigger a NextJS navigation since that would remount the
// component. So, here we replicate the metadata behavior of the route.
const newUrl = skinPath(visibleSkinMd5, visibleSkinFileName);
window.document.title = `${visibleSkinFileName} - Winamp Skin Museum`;
const newUrl = skinPath(visibleMd5, visibleFileName);
window.document.title = `${visibleFileName} - Winamp Skin Museum`;
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
setFeedSkin(visibleMd5);
const item = visibleRef.current;
if (item.listIndex != null) {
setListPosition({ query, index: item.listIndex, skin: item.skin });
}
if (openViewRef.current?.skinMd5 !== visibleSkinMd5) {
if (openViewRef.current?.skinMd5 !== visibleMd5) {
const isFirstView = !hasOpenedViewRef.current;
hasOpenedViewRef.current = true;
closeView(false);
closeView("next_skin");
openView(
visibleRef.current.skin,
visibleRef.current.index,
isFirstView ? "initial" : takeNavMethod()
item,
isFirstView ? arrivedViaRef.current ?? "link" : takeNavMethod()
);
}
}, [visibleSkinMd5, visibleSkinFileName]);
useLayoutEffect(() => {
if (fetching || exhausted) {
return;
}
if (visibleSkinIndex + 5 < skins.length) {
return;
}
setFetching(true);
const offset = skins.length;
logger.log({ type: "skins_fetch_start", offset });
getSkins(sessionId, offset)
.then((newSkins) => {
logger.log({ type: "skins_fetch_success", offset });
failureCountRef.current = 0;
if (newSkins.length === 0) {
setExhausted(true);
} else {
setSkins((prev) => {
// Guard against the server returning a skin we already have,
// which would give two pages the same key.
const seen = new Set(prev.map((skin) => skin.md5));
return [...prev, ...newSkins.filter((skin) => !seen.has(skin.md5))];
});
}
setFetching(false);
})
.catch((error) => {
logger.log({
type: "skins_fetch_failure",
offset,
errorMessage: error.message,
});
// Back off before allowing another attempt, rather than retrying in a
// tight loop.
const delay = Math.min(
FETCH_RETRY_BASE_MS * 2 ** failureCountRef.current,
FETCH_RETRY_MAX_MS
);
failureCountRef.current++;
setTimeout(() => setFetching(false), delay);
});
}, [
visibleSkinIndex,
skins.length,
fetching,
exhausted,
sessionId,
getSkins,
logger,
]);
}, [visibleMd5, visibleFileName]);
return (
<EventLoggerContext.Provider value={logger}>
<>
<div
className="feed-backdrop"
aria-hidden
@@ -447,56 +579,54 @@ export default function SkinScroller({
height: "100dvh",
overflowY: "scroll",
scrollSnapType: "y mandatory",
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
// We keep the skin in view in place ourselves when skins are added
// above it.
overflowAnchor: "none",
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
}}
className="hide-scrollbar"
>
{skins.map((skin, i) => {
return (
<SkinPage
key={skin.md5}
skin={skin}
index={i}
focused={i === visibleSkinIndex}
webampSlotRef={setWebampSlot}
/>
);
})}
{clientSkins.map((skin, i) => (
<SkinPage
key={skin.md5}
skin={skin}
focused={i === visiblePos}
webampSlotRef={setWebampSlot}
/>
))}
{listEnded && (
<div className="skin-page feed-end">
<p>That&apos;s every skin for &ldquo;{query}&rdquo;.</p>
<button
className="feed-end-button"
onClick={() => returnToGrid(router)}
>
Back to the results
</button>
</div>
)}
</div>
<ScrollHintPill
hasScrolled={hasEverScrolled}
// Once done, the hint is remembered as done (see HintPill), so
// scrolling back to the first skin doesn't bring it back.
hasScrolled={visibleMd5 !== linkedSkin.md5}
onShown={() => {
logger.log({ type: "scroll_hint_shown" });
}}
onTap={() => scrollToSkin(visibleSkinIndex + 1, "hint")}
onTap={() => scrollBySkins(1, "hint")}
/>
<FeedNavButtons
canGoPrevious={visibleSkinIndex > 0}
canGoNext={visibleSkinIndex < skins.length - 1}
onPrevious={() => scrollToSkin(visibleSkinIndex - 1, "button")}
onNext={() => scrollToSkin(visibleSkinIndex + 1, "button")}
canGoPrevious={visiblePos > 0}
canGoNext={visiblePos < items.length - 1}
onPrevious={() => scrollBySkins(-1, "button")}
onNext={() => scrollBySkins(1, "button")}
/>
{/*
Fades out whatever runs off the bottom of the page (usually the readme)
into the menu bar. Fixed, so it stays put as you scroll between skins.
*/}
<div className="feed-bottom-fade" aria-hidden />
{/* Top shadow overlay */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "1rem",
background:
"linear-gradient(180deg, rgba(12, 12, 16, 0.8) 0%, rgba(12, 12, 16, 0.4) 50%, rgba(12, 12, 16, 0) 100%)",
pointerEvents: "none",
zIndex: 500,
}}
/>
</EventLoggerContext.Provider>
<div className="feed-top-shadow" aria-hidden />
</>
);
}
@@ -191,6 +191,23 @@ async function createWebamp(
const WEBAMP_READY_ATTRIBUTE = "data-webamp-ready";
/**
* Show the skin's screenshot in Webamp's place, as the feed is left: the view
* transition back to the grid animates the skin's image into its tile, and
* Webamp (which hides the image once it's loaded) doesn't make the trip.
*/
export function showScreenshotInsteadOfWebamp(): void {
for (const container of document.querySelectorAll(
`[${WEBAMP_READY_ATTRIBUTE}]`
)) {
container.removeAttribute(WEBAMP_READY_ATTRIBUTE);
}
for (const host of document.querySelectorAll<HTMLElement>(".feed-webamp")) {
host.style.transition = "none";
host.style.opacity = "0";
}
}
// Which windows are open in Webamp's default layout (and in screenshots).
const DEFAULT_WINDOWS_OPEN = [
["main", true],
@@ -1,11 +1,11 @@
"use client";
import { createContext, useContext } from "react";
import {
EVENT_SCHEMA_VERSION,
type FeedEntry,
type LoggedEvent,
type MuseumView,
type UserEvent,
type ViewVia,
} from "./eventTypes";
import { DESKTOP_MEDIA_QUERY } from "./breakpoints";
@@ -23,11 +23,11 @@ export type EventLogger = {
};
/**
* Queues the feed's events and sends them to /scroll/events in batches, each
* Queues a session's events and sends them to /scroll/events in batches, each
* stamped with the schema version, a per-session sequence number and the
* client's clock.
*/
export function createEventLogger(sessionId: string): EventLogger {
function createEventLogger(sessionId: string): EventLogger {
let queue: LoggedEvent[] = [];
let seq = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -76,35 +76,141 @@ export function createEventLogger(sessionId: string): EventLogger {
return { log, flush };
}
const noopLogger: EventLogger = { log() {}, flush() {} };
// The visit's session: everything from opening the museum in a tab until
// it's closed or reloaded, across the grid and the feed.
type Session = {
logger: EventLogger;
started: boolean;
ended: boolean;
// The skin in view in the feed, while it's showing.
feedSkinMd5: string | null;
};
export const EventLoggerContext = createContext<EventLogger>(noopLogger);
let session: Session | null = null;
const endListeners = new Set<() => void>();
const resumeListeners = new Set<() => void>();
function getSession(): Session {
if (session == null) {
const created: Session = {
logger: createEventLogger(crypto.randomUUID()),
started: false,
ended: false,
feedSkinMd5: null,
};
session = created;
watchPageLifecycle(created);
}
return session;
}
// Log the page being hidden and shown, and send what we have whenever it's
// hidden: mobile browsers often discard backgrounded pages without warning.
// The session ends on pagehide, and resumes if the page comes back from the
// back/forward cache.
function watchPageLifecycle(s: Session): void {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
s.logger.log({ type: "page_hidden", skinMd5: s.feedSkinMd5 });
s.logger.flush({ unloading: true });
} else {
s.logger.log({ type: "page_visible", skinMd5: s.feedSkinMd5 });
}
});
addEventListener("pagehide", () => {
if (s.ended) return;
s.ended = true;
endListeners.forEach((listener) => listener());
s.logger.log({ type: "session_end", reason: "pagehide" });
s.logger.flush({ unloading: true });
});
addEventListener("pageshow", (e) => {
if (!e.persisted || !s.ended) return;
s.ended = false;
s.logger.log({ type: "session_resume" });
resumeListeners.forEach((listener) => listener());
});
}
// The session's logger. The session starts with its first event.
const sessionLogger: EventLogger = {
log(event) {
getSession().logger.log(event);
},
flush(options) {
getSession().logger.flush(options);
},
};
export function useEventLogger(): EventLogger {
return useContext(EventLoggerContext);
return sessionLogger;
}
// How the next feed visit was reached, set by in-app links to the feed just
// before they navigate. The feed reads it once when it mounts; if nothing set
// it, the visitor landed on the feed directly.
type PendingFeedEntry = { entry: FeedEntry; fromSessionId: string | null };
let pendingFeedEntry: PendingFeedEntry | null = null;
// How the next view is being reached, set by in-app links just before they
// navigate. Read when the view opens; if nothing set it, it was the browser's
// history (or something we don't know about).
let nextViewVia: ViewVia | null = null;
export function setFeedEntry(
entry: FeedEntry,
fromSessionId: string | null = null
): void {
pendingFeedEntry = { entry, fromSessionId };
export function setNextViewVia(via: ViewVia): void {
nextViewVia = via;
}
export function takeFeedEntry(): PendingFeedEntry | null {
const pending = pendingFeedEntry;
pendingFeedEntry = null;
return pending;
/**
* Log arriving at a view: the session's start, if this is its first view,
* otherwise a switch to it. Returns how the view was reached ("link" for the
* session's first). Call once per visit to the view.
*/
export function enterView(
view: MuseumView,
context: {
query: string | null;
skinMd5: string | null;
listIndex: number | null;
}
): "link" | ViewVia {
const s = getSession();
const via = nextViewVia ?? "history";
nextViewVia = null;
if (!s.started) {
s.started = true;
s.logger.log({
type: "session_start",
view,
query: context.query,
skinMd5: context.skinMd5,
...getVisitContext(),
});
// Send right away: this is what records the session.
s.logger.flush();
return "link";
}
s.logger.log({ type: "view_switch", to: view, via, ...context });
return via;
}
// Context logged when a feed or grid visit starts.
export function getVisitContext(): {
/** The feed's skin in view (null when leaving the feed), for page_hidden/visible. */
export function setFeedSkin(md5: string | null): void {
getSession().feedSkinMd5 = md5;
}
/** Called when the session ends (before session_end is logged). */
export function onSessionEnd(listener: () => void): () => void {
endListeners.add(listener);
return () => {
endListeners.delete(listener);
};
}
/** Called when an ended session resumes (after session_resume is logged). */
export function onSessionResume(listener: () => void): () => void {
resumeListeners.add(listener);
return () => {
resumeListeners.delete(listener);
};
}
// Context logged when a session starts.
function getVisitContext(): {
referrerHost: string | null;
layout: "mobile" | "desktop";
pointer: "coarse" | "fine";
@@ -124,19 +230,3 @@ export function getVisitContext(): {
pointer: matchMedia("(pointer: coarse)").matches ? "coarse" : "fine",
};
}
// Sends queued events whenever the page is hidden (mobile browsers often
// discard backgrounded pages without warning) and when the caller unmounts.
// Returns a cleanup function.
export function flushWhenHidden(logger: EventLogger): () => void {
function onVisibilityChange() {
if (document.visibilityState === "hidden") {
logger.flush({ unloading: true });
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
logger.flush({ unloading: true });
};
}
@@ -1,30 +1,36 @@
// Event types logged by the /scroll feed. Shared by the client (which batches
// and sends them, see eventLogger.ts), the /scroll/events endpoint (which
// validates and stores them) and analysis tasks. Nothing here imports server
// code, so client components can use it.
// Event types logged by the new museum UI (the grid and the feed). Shared by
// the client (which batches and sends them, see eventLogger.ts), the
// /scroll/events endpoint (which validates and stores them) and analysis
// tasks. Nothing here imports server code, so client components can use it.
// Bump when what an event means or contains changes, so analysis never mixes
// data collected under different rules. Stored on every event as `v`. Events
// from before versioning (no `v`) are version 1: they lack view context, count
// time the page was hidden as dwell, and lose the final view's end on exit.
export const EVENT_SCHEMA_VERSION = 2;
// data collected under different rules. Stored on every event as `v`.
//
// - 1 (no `v`): feed-only sessions. Lacks view context, counts time the page
// was hidden as dwell, and loses the final view's end on exit.
// - 2: feed and grid visits are separate sessions (a feed session names the
// grid session it came from). The feed was its own shuffled list: museum
// favorites plus 20% random skins, never repeating within a session.
// - 3: a session is a visit to the museum in one tab (until it's closed or
// reloaded), spanning the grid and the feed, which are two views of the
// same list: the museum, or a search. See LegacyEvent for what changed.
export const EVENT_SCHEMA_VERSION = 3;
// Where a skin in the feed came from.
export type SkinSource =
| "curated" // museum_sort_order
| "random" // the random "fresh" share of each page
| "link"; // the skin the visitor landed on (e.g. a shared link)
// The grid and the feed.
export type MuseumView = "grid" | "feed";
// How the visitor got to the feed.
export type FeedEntry =
| "link" // landed directly on /scroll/skin/:md5, e.g. a shared link
| "direct" // landed directly on /scroll/skin
| "grid" // tapped a skin in the /scroll grid
| "feed_tab"; // the bottom menu bar's feed button
// How the visitor got to a view (other than when the session started on it).
export type ViewVia =
| "grid" // tapped a skin in the grid
| "menu" // the bottom menu bar
| "back" // the feed's back arrow or Escape
| "history"; // the browser's back/forward buttons, or anything else
// How a skin came into view.
// How a skin came into view in the feed. Its first skin came in however the
// feed was reached: "link" if the session started on it, otherwise its ViewVia.
export type NavMethod =
| "initial" // the first skin of the session
| "link"
| ViewVia
| "scroll" // swipe, wheel or trackpad
| "key" // J/K, arrows or space
| "button" // the desktop previous/next buttons
@@ -37,35 +43,47 @@ export type WebampTarget =
| "drag" // a window's draggable area (title bar, background)
| "control"; // anything else: buttons, the playlist, the equalizer
// Identifies a view of one skin: which skin, where it was in the feed
// (0-based) and where it came from. Position matters because skins shown
// earlier get more attention regardless of quality.
type ViewContext = {
// A list of skins: the museum (`query` null) or a search.
type ListContext = {
query: string | null;
};
// Identifies a view of one skin in the feed.
type ViewContext = ListContext & {
skinMd5: string;
position: number;
source: SkinSource;
// Its place in the list (0-based), or null for the skin a feed opened on
// when it isn't in the list (e.g. a shared link, followed by the museum).
// Skins earlier in a list get more attention regardless of quality, and the
// museum is already sorted by popularity, so weigh views by this.
listIndex: number | null;
// How many skins the visitor had already viewed in this visit to the feed
// (0 for the one it opened on). Scrolling back up counts too.
depth: number;
};
export type UserEvent =
| {
type: "session_start";
entry: FeedEntry;
// The view the session started on, and what it showed.
view: MuseumView;
query: string | null;
// For the feed: the skin it opened on.
skinMd5: string | null;
// Host of an external referrer (e.g. "twitter.com"), if any.
referrerHost: string | null;
layout: "mobile" | "desktop";
pointer: "coarse" | "fine";
// The skins the page was rendered with. Recorded as seen by the session
// so later pages exclude them (sessions aren't written until a browser
// reports in, which keeps bots out of the session tables).
skinMd5s: string[];
// The grid session the visitor came from, if they tapped a grid skin.
fromSessionId: string | null;
}
| {
type: "session_end";
// "before_unload" only appears in version 1 data.
reason: "pagehide" | "unmount" | "before_unload";
}
// Switched to the other view. For the feed, skinMd5 and listIndex are the
// skin it opened on; for the grid, the skin it came back to (if any).
| ({
type: "view_switch";
to: MuseumView;
via: ViewVia;
skinMd5: string | null;
listIndex: number | null;
} & ListContext)
| { type: "session_end"; reason: "pagehide" }
// The page was restored from the back/forward cache after a session_end.
| { type: "session_resume" }
| ({ type: "skin_view_start"; via: NavMethod } & ViewContext)
@@ -75,26 +93,27 @@ export type UserEvent =
durationMs: number;
// Of that, time the page was actually visible (not backgrounded).
activeMs: number;
// True if the session ended on this skin (closed, backgrounded until
// the browser discarded it, or navigated away from the feed).
exit: boolean;
// What ended it: moving on to another skin in the feed, leaving the
// feed (usually for the grid), or the session ending on it (closed,
// backgrounded until the browser discarded it, or navigated away).
endedBy: "next_skin" | "left_feed" | "session_end";
// Completed presses on Webamp while this skin was in view.
webampInteractions: number;
} & ViewContext)
// The first completed press on Webamp while a skin was in view. Presses
// that turn into a scroll don't count.
| { type: "webamp_interact"; skinMd5: string; target: WebampTarget }
// The page was backgrounded or brought back. Mobile browsers often discard
// backgrounded pages without a pagehide, so a session whose last event is
// page_hidden ended there.
// The page was backgrounded or brought back, with the feed's skin in view
// (if it's the feed). Mobile browsers often discard backgrounded pages
// without a pagehide, so a session whose last event is page_hidden ended
// there.
| { type: "page_hidden"; skinMd5: string | null }
| { type: "page_visible"; skinMd5: string | null }
// Webamp's playback state changed (e.g. "PLAYING", "PAUSED", "STOPPED").
// Music playing explains long dwell without implying interest in the skin.
| { type: "audio_status"; status: string; skinMd5: string | null }
| { type: "skins_fetch_start"; offset: number }
| { type: "skins_fetch_success"; offset: number }
| { type: "skins_fetch_failure"; offset: number; errorMessage: string }
// The feed couldn't load skins' readmes and like counts.
| { type: "details_fetch_failure"; errorMessage: string }
| { type: "readme_expand"; skinMd5: string }
| { type: "skin_download"; skinMd5: string }
| { type: "skin_like"; skinMd5: string; liked: boolean }
@@ -105,8 +124,62 @@ export type UserEvent =
| { type: "share_success"; skinMd5: string }
| { type: "share_failure"; skinMd5: string; errorMessage: string }
| { type: "scroll_hint_shown" }
// The /scroll grid. Grid visits are their own sessions (no session_start);
// a feed session reached from the grid names it in fromSessionId.
// The grid's "Tap a skin" hint was shown.
| { type: "grid_hint_shown" }
// A search's results arrived.
| { type: "grid_search"; query: string; resultCount: number }
// A skin in the grid was tapped. Position is its index in the list
// (0-based); query is the search it was found with, if any.
| {
type: "grid_skin_click";
skinMd5: string;
position: number;
query: string | null;
};
/**
* Events (and shapes of events) only in earlier versions' data. Version 3
* replaced session_start's feed fields (entry, skinMd5s, fromSessionId) and
* grid_open with session_start/view_switch; ViewContext's `position` (depth
* into a feed session) and `source` with listIndex, query and depth;
* skin_view_end's `exit` with endedBy; and the fetch events with
* details_fetch_failure.
*/
export type LegacyEvent =
| {
type: "session_start";
entry: "link" | "direct" | "grid" | "feed_tab";
referrerHost: string | null;
layout: "mobile" | "desktop";
pointer: "coarse" | "fine";
skinMd5s: string[];
fromSessionId: string | null;
}
| {
type: "session_end";
// "before_unload" only appears in version 1 data.
reason: "pagehide" | "unmount" | "before_unload";
}
| {
type: "skin_view_start";
skinMd5: string;
position: number;
source: "curated" | "random" | "link";
via: string;
}
| {
type: "skin_view_end";
skinMd5: string;
position: number;
source: "curated" | "random" | "link";
durationMs: number;
activeMs: number;
exit: boolean;
webampInteractions: number;
}
| { type: "skins_fetch_start"; offset: number }
| { type: "skins_fetch_success"; offset: number }
| { type: "skins_fetch_failure"; offset: number; errorMessage: string }
| {
type: "grid_open";
query: string | null;
@@ -114,19 +187,6 @@ export type UserEvent =
layout: "mobile" | "desktop";
pointer: "coarse" | "fine";
}
// The grid's "Tap a skin" hint was shown.
| { type: "grid_hint_shown" }
// A search's results arrived.
| { type: "grid_search"; query: string; resultCount: number }
// A skin in the grid was tapped. Position is its index in the grid (0-based);
// query is the search it was found with, if any.
| {
type: "grid_skin_click";
skinMd5: string;
position: number;
query: string | null;
}
/** @deprecated Only in early version 1 data. */
| { type: "skin_view"; skinMd5: string };
// What's sent and stored for each event.
@@ -139,33 +199,36 @@ export type LoggedEvent = UserEvent & {
t: number;
};
// Every type the endpoint accepts.
export const USER_EVENT_TYPES: ReadonlySet<string> = new Set<UserEvent["type"]>(
[
"session_start",
"session_end",
"session_resume",
"skin_view_start",
"skin_view_end",
"page_hidden",
"page_visible",
"audio_status",
"skins_fetch_start",
"skins_fetch_success",
"skins_fetch_failure",
"readme_expand",
"skin_download",
"skin_like",
"skin_flag_nsfw",
"nsfw_reveal",
"share_open",
"share_success",
"share_failure",
"scroll_hint_shown",
"webamp_interact",
"grid_open",
"grid_hint_shown",
"grid_search",
"grid_skin_click",
]
);
// Every type the endpoint accepts. Includes earlier versions' types, for tabs
// still open on an older build.
export const USER_EVENT_TYPES: ReadonlySet<string> = new Set<
UserEvent["type"] | LegacyEvent["type"]
>([
"session_start",
"view_switch",
"session_end",
"session_resume",
"skin_view_start",
"skin_view_end",
"page_hidden",
"page_visible",
"audio_status",
"details_fetch_failure",
"readme_expand",
"skin_download",
"skin_like",
"skin_flag_nsfw",
"nsfw_reveal",
"share_open",
"share_success",
"share_failure",
"scroll_hint_shown",
"webamp_interact",
"grid_hint_shown",
"grid_search",
"grid_skin_click",
"skins_fetch_start",
"skins_fetch_success",
"skins_fetch_failure",
"grid_open",
]);
@@ -1,7 +1,7 @@
import { storeEvents } from "../Events";
import { USER_EVENT_TYPES, type LoggedEvent } from "../eventTypes";
// Receives batches of /scroll feed events from eventLogger.ts, as either a
// Receives batches of the new museum UI's events from eventLogger.ts, as either a
// fetch or a `navigator.sendBeacon` (the only reliable way to send while the
// page is unloading).
//
@@ -62,7 +62,8 @@ function isValidEvent(event: unknown): event is LoggedEvent {
return false;
}
if ("skinMd5" in e && e.skinMd5 != null && !isMd5(e.skinMd5)) return false;
if (e.type === "session_start") {
// Version 2's session_start (from tabs still open on an older build).
if (e.type === "session_start" && "skinMd5s" in e) {
return (
(e.fromSessionId == null ||
(typeof e.fromSessionId === "string" &&
@@ -0,0 +1,57 @@
import UserContext from "../../../data/UserContext";
import SkinModel from "../../../data/SkinModel";
import { GridSkin, SkinDetails, toGridSkin } from "./gridSkin";
// Server-only: what the feed shows of a skin.
// The feed shows the whole readme (clipped until expanded), so we send it all,
// within reason. A handful of readmes are enormous.
const README_MAX_LENGTH = 5000;
/** The skin a feed page is for, or null if there's no such classic skin. */
export async function getLinkedSkin(
md5: string
): Promise<{ skin: GridSkin; details: SkinDetails } | null> {
const model = await SkinModel.fromMd5(new UserContext(), md5);
if (model == null || model.getSkinType() !== "CLASSIC") return null;
const [fileName, nsfw, details] = await Promise.all([
model.getFileName(true),
model.getIsNsfw(),
getDetails(model),
]);
return { skin: toGridSkin(md5, fileName, nsfw), details };
}
/** Whether there's a classic skin with this md5 (cheaper than getLinkedSkin). */
export async function isLinkableSkin(md5: string): Promise<boolean> {
const model = await SkinModel.fromMd5(new UserContext(), md5);
return model != null && model.getSkinType() === "CLASSIC";
}
export async function getSkinDetailsFor(
md5s: string[]
): Promise<Record<string, SkinDetails>> {
const ctx = new UserContext();
const entries = await Promise.all(
md5s.map(async (md5) => {
const model = await SkinModel.fromMd5(ctx, md5);
return model == null ? null : ([md5, await getDetails(model)] as const);
})
);
return Object.fromEntries(entries.filter((entry) => entry != null));
}
async function getDetails(model: SkinModel): Promise<SkinDetails> {
const [readme, tweet] = await Promise.all([
model.getReadme(),
model.getTweet(),
]);
const readmeText = readme?.trim() ?? "";
return {
readme:
readmeText.length > README_MAX_LENGTH
? `${readmeText.slice(0, README_MAX_LENGTH)}…`
: readmeText,
likeCount: tweet ? tweet.getLikes() : 0,
};
}
@@ -1,87 +0,0 @@
import UserContext from "../../../data/UserContext";
import SessionModel from "../../../data/SessionModel";
import { ClientSkin } from "./SkinScroller";
import { getScrollPage } from "../../../data/skins";
import SkinModel from "../../../data/SkinModel";
import type { SkinSource } from "./eventTypes";
import { skinPath } from "./paths";
// The feed shows the whole readme (clipped until expanded), so we send it all,
// within reason. A handful of readmes are enormous.
const README_MAX_LENGTH = 5000;
// Fetches the next page of the feed as the user scrolls. Only a real browser
// calls this, so it records the page in the session (one insert) so that a
// later getScrollPage for this session excludes them.
export async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
"use server";
const page = await getScrollPage(sessionId);
const [skins] = await Promise.all([
getClientSkinsFor(page),
SessionModel.addSkins(
sessionId,
page.map((item) => item.md5)
),
]);
return skins;
}
// The first page is rendered with the route, so we keep it small: everything
// in it has to download before the feed appears. The scroller fetches full
// pages from there.
export const INITIAL_PAGE_SIZE = 10;
// The page a feed is rendered with. Not recorded in the session here: renders
// also come from crawlers and link previews, so the browser reports these
// skins in its session_start event instead (see SessionModel).
export async function getInitialClientSkins(
sessionId: string,
linkedMd5?: string
): Promise<ClientSkin[]> {
const page = (await getScrollPage(sessionId, INITIAL_PAGE_SIZE)).filter(
(item) => item.md5 !== linkedMd5
);
return getClientSkinsFor(
linkedMd5 == null ? page : [{ md5: linkedMd5, source: "link" }, ...page]
);
}
function getClientSkinsFor(
page: Array<{ md5: string; source: SkinSource }>
): Promise<ClientSkin[]> {
const ctx = new UserContext();
return Promise.all(
page.map(({ md5, source }) => getClientSkin(ctx, md5, source))
);
}
async function getClientSkin(
ctx: UserContext,
md5: string,
source: SkinSource
): Promise<ClientSkin> {
const model = await SkinModel.fromMd5Assert(ctx, md5);
const [readme, fileName, tweet, nsfw] = await Promise.all([
model.getReadme(),
model.getFileName(true),
model.getTweet(),
model.getIsNsfw(),
]);
const readmeText = readme?.trim() ?? "";
return {
screenshotUrl: model.getScreenshotUrl(),
skinUrl: model.getSkinUrl(),
md5,
source,
fileName,
readme:
readmeText.length > README_MAX_LENGTH
? `${readmeText.slice(0, README_MAX_LENGTH)}…`
: readmeText,
downloadUrl: model.getSkinUrl(),
shareUrl: `https://skins.webamp.org${skinPath(md5, fileName)}`,
nsfw,
likeCount: tweet ? tweet.getLikes() : 0,
};
}
@@ -1,31 +1,17 @@
"use server";
import { getMuseumPage, getScreenshotUrl } from "../../../data/skins";
import { getMuseumPage } from "../../../data/skins";
import UserContext from "../../../data/UserContext";
import { search_classic_skins } from "../../../api/graphql/resolvers/SkinResolver";
import { normalizeSkinFileName } from "./skinFileName";
export type GridSkin = {
md5: string;
screenshotUrl: string;
fileName: string;
nsfw: boolean;
};
import { GridSkin, SkinDetails, toGridSkin } from "./gridSkin";
import { getLinkedSkin, getSkinDetailsFor, isLinkableSkin } from "./feedSkins";
export async function getMuseumPageSkins(
offset: number,
limit: number
): Promise<GridSkin[]> {
const page = await getMuseumPage({ offset, first: limit });
const skins = page.map((item) => ({
md5: item.md5,
screenshotUrl: getScreenshotUrl(item.md5),
fileName: normalizeSkinFileName(item.fileName),
nsfw: item.nsfw,
}));
return skins;
return page.map((item) => toGridSkin(item.md5, item.fileName, item.nsfw));
}
/**
@@ -42,17 +28,51 @@ export async function getSearchSkins(query: string): Promise<GridSkin[]> {
new UserContext()
);
return Promise.all(
results.flatMap((skin) =>
skin == null
? []
: [
(async () => ({
md5: skin.md5(),
screenshotUrl: getScreenshotUrl(skin.md5()),
fileName: normalizeSkinFileName(await skin.filename(true)),
nsfw: await skin.nsfw(),
}))(),
]
)
results
.filter((skin) => skin != null)
.map(async (skin) => {
const [fileName, nsfw] = await Promise.all([
skin.filename(true),
skin.nsfw(),
]);
return toGridSkin(skin.md5(), fileName, nsfw);
})
);
}
const MAX_DETAILS_PER_REQUEST = 20;
const MD5 = /^[0-9a-f]{32}$/;
/**
* The skin a feed page is for, or null if there's no such classic skin.
*
* The feed's page calls this rather than feedSkins.ts directly: its client
* code calls this module's actions, and importing them from the page too lets
* the build share one copy of everything they use, rather than two.
*/
export async function getFeedSkin(
md5: string
): Promise<{ skin: GridSkin; details: SkinDetails } | null> {
if (typeof md5 !== "string" || !MD5.test(md5)) return null;
return getLinkedSkin(md5);
}
/** Whether there's a feed page for this md5 (see skin/[md5]/layout.tsx). */
export async function feedSkinExists(md5: string): Promise<boolean> {
return typeof md5 === "string" && MD5.test(md5) && isLinkableSkin(md5);
}
/** The feed's details for these skins, for the ones that exist. */
export async function getSkinDetails(
md5s: string[]
): Promise<Record<string, SkinDetails>> {
// Anyone can call a server action, so take only what the feed would send.
if (
!Array.isArray(md5s) ||
md5s.length > MAX_DETAILS_PER_REQUEST ||
!md5s.every((md5) => typeof md5 === "string" && MD5.test(md5))
) {
throw new Error("Invalid request");
}
return getSkinDetailsFor(md5s);
}
@@ -1,19 +1,99 @@
"use client";
// Whether the feed was opened from the grid, so going "back to the grid"
// returns to that grid (its search and scroll position) rather than opening a
// fresh one.
// Going "back to the grid" from the feed: to the grid you came from, if you
// did (with its search and scroll position, via history), otherwise to a new
// one for the list you're in. Either way it comes back to the skin you were
// on (see listPosition.ts).
let cameFromGrid = false;
import { setNextViewVia } from "./eventLogger";
import type { ViewVia } from "./eventTypes";
import { gridPath } from "./paths";
import { getListPosition } from "./listPosition";
import { showScreenshotInsteadOfWebamp } from "./Webamp";
export function setCameFromGrid(value: boolean): void {
cameFromGrid = value;
// The page before this one in the tab, and this one, as "grid", "feed" or a
// pathname. The feed counts as one page, though its URL changes as you scroll.
let currentPage: string | null = null;
let previousPage: string | null = null;
/** Note the page the tab is on (see BottomMenuBar, which is on every page). */
export function notePathname(pathname: string): void {
let page = pathname;
if (pathname === "/") page = "grid";
if (pathname.startsWith("/skin/")) page = "feed";
if (page === currentPage) return;
previousPage = currentPage;
currentPage = page;
}
export function returnToGrid(router: { push(href: string): void }): void {
if (cameFromGrid) {
history.back();
export function returnToGrid(
router: { push(href: string): void },
via: ViewVia = "back"
): void {
setNextViewVia(via);
showScreenshotInsteadOfWebamp();
if (previousPage === "grid") {
backWithTransition();
} else {
router.push("/");
router.push(gridPath(getListPosition()?.query ?? ""));
}
}
const SKIN_PATH = /^\/skin\/([0-9a-f]{32})\//;
// How long to wait for the grid to show the skin's tile, before animating to
// whatever it shows.
const TILE_TIMEOUT_MS = 1000;
// Going back with a view transition, like the one into the feed: the skin in
// view in the feed shrinks back into its tile in the grid. React's
// <ViewTransition> can't do this one: React only animates commits made
// entirely of transitions, and on Back, Next's router updates outside its
// transition (it resolves its state asynchronously; in Next 16 its optimistic
// state is an urgent update instead). So it's done by hand.
function backWithTransition(): void {
const md5 = location.pathname.match(SKIN_PATH)?.[1];
const from =
md5 == null
? null
: document.querySelector<HTMLElement>(
`[data-skin-md5="${md5}"] .skin-frame > div`
);
if (
from == null ||
document.startViewTransition == null ||
matchMedia("(prefers-reduced-motion: reduce)").matches
) {
history.back();
return;
}
const name = `skin-${md5}`;
from.style.viewTransitionName = name;
let to: HTMLElement | null = null;
const transition = document.startViewTransition(async () => {
from.style.viewTransitionName = "";
history.back();
to = await waitFor(() =>
document.querySelector<HTMLElement>(
`.grid-tile a[href^="/skin/${md5}/"] img`
)
);
if (to != null) to.style.viewTransitionName = name;
});
transition.finished.finally(() => {
if (to != null) to.style.viewTransitionName = "";
});
}
// Polls with timers, as animation frames don't run while a view transition
// is waiting for the page to update.
function waitFor<T>(find: () => T | null): Promise<T | null> {
const deadline = performance.now() + TILE_TIMEOUT_MS;
return new Promise((resolve) => {
function check() {
const found = find();
if (found != null || performance.now() > deadline) resolve(found);
else setTimeout(check, 10);
}
check();
});
}
@@ -0,0 +1,62 @@
import {
screenshotUrlFromHash,
skinUrlFromHash,
} from "../../../legacy-client/src/utils";
import { normalizeSkinFileName } from "./skinFileName";
import { skinPath } from "./paths";
export type GridSkin = {
md5: string;
screenshotUrl: string;
fileName: string;
nsfw: boolean;
};
/**
* A skin as the grid shows it. Museum pages, the server's search and the
* browser's all build theirs here, so a search's results look the same
* whichever of them ran it.
*/
export function toGridSkin(
md5: string,
fileName: string,
nsfw: boolean
): GridSkin {
return {
md5,
screenshotUrl: screenshotUrlFromHash(md5),
fileName: normalizeSkinFileName(fileName),
nsfw,
};
}
// What the feed shows of a skin beyond what the grid has, fetched for the
// skins around the one in view.
export type SkinDetails = {
readme: string;
likeCount: number;
};
// A skin as the feed shows it. Until its details arrive, `readme` is null.
export type ClientSkin = GridSkin & {
skinUrl: string;
downloadUrl: string;
shareUrl: string;
readme: string | null;
likeCount: number;
};
export function toClientSkin(
skin: GridSkin,
details: SkinDetails | undefined
): ClientSkin {
const skinUrl = skinUrlFromHash(skin.md5);
return {
...skin,
skinUrl,
downloadUrl: skinUrl,
shareUrl: `https://skins.webamp.org${skinPath(skin.md5, skin.fileName)}`,
readme: details?.readme ?? null,
likeCount: details?.likeCount ?? 0,
};
}
@@ -0,0 +1,75 @@
"use client";
import { useSyncExternalStore } from "react";
import type { GridSkin } from "./gridSkin";
// Which list of skins (see skinList.ts) the visitor is in, and where. It lives
// here for the visit (this tab, until it's reloaded), not in URLs: the feed's
// URLs are plain permalinks for the skin in view, and to share a list you
// share the grid's URL. So opening the feed from the grid continues through
// the grid's list, and going back to the grid lands on the skin you were on,
// but a link to a skin opened fresh has no list (see followingList).
//
// Kept apart from skinList.ts, which loads skins with server actions, so the
// menu bar and back links (on every page) don't bring those along.
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* The skin the visitor was last on in a list: the one in view in the feed, or
* the first one on screen in the grid. The feed opens into this list when
* it's opened on this skin (from the grid, the menu bar, or Forward), and the
* grid comes back to it.
*/
export type ListPosition = { query: string; index: number; skin: GridSkin };
let position: ListPosition | null = null;
export function setListPosition(next: ListPosition): void {
if (
position != null &&
position.query === next.query &&
position.index === next.index &&
position.skin.md5 === next.skin.md5
) {
return;
}
position = next;
listeners.forEach((listener) => listener());
}
export function getListPosition(): ListPosition | null {
return position;
}
export function useListPosition(): ListPosition | null {
return useSyncExternalStore(
subscribe,
() => position,
() => null
);
}
/**
* The list a feed opened on `md5` shows: the one the visitor was in, if they
* were on this skin in it, with the skin's place in it. Otherwise (e.g. a
* shared link) there's no list to go on, so the skin is shown first and
* followed by the museum from the top (`index` is null). This is the place to
* pick something better to follow it with one day, like similar skins.
*/
export function followingList(md5: string): {
query: string;
index: number | null;
} {
if (position != null && position.skin.md5 === md5) {
return { query: position.query, index: position.index };
}
return { query: "", index: null };
}
@@ -1,7 +1,13 @@
// Public URLs for the museum's pages. The new UI's routes live under /scroll,
// and middleware.ts maps these onto them.
// A skin, in the same form as the sitemap: /skin/:md5/:fileName.
// A skin: /skin/:md5/:fileName (also what the sitemap lists). A permalink:
// which list the feed was showing it in isn't part of it (see listPosition.ts).
export function skinPath(md5: string, fileName: string): string {
return `/skin/${md5}/${encodeURIComponent(fileName)}`;
}
// The grid, showing the museum ("") or a search.
export function gridPath(query: string): string {
return query === "" ? "/" : `/?${new URLSearchParams({ query })}`;
}
@@ -15,12 +15,10 @@ button {
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
.scroller::-webkit-scrollbar,
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.scroller,
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@@ -54,7 +52,7 @@ button {
* Behind the feed: a spotlight tinted with the visible skin's colors (set by
* SkinScroller), which stays put and morphs to each new skin's tint. The two
* colors are registered properties so the gradient can transition.
* The readme's fade blends into its dark edge (--feed-edge).
* The fixed fade above the menu bar blends into its dark edge (--feed-edge).
*/
@property --feed-glow {
syntax: "<color>";
@@ -175,6 +173,17 @@ button {
image-rendering: pixelated;
}
/*
* A view transition animates snapshots of the page, which are scaled
* smoothly; keep a skin's pixels crisp as it grows or shrinks between its
* grid tile and the feed. (Everything else in a transition is shown at its
* own size, so this doesn't change it.)
*/
::view-transition-old(*),
::view-transition-new(*) {
image-rendering: pixelated;
}
/*
* Webamp sets touch-action: none on its windows and sliders so dragging them
* doesn't scroll the page. In the feed, vertical swipes over Webamp should
@@ -271,6 +280,55 @@ button {
display: none;
}
/* The grid's search box, floating above the bottom menu bar. */
.grid-search {
position: fixed;
bottom: 4.25rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
padding: 0 1rem;
z-index: 998;
}
.grid-search-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
background-color: rgba(26, 26, 26, 0.55);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 9999px;
color: #fff;
outline: none;
font-family: inherit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: padding-right 0.2s ease;
}
.grid-search-input:focus {
background-color: rgba(26, 26, 26, 0.65);
border-color: rgba(255, 255, 255, 0.3);
}
/* Makes room for the spinner. */
.grid-search-input.pending {
padding-right: 2.75rem;
}
/* In place of the grid: a failed search, or one with no results. */
.grid-message {
display: flex;
justify-content: center;
align-items: center;
height: 100dvh;
color: #ccc;
}
.grid-message-error {
color: #ff6b6b;
}
/* Shown inside the search input while a query is debouncing or in flight. */
.search-spinner {
position: absolute;
@@ -343,6 +401,32 @@ button {
}
/* In place of the text when a skin has no readme, so the space isn't blank. */
/* The page after a search's last skin in the feed. */
.feed-end {
align-items: center;
justify-content: center;
gap: 1rem;
color: #aaa;
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
font-size: 0.9rem;
text-align: center;
}
.feed-end-button {
padding: 0.6rem 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font: inherit;
cursor: pointer;
}
.feed-end-button:hover,
.feed-end-button:focus-visible {
background: rgba(255, 255, 255, 0.16);
}
.skin-readme-empty {
/* Centered in the space under the skin (the readme area is a flex column). */
margin: auto 0;
@@ -360,7 +444,8 @@ button {
right: 0;
bottom: 0;
height: min(2.5rem, 60%);
background: linear-gradient(to bottom, transparent, var(--readme-fade-to));
/* Into the expanded readme's background. */
background: linear-gradient(to bottom, transparent, #15151a);
pointer-events: none;
}
@@ -369,6 +454,23 @@ button {
* menu bar. Fixed to the viewport rather than part of each page, so it doesn't
* scroll along with them. Below the expanded readme, which has its own.
*/
/* Shades the top edge of the feed, under the title row. */
.feed-top-shadow {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 1rem;
background: linear-gradient(
180deg,
rgba(12, 12, 16, 0.8) 0%,
rgba(12, 12, 16, 0.4) 50%,
rgba(12, 12, 16, 0) 100%
);
pointer-events: none;
z-index: 500;
}
.feed-bottom-fade {
position: fixed;
left: 0;
@@ -394,7 +496,6 @@ button {
max-height: calc(100% - var(--bottom-nav-height, 4rem) - 3rem);
/* Solid, since it covers the skin. */
background: #15151a;
--readme-fade-to: #15151a;
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
}
@@ -538,7 +639,7 @@ button {
/*
* Desktop: the window is wide enough to fit things beside the 9:16 skin
* column. Keep in sync with DESKTOP_MEDIA_QUERY in FeedNavigation.tsx.
* column. Keep in sync with DESKTOP_MEDIA_QUERY in breakpoints.ts.
*/
@media (min-aspect-ratio: 3/4) {
/* Already narrower than the screen; no gutter needed. */
@@ -636,6 +737,10 @@ button {
}
.grid-tile img {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: pixelated;
transition: filter 0.15s ease-out;
}
@@ -0,0 +1,20 @@
import { ReactNode } from "react";
import { notFound } from "next/navigation";
import { feedSkinExists } from "../../getMuseumPageSkins";
/**
* Checks the skin exists before anything is sent, so a missing one gets a 404
* status. The page can't: loading.tsx starts streaming the response (with a
* 200) before the page renders.
*/
export default async function SkinLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ md5: string }>;
}) {
const { md5 } = await params;
if (!(await feedSkinExists(md5))) notFound();
return children;
}
@@ -4,9 +4,8 @@
import { unstable_ViewTransition as ViewTransition } from "react";
import { useParams } from "next/navigation";
import { MOBILE_MAX_WIDTH } from "../../../../../legacy-client/src/constants";
import { screenshotUrlFromHash } from "../../../../../legacy-client/src/utils";
import BackToGrid from "../../BackToGrid";
import { corsScreenshotUrl } from "../../skinTint";
import SkinScreenshot from "../../SkinScreenshot";
import { getKnownNsfw, useShowNsfw } from "../../nsfwConsent";
/**
@@ -34,16 +33,11 @@ export default function Loading() {
</div>
<div className="skin-frame">
<ViewTransition name={`skin-${md5}`}>
<img
className={
hidden ? "skin-screenshot nsfw-blurred" : "skin-screenshot"
}
style={nsfw == null ? { visibility: "hidden" } : undefined}
// The same URL and request mode as SkinPage's, so it's one
// cached image.
crossOrigin="anonymous"
src={corsScreenshotUrl(screenshotUrlFromHash(md5))}
<SkinScreenshot
md5={md5}
alt=""
blurred={hidden}
invisible={nsfw == null}
/>
</ViewTransition>
</div>
@@ -1,6 +1,6 @@
import { Metadata } from "next";
import SessionModel from "../../../../../data/SessionModel";
import { getClientSkins, getInitialClientSkins } from "../../getClientSkins";
import { notFound } from "next/navigation";
import { getFeedSkin } from "../../getMuseumPageSkins";
import SkinScroller from "../../SkinScroller";
import { generateSkinPageMetadata } from "../../../../(legacy)/skin/[hash]/skinMetadata";
@@ -9,22 +9,16 @@ export async function generateMetadata({ params }): Promise<Metadata> {
return generateSkinPageMetadata(md5);
}
// Ensure each page load gets a new session
// Likes and readmes change; render each visit fresh.
export const dynamic = "force-dynamic";
/**
* A skin's permalink: the feed, opened on it. Which skins come after it is
* up to the browser (see listPosition.ts), so all we render is this one.
*/
export default async function Skin({ params }) {
const { md5 } = await params;
// Nothing is written for the session until the browser reports in.
const sessionId = SessionModel.newId();
const initialSkins = await getInitialClientSkins(sessionId, md5);
return (
<SkinScroller
initialSkins={initialSkins}
getSkins={getClientSkins}
sessionId={sessionId}
landedOnSkin
/>
);
const linked = await getFeedSkin(md5);
if (linked == null) notFound();
return <SkinScroller skin={linked.skin} details={linked.details} />;
}
@@ -1,23 +1,16 @@
import SessionModel from "../../../../data/SessionModel";
import { getClientSkins, getInitialClientSkins } from "../getClientSkins";
import SkinScroller from "../SkinScroller";
import { redirect } from "next/navigation";
import { getMuseumPageSkins } from "../getMuseumPageSkins";
import { skinPath } from "../paths";
// Ensure each page load gets a new session
// The museum's top skin can change.
export const dynamic = "force-dynamic";
/**
* A tik-tok style scroll page where we display one skin at a time in full screen
* /feed: the feed, from the top of the museum. The feed is a view of the
* grid's lists rather than a list of its own, so this just opens the museum's
* first skin.
*/
export default async function ScrollPage() {
// Nothing is written for the session until the browser reports in.
const sessionId = SessionModel.newId();
const initialSkins = await getInitialClientSkins(sessionId);
return (
<SkinScroller
initialSkins={initialSkins}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
export default async function Feed() {
const [first] = await getMuseumPageSkins(0, 1);
redirect(first == null ? "/" : skinPath(first.md5, first.fileName));
}
@@ -0,0 +1,229 @@
"use client";
import { useSyncExternalStore } from "react";
import { getMuseumPageSkins, getSkinDetails } from "./getMuseumPageSkins";
import { GridSkin, SkinDetails, toGridSkin } from "./gridSkin";
import {
algoliaSearch,
graphqlSearch,
USE_ALGOLIA,
} from "../../../legacy-client/src/algolia";
// The lists of skins that the grid and the feed are two views of: the museum
// (every skin, in museum order) and searches. A list is named by its query;
// "" is the museum.
//
// Which list you're in, and where, is kept in listPosition.ts.
//
// Everything is kept in module state, so it outlives the views and switching
// between them never refetches. Nothing is stored during the server's render.
const isServer = typeof window === "undefined";
export const MUSEUM_PAGE_SIZE = 50;
const MAX_CACHED_SEARCHES = 20;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function notify(): void {
listeners.forEach((listener) => listener());
}
// The museum
type Museum = {
// Sparse, by position: pages are written at their offset as they arrive.
skins: ReadonlyArray<GridSkin | undefined>;
// How many skins it has, once known.
total: number | null;
};
// Replaced, never mutated, so it can be a snapshot.
let museum: Museum = { skins: [], total: null };
const loadedPages = new Set<number>();
const inFlightPages = new Set<number>();
function storeMuseumPage(offset: number, skins: GridSkin[]): void {
const next = museum.skins.slice();
skins.forEach((skin, i) => {
next[offset + i] = skin;
});
museum = { ...museum, skins: next };
loadedPages.add(offset / MUSEUM_PAGE_SIZE);
}
/** Start with a page the server rendered. Doesn't notify (call it while rendering). */
export function seedMuseum(
offset: number,
skins: GridSkin[],
total: number
): void {
if (isServer) return;
if (museum.total !== total) museum = { ...museum, total };
if (skins.length > 0 && !loadedPages.has(offset / MUSEUM_PAGE_SIZE)) {
storeMuseumPage(offset, skins);
}
}
async function loadMuseumPage(page: number): Promise<void> {
if (loadedPages.has(page) || inFlightPages.has(page)) return;
inFlightPages.add(page);
try {
const offset = page * MUSEUM_PAGE_SIZE;
const skins = await getMuseumPageSkins(offset, MUSEUM_PAGE_SIZE);
storeMuseumPage(offset, skins);
if (skins.length < MUSEUM_PAGE_SIZE && museum.total == null) {
museum = { ...museum, total: offset + skins.length };
}
notify();
} catch (error) {
// Left unloaded, so it's tried again next time it's asked for.
console.error("Failed to load skins:", error);
} finally {
inFlightPages.delete(page);
}
}
/** Load the museum's skins from `first` to `last` (inclusive). */
export function loadMuseumRange(first: number, last: number): void {
const end = museum.total == null ? last : Math.min(last, museum.total - 1);
for (
let page = Math.floor(Math.max(0, first) / MUSEUM_PAGE_SIZE);
page <= Math.floor(end / MUSEUM_PAGE_SIZE);
page++
) {
loadMuseumPage(page);
}
}
const emptyMuseum: Museum = { skins: [], total: null };
export function useMuseum(): Museum {
return useSyncExternalStore(
subscribe,
() => museum,
() => emptyMuseum
);
}
// Searches
// Recent searches' results, most recent last.
const searches = new Map<string, GridSkin[]>();
const inFlightSearches = new Map<string, Promise<GridSkin[]>>();
export function getCachedSearch(query: string): GridSkin[] | undefined {
return searches.get(query);
}
/** Remember a search's results. Doesn't notify (call it while rendering). */
export function cacheSearch(query: string, skins: GridSkin[]): void {
if (isServer) return;
searches.delete(query);
searches.set(query, skins);
if (searches.size > MAX_CACHED_SEARCHES) {
searches.delete(searches.keys().next().value!);
}
}
// Same backends as the legacy museum search. If Algolia is enabled but
// failing (e.g. out of quota) fall back to our own SQLite FTS search.
async function runSearch(query: string): Promise<{
hits: Array<{ objectID: string; fileName: string; nsfw?: any }>;
}> {
if (USE_ALGOLIA) {
try {
return await algoliaSearch(query);
} catch (error) {
console.warn("Algolia search failed, falling back to SQLite", error);
}
}
return graphqlSearch(query);
}
/** A search's results, from the cache if we have them. */
export function loadSearch(query: string): Promise<GridSkin[]> {
const cached = searches.get(query);
if (cached != null) return Promise.resolve(cached);
let pending = inFlightSearches.get(query);
if (pending == null) {
pending = runSearch(query)
.then((result) => {
const skins = result.hits.map((hit) =>
toGridSkin(
hit.objectID,
hit.fileName,
// Some records still have float scores rather than booleans.
hit.nsfw === true || hit.nsfw === 1
)
);
cacheSearch(query, skins);
notify();
return skins;
})
.finally(() => inFlightSearches.delete(query));
inFlightSearches.set(query, pending);
}
return pending;
}
export function useSearch(query: string): GridSkin[] | undefined {
return useSyncExternalStore(
subscribe,
() => searches.get(query),
() => undefined
);
}
// The feed's details for each skin
const details = new Map<string, SkinDetails>();
const inFlightDetails = new Set<string>();
// Bumped on every change, as the snapshot.
let detailsVersion = 0;
/** Start with details the server rendered. Doesn't notify (call it while rendering). */
export function seedDetails(md5: string, skinDetails: SkinDetails): void {
if (isServer || details.has(md5)) return;
details.set(md5, skinDetails);
detailsVersion++;
}
export function getDetails(md5: string): SkinDetails | undefined {
return details.get(md5);
}
/** Load the details of whichever of these skins we don't have yet. */
export async function loadDetails(md5s: string[]): Promise<void> {
const missing = md5s.filter(
(md5) => !details.has(md5) && !inFlightDetails.has(md5)
);
if (missing.length === 0) return;
missing.forEach((md5) => inFlightDetails.add(md5));
try {
const loaded = await getSkinDetails(missing);
for (const [md5, skinDetails] of Object.entries(loaded)) {
details.set(md5, skinDetails);
}
detailsVersion++;
notify();
} finally {
missing.forEach((md5) => inFlightDetails.delete(md5));
}
}
/** Re-renders when any skin's details arrive; read them with getDetails. */
export function useDetailsVersion(): number {
return useSyncExternalStore(
subscribe,
() => detailsVersion,
() => 0
);
}
@@ -45,10 +45,9 @@ function loadTint(url: string): Promise<SkinTint | null> {
* until it's known or if it can't be read. Results are cached, and the image
* is usually already in the browser's cache from being displayed.
*/
export function useSkinTint(url: string | null): SkinTint | null {
export function useSkinTint(url: string): SkinTint | null {
const [tint, setTint] = useState<SkinTint | null>(null);
useEffect(() => {
if (url == null) return;
let cancelled = false;
loadTint(url).then((result) => {
if (!cancelled && result != null) setTint(result);
@@ -117,7 +116,7 @@ function toHueSaturation(
// How strongly the backdrop takes on the skin's color: 0 is the neutral
// spotlight, 1 the full tint.
export const TINT_INTENSITY = 0.15;
const TINT_INTENSITY = 0.15;
// The neutral spotlight's colors (its center, and halfway to the edge).
// Keep in sync with the initial values of --feed-glow/--feed-glow-mid.
@@ -128,25 +127,25 @@ type Rgb = [number, number, number];
/**
* The backdrop's two colors for a skin's tint, blended with the neutral
* spotlight by `intensity` (0-1). They're plain colors rather than hue and
* spotlight by TINT_INTENSITY. They're plain colors rather than hue and
* saturation so the backdrop's transition goes straight from one skin's color
* to the next, rather than sweeping through every hue in between (vivid reds
* and greens neither skin has) on the way.
*/
export function backdropColors(
tint: SkinTint,
intensity: number
): { glow: string; glowMid: string } {
export function backdropColors(tint: SkinTint): {
glow: string;
glowMid: string;
} {
return {
glow: blend(
NEUTRAL_GLOW,
hslToRgb(tint.hue, tint.saturation, 22),
intensity
TINT_INTENSITY
),
glowMid: blend(
NEUTRAL_GLOW_MID,
hslToRgb(tint.hue, tint.saturation * 0.8, 11),
intensity
TINT_INTENSITY
),
};
}
+2 -3
View File
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next";
import { skinPath } from "./(modern)/scroll/paths";
import * as Skins from "../data/skins";
// Google's limit is 50,000 URLs per sitemap
@@ -23,9 +24,7 @@ export default async function sitemap({
id: string;
}): Promise<MetadataRoute.Sitemap> {
const md5s = await Skins.getAllClassicSkins();
const skinUrls = md5s.map(
({ md5, fileName }) => `/skin/${md5}/${encodeURIComponent(fileName)}`
);
const skinUrls = md5s.map(({ md5, fileName }) => skinPath(md5, fileName));
const urls = ["/about", "/", "/upload", ...skinUrls];
const slice = urls.slice(
parseInt(id, 10) * PAGE_SIZE,
+5 -26
View File
@@ -1,15 +1,11 @@
import { knex } from "../db";
import { randomUUID } from "crypto";
// A /scroll feed session: one visit to the feed. Pages mint the ID when they
// render, but nothing is written until the visitor's browser reports in (its
// first event batch, or a request for more skins). Server renders that never
// run JavaScript, like crawlers and link previews, leave no rows.
// A session of the new museum UI: a visit in one tab (see eventLogger.ts). The
// browser mints the ID, and the session is recorded with its first batch of
// events, so crawlers and link previews leave no rows. (The session_skin
// table, which recorded the skins each session's feed had been shown, is no
// longer written.)
export default class SessionModel {
static newId(): string {
return randomUUID();
}
// Record the session if it isn't already. Safe to call repeatedly.
static async ensure(sessionId: string): Promise<void> {
await knex("session")
@@ -17,21 +13,4 @@ export default class SessionModel {
.onConflict("id")
.ignore();
}
// Record skins as shown to the session, so later pages exclude them.
static async addSkins(sessionId: string, skinMd5s: string[]): Promise<void> {
await SessionModel.ensure(sessionId);
if (skinMd5s.length === 0) return;
await knex("session_skin").insert(
skinMd5s.map((skinMd5) => ({ session_id: sessionId, skin_md5: skinMd5 }))
);
}
static async getIncludedSkinCount(sessionId: string): Promise<number> {
const result = await knex("session_skin")
.where({ session_id: sessionId })
.count("* as count")
.first();
return result ? (result.count as number) : 0;
}
}
-72
View File
@@ -738,78 +738,6 @@ export type MuseumPage = Array<{
nsfw: boolean;
}>;
export type ScrollPage = Array<{
md5: string;
// Whether it came from the curated museum order or the random share.
source: "curated" | "random";
}>;
const PAGE_SIZE = 50;
const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
// How many random rowids to sample per random skin wanted (see getScrollPage).
const RANDOM_OVERSAMPLE = 30;
export async function getScrollPage(
sessionId: string,
pageSize: number = PAGE_SIZE
): Promise<ScrollPage> {
const randomCount = Math.floor(pageSize * FRESHNESS_PERCENTAGE);
const curatedCount = pageSize - randomCount;
const skins = await knex.raw(
`
SELECT
museum_sort_order.skin_md5
FROM
museum_sort_order
WHERE museum_sort_order.skin_md5 NOT IN (SELECT skin_md5 from session_skin WHERE session_id = ?)
LIMIT ?`,
[sessionId, curatedCount]
);
// Random classic skins this session hasn't seen and that aren't NSFW.
//
// Rather than sorting every skin with ORDER BY RANDOM() (which took ~1.5s in
// production, and checked each skin against every session that had ever
// seen it), we pick random rowids and keep the ones that pass the filters.
// Each existing row is equally likely to be picked. Rowids are sparse and
// most rows aren't classic skins (about 100k of 220k), so we oversample
// generously; in the rare case too few pass, the page just has fewer random
// skins.
const randomSkins = await knex.raw(
`
WITH RECURSIVE
n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?),
bounds(max_id) AS (SELECT max(rowid) FROM skins),
picks(id) AS (SELECT DISTINCT abs(random()) % max_id + 1 FROM n, bounds)
SELECT
skins.md5 as skin_md5
FROM
picks
JOIN skins ON skins.rowid = picks.id
WHERE
skins.skin_type = 1
AND skins.md5 NOT IN (SELECT skin_md5 FROM session_skin WHERE session_id = ?)
AND skins.md5 NOT IN (SELECT skin_md5 FROM skin_reviews WHERE review = 'NSFW')
LIMIT ?`,
[randomCount * RANDOM_OVERSAMPLE, sessionId, randomCount]
);
// A random pick can also be one of the curated ones; keep it as curated.
const curatedMd5s = new Set(skins.map(({ skin_md5 }) => skin_md5));
const allSkins: ScrollPage = [
...skins.map(({ skin_md5 }) => ({
md5: skin_md5,
source: "curated" as const,
})),
...randomSkins
.filter(({ skin_md5 }) => !curatedMd5s.has(skin_md5))
.map(({ skin_md5 }) => ({ md5: skin_md5, source: "random" as const })),
];
// Shuffle the results
allSkins.sort(() => Math.random() - 0.5);
return allSkins;
}
export async function getMuseumPage({
offset,
first,
+10 -14
View File
@@ -1,6 +1,6 @@
import path from "path";
const production = {
export const production = {
client: "sqlite3",
connection: {
// Use process.cwd() and not __dir since Next runs from a build directory
@@ -15,19 +15,15 @@ const production = {
},
};
const configs = {
test: {
...production,
connection: ":memory:",
seeds: {
// Only put this in the test config so that we ensure we never clobber prod data.
directory: path.join(__dirname, "./seeds"),
},
export const test = {
...production,
connection: ":memory:",
seeds: {
// Only put this in the test config so that we ensure we never clobber prod data.
directory: path.join(__dirname, "./seeds"),
},
development: {
...production,
},
production,
};
module.exports = configs;
export const development = {
...production,
};
+1
View File
@@ -40,6 +40,7 @@
"react-window": "^1.8.1",
"redux": "^5.0.0",
"redux-observable": "3.0.0-rc.2",
"reselect": "^3.0.1",
"rxjs": "^7.8.1",
"sharp": "^0.31.3",
"spark-md5": "^3.0.1",
@@ -47,7 +47,9 @@ check() {
if [[ "$MODE" == new ]]; then expected="$2"; else expected="$3"; fi
local actual
actual=$(describe "$path" "${4:-GET}")
if [[ "$expected" == any || "$actual" == "$expected" ]]; then
# Expectations are glob patterns (e.g. "307 /skin/*").
# shellcheck disable=SC2053
if [[ "$expected" == any || "$actual" == $expected ]]; then
printf ' ok %-60s %s\n' "$path" "$actual"
else
printf ' FAIL %-60s %s (expected %s)\n' "$path" "$actual" "$expected"
@@ -60,10 +62,11 @@ echo "Checking $BASE (expecting the $MODE UI)"
# The museum's own URLs.
check "/" new classic
check "/?query=zelda" new classic
check "/feed" new 404
check "/feed" "307 /skin/*" 404
check "/skin/$MD5" new classic
check "/skin/$MD5/$FILE" new classic
check "/skin/$MD5/$FILE/null" new any
check "/skin/00000000000000000000000000000000/x.wsz" 404 any
check "/about" new classic
check "/feedback" new classic
+4
View File
@@ -241,6 +241,9 @@ importers:
redux-observable:
specifier: 3.0.0-rc.2
version: 3.0.0-rc.2([email protected])([email protected])
reselect:
specifier: ^3.0.1
version: 3.0.1
rxjs:
specifier: ^7.8.1
version: 7.8.1
@@ -13442,6 +13445,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
[email protected]:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}