diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts new file mode 100644 index 00000000..ef917ee1 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/Events.ts @@ -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; + }; diff --git a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx new file mode 100644 index 00000000..f567c280 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx @@ -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 ( +
+ {skin.fileName} +
+

+ {skin.fileName} +

+

+ {skin.readmeStart} +

+
+
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx index aff10b26..3d3d467b 100644 --- a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx +++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx @@ -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; + getSkins: (sessionId: string, offset: number) => Promise; + sessionId: string; }; -export default function SkinScroller({ initialSkins, getSkins }: Props) { +export default function SkinScroller({ + initialSkins, + getSkins, + sessionId, +}: Props) { const [skins, setSkins] = useState(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 ( -
- {skin.fileName} -
-

- {skin.fileName} -

-

- {skin.readmeStart} -

-
-
+ skin={skin} + index={i} + sessionId={sessionId} + /> ); })} diff --git a/packages/skin-database/app/(modern)/scroll/page.tsx b/packages/skin-database/app/(modern)/scroll/page.tsx index 5bc0c14f..af52bf08 100644 --- a/packages/skin-database/app/(modern)/scroll/page.tsx +++ b/packages/skin-database/app/(modern)/scroll/page.tsx @@ -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 { +async function getClientSkins(sessionId: string): Promise { "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 ; + const initialSkins = await getClientSkins(sessionId); + + return ( + + ); } diff --git a/packages/skin-database/data/SessionModel.ts b/packages/skin-database/data/SessionModel.ts new file mode 100644 index 00000000..b6d3c045 --- /dev/null +++ b/packages/skin-database/data/SessionModel.ts @@ -0,0 +1,29 @@ +import { knex } from "../db"; +import { randomUUID } from "crypto"; + +export default class SessionModel { + static async create(): Promise { + 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 { + await knex("session_skin").insert({ + session_id: sessionId, + skin_md5: skinMd5, + }); + } + + static async getIncludedSkinCount(sessionId: string): Promise { + const result = await knex("session_skin") + .where({ session_id: sessionId }) + .count("* as count") + .first(); + return result ? (result.count as number) : 0; + } +} diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts index 6d48f701..7fef938c 100644 --- a/packages/skin-database/data/skins.ts +++ b/packages/skin-database/data/skins.ts @@ -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 { + 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, diff --git a/packages/skin-database/legacy-client/src/redux/selectors.js b/packages/skin-database/legacy-client/src/redux/selectors.js index 33bdfdd1..83b8ff6c 100644 --- a/packages/skin-database/legacy-client/src/redux/selectors.js +++ b/packages/skin-database/legacy-client/src/redux/selectors.js @@ -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" }; } diff --git a/packages/skin-database/migrations/20251106000000_user_log_events.ts b/packages/skin-database/migrations/20251106000000_user_log_events.ts new file mode 100644 index 00000000..1f35cc39 --- /dev/null +++ b/packages/skin-database/migrations/20251106000000_user_log_events.ts @@ -0,0 +1,21 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.raw(` + DROP TABLE IF EXISTS user_log_events; + `); +} diff --git a/packages/skin-database/migrations/20251107000000_session_tables.ts b/packages/skin-database/migrations/20251107000000_session_tables.ts new file mode 100644 index 00000000..dc212648 --- /dev/null +++ b/packages/skin-database/migrations/20251107000000_session_tables.ts @@ -0,0 +1,36 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + 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 { + await knex.raw(` + DROP TABLE IF EXISTS session_skin; + `); + + await knex.raw(` + DROP TABLE IF EXISTS session; + `); +}