Make scroll sessions more dynamic

This commit is contained in:
Jordan Eldredge 2025-11-07 15:23:02 -08:00
parent fbe3a0090f
commit 52ff84d29b
9 changed files with 380 additions and 84 deletions

View file

@ -0,0 +1,65 @@
"use server";
import { knex } from "../../../db";
export async function logUserEvent(sessionId: string, event: UserEvent) {
const timestamp = Date.now();
await knex("user_log_events").insert({
session_id: sessionId,
timestamp: timestamp,
metadata: JSON.stringify(event),
});
}
type UserEvent =
| {
type: "session_start";
}
| {
type: "session_end";
reason: "unmount" | "before_unload";
}
| {
type: "skin_view_start";
skinMd5: string;
}
| {
type: "skin_view_end";
skinMd5: string;
durationMs: number;
}
| {
type: "skins_fetch_start";
offset: number;
}
| {
type: "skins_fetch_success";
offset: number;
}
| {
type: "skins_fetch_failure";
offset: number;
errorMessage: string;
}
| {
type: "readme_expand";
skinMd5: string;
}
| {
type: "skin_download";
skinMd5: string;
}
| {
type: "share_open";
skinMd5: string;
}
| {
type: "share_success";
skinMd5: string;
}
| {
type: "share_failure";
skinMd5: string;
errorMessage: string;
};

View file

@ -0,0 +1,72 @@
"use client";
import { ClientSkin } from "./SkinScroller";
type Props = {
skin: ClientSkin;
index: number;
sessionId: string;
};
export default function SkinPage({ skin, index }: Props) {
return (
<div
key={skin.md5}
skin-md5={skin.md5}
skin-index={index}
className="scroller"
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100vh",
scrollSnapAlign: "start",
scrollSnapStop: "always",
}}
>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
paddingTop: "4rem",
boxSizing: "border-box",
width: "100%",
imageRendering: "pixelated",
}}
/>
<div
style={{
color: "white",
flexGrow: 1,
paddingLeft: "0.5rem",
paddingTop: "0.5rem",
}}
>
<h2
style={{
marginBottom: 0,
fontSize: "0.9rem",
paddingBottom: "0",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
color: "#ccc",
wordBreak: "break-all",
}}
>
{skin.fileName}
</h2>
<p
style={{
marginTop: "0.5rem",
fontSize: "0.75rem",
paddingTop: "0",
color: "#999",
fontFamily: 'monospace, "Courier New", Courier, monospace',
overflow: "hidden",
}}
>
{skin.readmeStart}
</p>
</div>
</div>
);
}

View file

@ -1,6 +1,8 @@
"use client";
import { useState, useLayoutEffect } from "react";
import { useState, useLayoutEffect, useEffect } from "react";
import SkinPage from "./SkinPage";
import { logUserEvent } from "./Events";
export type ClientSkin = {
screenshotUrl: string;
@ -11,10 +13,15 @@ export type ClientSkin = {
type Props = {
initialSkins: ClientSkin[];
getSkins: (offset: number) => Promise<ClientSkin[]>;
getSkins: (sessionId: string, offset: number) => Promise<ClientSkin[]>;
sessionId: string;
};
export default function SkinScroller({ initialSkins, getSkins }: Props) {
export default function SkinScroller({
initialSkins,
getSkins,
sessionId,
}: Props) {
const [skins, setSkins] = useState<ClientSkin[]>(initialSkins);
const [visibleSkinIndex, setVisibleSkinIndex] = useState(0);
const [fetching, setFetching] = useState(false);
@ -38,16 +45,72 @@ export default function SkinScroller({ initialSkins, getSkins }: Props) {
};
}, [containerRef]);
useEffect(() => {
logUserEvent(sessionId, {
type: "session_start",
});
function beforeUnload() {
logUserEvent(sessionId, {
type: "session_end",
reason: "before_unload",
});
}
addEventListener("beforeunload", beforeUnload);
return () => {
removeEventListener("beforeunload", beforeUnload);
logUserEvent(sessionId, {
type: "session_end",
reason: "unmount",
});
};
}, []);
useEffect(() => {
logUserEvent(sessionId, {
type: "skin_view_start",
skinMd5: skins[visibleSkinIndex].md5,
});
const startTime = Date.now();
return () => {
const durationMs = Date.now() - startTime;
logUserEvent(sessionId, {
type: "skin_view_end",
skinMd5: skins[visibleSkinIndex].md5,
durationMs,
});
};
}, [visibleSkinIndex, skins, fetching]);
useLayoutEffect(() => {
if (fetching) {
return;
}
if (visibleSkinIndex + 5 >= skins.length) {
setFetching(true);
getSkins(skins.length).then((newSkins) => {
setSkins([...skins, ...newSkins]);
setFetching(false);
console.log("Fetching more skins...");
logUserEvent(sessionId, {
type: "skins_fetch_start",
offset: skins.length,
});
getSkins(sessionId, skins.length)
.then((newSkins) => {
logUserEvent(sessionId, {
type: "skins_fetch_success",
offset: skins.length,
});
setSkins([...skins, ...newSkins]);
setFetching(false);
})
.catch((error) => {
logUserEvent(sessionId, {
type: "skins_fetch_failure",
offset: skins.length,
errorMessage: error.message,
});
setFetching(false);
});
}
}, [visibleSkinIndex, skins, fetching]);
@ -62,64 +125,12 @@ export default function SkinScroller({ initialSkins, getSkins }: Props) {
>
{skins.map((skin, i) => {
return (
<div
<SkinPage
key={skin.md5}
skin-md5={skin.md5}
skin-index={i}
className="scroller"
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100vh",
scrollSnapAlign: "start",
scrollSnapStop: "always",
}}
>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
paddingTop: "4rem",
boxSizing: "border-box",
width: "100%",
imageRendering: "pixelated",
}}
/>
<div
style={{
color: "white",
flexGrow: 1,
paddingLeft: "0.5rem",
paddingTop: "0.5rem",
}}
>
<h2
style={{
marginBottom: 0,
fontSize: "0.9rem",
paddingBottom: "0",
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
color: "#ccc",
wordBreak: "break-all",
}}
>
{skin.fileName}
</h2>
<p
style={{
marginTop: "0.5rem",
fontSize: "0.75rem",
paddingTop: "0",
color: "#999",
fontFamily: 'monospace, "Courier New", Courier, monospace',
overflow: "hidden",
}}
>
{skin.readmeStart}
</p>
</div>
</div>
skin={skin}
index={i}
sessionId={sessionId}
/>
);
})}
</div>

View file

@ -1,40 +1,49 @@
import ClassicSkinResolver from "../../../api/graphql/resolvers/ClassicSkinResolver";
import SkinsConnection from "../../../api/graphql/SkinsConnection";
import UserContext from "../../../data/UserContext";
import SessionModel from "../../../data/SessionModel";
import "./scroll.css";
import SkinScroller, { ClientSkin } from "./SkinScroller";
import { getScrollPage } from "../../../data/skins";
import SkinModel from "../../../data/SkinModel";
async function getClientSkins(offset: number): Promise<ClientSkin[]> {
async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
"use server";
const ctx = new UserContext();
const connection = new SkinsConnection(10, offset, "MUSEUM", null);
const skins = await connection.nodes(ctx);
if (skins == null) {
return [];
}
const classicSkins = skins.filter(
(skin): skin is ClassicSkinResolver => skin instanceof ClassicSkinResolver
);
const clientSkins: ClientSkin[] = await Promise.all(
classicSkins.map(async (skin) => {
const url = await skin.screenshot_url();
const readmeText = await skin.readme_text();
const page = await getScrollPage(sessionId);
const skins = await Promise.all(
page.map(async (item) => {
const model = await SkinModel.fromMd5Assert(ctx, item.md5);
const readmeText = await model.getReadme();
return {
screenshotUrl: url,
md5: skin.md5(),
fileName: await skin.filename(true),
screenshotUrl: model.getScreenshotUrl(),
md5: item.md5,
// TODO: Normalize to .wsz
fileName: await model.getFileName(),
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
};
})
);
return clientSkins;
for (const skin of skins) {
SessionModel.addSkin(sessionId, skin.md5);
}
return skins;
}
/**
* A tik-tok style scroll page where we display one skin at a time in full screen
*/
export default async function ScrollPage() {
const initialSkins = await getClientSkins(0);
// Create the session in the database
const sessionId = await SessionModel.create();
return <SkinScroller initialSkins={initialSkins} getSkins={getClientSkins} />;
const initialSkins = await getClientSkins(sessionId);
return (
<SkinScroller
initialSkins={initialSkins}
getSkins={getClientSkins}
sessionId={sessionId}
/>
);
}

View file

@ -0,0 +1,29 @@
import { knex } from "../db";
import { randomUUID } from "crypto";
export default class SessionModel {
static async create(): Promise<string> {
const sessionId = randomUUID();
const startTime = Date.now();
await knex("session").insert({
id: sessionId,
start_time: startTime,
});
return sessionId;
}
static async addSkin(sessionId: string, skinMd5: string): Promise<void> {
await knex("session_skin").insert({
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;
}
}

View file

@ -718,6 +718,53 @@ export type MuseumPage = Array<{
nsfw: boolean;
}>;
export type ScrollPage = Array<{
md5: string;
}>;
const PAGE_SIZE = 50;
const FRESHNESS_PERCENTAGE = 0.2; // 20% random skins
const randomCount = Math.floor(PAGE_SIZE * FRESHNESS_PERCENTAGE);
const curatedCount = PAGE_SIZE - randomCount;
export async function getScrollPage(sessionId: string): Promise<ScrollPage> {
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]
);
const randomSkins = await knex.raw(
`
SELECT
skins.md5 as skin_md5
FROM
skins
LEFT JOIN session_skin ON session_skin.skin_md5 = skins.md5 AND session_skin.session_id = ?
LEFT JOIN skin_reviews ON skin_reviews.skin_md5 = skins.md5 AND skin_reviews.review = 'NSFW'
WHERE
skins.skin_type = 1 AND session_skin.skin_md5 IS NULL AND skin_reviews.skin_md5 IS NULL
ORDER BY RANDOM()
LIMIT ?`,
[sessionId, randomCount]
);
// Note: Technically we could get duplicates if a random skin is also in museum_sort_order,
// but in practice this is rare and acceptable for the use case.
const allSkins = skins.concat(randomSkins);
// Shuffle the results
allSkins.sort(() => Math.random() - 0.5);
return allSkins.map(({ skin_md5 }) => {
return { md5: skin_md5 };
});
}
export async function getMuseumPage({
offset,
first,

View file

@ -165,6 +165,12 @@ export const getRouteData = createSelector(
getPermalinkUrlFromHash,
skinData
) => {
const currentUrl = window.location.href;
// Hard code ignore "modern" paths for now.
if (currentUrl.includes("/scroll")) {
return { url: "/scroll/", title: "Scroll" };
}
if (activeContentPage === REVIEW_PAGE) {
return { url: "/review/", title: "Review" };
}

View file

@ -0,0 +1,21 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE TABLE user_log_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
metadata TEXT NOT NULL
);
CREATE INDEX idx_session_id ON user_log_events(session_id);
CREATE INDEX idx_timestamp ON user_log_events(timestamp);
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP TABLE IF EXISTS user_log_events;
`);
}

View file

@ -0,0 +1,36 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
start_time INTEGER NOT NULL
);
`);
await knex.raw(`
CREATE TABLE session_skin (
session_id TEXT NOT NULL,
skin_md5 TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES session(id)
);
`);
await knex.raw(`
CREATE INDEX idx_session_skin_session_id ON session_skin(session_id);
`);
await knex.raw(`
CREATE INDEX idx_session_skin_skin_md5 ON session_skin(skin_md5);
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP TABLE IF EXISTS session_skin;
`);
await knex.raw(`
DROP TABLE IF EXISTS session;
`);
}