v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,18 +5,28 @@
|
||||
* the month ending at the last refresh, which {@link communityJob} redoes
|
||||
* every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query.
|
||||
*
|
||||
* It also gathers the community feed ({@link getCommunityFeed}), the latest
|
||||
* things done in public, which is read from the tables on each request: it
|
||||
* has to show a dump the moment it is posted.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { db } from "../db/connection.ts";
|
||||
import { COMMUNITY_REFRESH_MINUTES } from "../config.ts";
|
||||
import type { CommunityStats } from "../../shared/community.ts";
|
||||
import type {
|
||||
CommunityEvent,
|
||||
CommunityFeed,
|
||||
CommunityItem,
|
||||
CommunityStats,
|
||||
} from "../../shared/community.ts";
|
||||
import {
|
||||
DIGEST_CADENCES,
|
||||
type DigestCadence,
|
||||
PERIOD_MS,
|
||||
} from "../../shared/settings.ts";
|
||||
import type { Job } from "../lib/scheduler.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
import { listTopDumps } from "./dump-service.ts";
|
||||
import { listNewPublicPlaylists } from "./playlist-service.ts";
|
||||
import { listNewUsers } from "./user-service.ts";
|
||||
@@ -91,3 +101,148 @@ export const communityJob: Job = {
|
||||
firstAfterMs: 0,
|
||||
run: () => refreshCommunityStats(),
|
||||
};
|
||||
|
||||
/** How many events the feed holds, and how many of each kind it reads. */
|
||||
const FEED_MAX = 12;
|
||||
|
||||
/**
|
||||
* A row of {@link listCommunityEvents}'s query, whose five parts share these
|
||||
* columns. `title`, `slug` and `owner_username` name the dump or the playlist
|
||||
* of every event but a `user_joined`.
|
||||
*/
|
||||
interface EventRow {
|
||||
type: CommunityEvent["type"];
|
||||
/** The dump, the playlist or the new user. */
|
||||
subject_id: string;
|
||||
at: string;
|
||||
username: string;
|
||||
title: string | null;
|
||||
slug: string | null;
|
||||
owner_username: string | null;
|
||||
comment_id: string | null;
|
||||
invited_by: string | null;
|
||||
}
|
||||
|
||||
/** Checks `row`'s columns that every event has. */
|
||||
function isEventRow(row: unknown): row is EventRow {
|
||||
const r = row as Partial<EventRow> | null;
|
||||
return !!r && typeof r.type === "string" &&
|
||||
typeof r.subject_id === "string" && typeof r.at === "string" &&
|
||||
typeof r.username === "string" &&
|
||||
(r.type === "user_joined" ||
|
||||
(typeof r.title === "string" && typeof r.slug === "string" &&
|
||||
typeof r.owner_username === "string"));
|
||||
}
|
||||
|
||||
function eventOf(row: EventRow): CommunityEvent {
|
||||
const base = {
|
||||
id: `${row.type}:${row.subject_id}`,
|
||||
at: new Date(row.at),
|
||||
username: row.username,
|
||||
};
|
||||
// `isEventRow` has checked the three on every row that reads `item`.
|
||||
const item: CommunityItem = {
|
||||
title: row.title!,
|
||||
slug: row.slug!,
|
||||
ownerUsername: row.owner_username!,
|
||||
};
|
||||
switch (row.type) {
|
||||
case "dump_posted":
|
||||
case "dump_upvoted":
|
||||
return { ...base, type: row.type, dump: item };
|
||||
case "dump_commented":
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
dump: item,
|
||||
commentId: row.comment_id!,
|
||||
};
|
||||
case "playlist_created":
|
||||
return { ...base, type: row.type, playlist: item };
|
||||
case "user_joined":
|
||||
return {
|
||||
...base,
|
||||
type: row.type,
|
||||
invitedByUsername: row.invited_by ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest things done in public, newest first, {@link FEED_MAX} at
|
||||
* most: the public dumps posted and playlists created, the comments and
|
||||
* upvotes on public dumps, and who joined. A dump has one comment event and
|
||||
* one upvote event at most, its latest, so that a thread or a popular dump
|
||||
* doesn't fill the feed.
|
||||
*
|
||||
* Each part reads its {@link FEED_MAX} latest, which is all the whole may
|
||||
* keep of it. The comments and the upvotes are grouped by dump first, and
|
||||
* with `MAX()` SQLite fills a group's other columns from the row that holds
|
||||
* the maximum: the latest comment's author and id, the latest voter.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as an event.
|
||||
*/
|
||||
export function listCommunityEvents(): CommunityEvent[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM (
|
||||
SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at,
|
||||
u.username AS username, d.title AS title, d.slug AS slug,
|
||||
u.username AS owner_username, NULL AS comment_id,
|
||||
NULL AS invited_by
|
||||
FROM dumps d JOIN users u ON u.id = d.user_id
|
||||
WHERE d.is_private = 0
|
||||
ORDER BY d.created_at DESC LIMIT ${FEED_MAX})
|
||||
UNION ALL SELECT * FROM (
|
||||
SELECT 'playlist_created', p.id, p.created_at, u.username, p.title,
|
||||
p.slug, u.username, NULL, NULL
|
||||
FROM playlists p JOIN users u ON u.id = p.user_id
|
||||
WHERE p.is_public = 1
|
||||
ORDER BY p.created_at DESC LIMIT ${FEED_MAX})
|
||||
UNION ALL SELECT * FROM (
|
||||
SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username,
|
||||
d.title, d.slug, ou.username, c.id, NULL
|
||||
FROM comments c
|
||||
JOIN dumps d ON d.id = c.dump_id
|
||||
JOIN users cu ON cu.id = c.user_id
|
||||
JOIN users ou ON ou.id = d.user_id
|
||||
WHERE c.deleted = 0 AND d.is_private = 0
|
||||
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
|
||||
UNION ALL SELECT * FROM (
|
||||
SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username,
|
||||
d.title, d.slug, ou.username, NULL, NULL
|
||||
FROM votes v
|
||||
JOIN dumps d ON d.id = v.dump_id
|
||||
JOIN users vu ON vu.id = v.user_id
|
||||
JOIN users ou ON ou.id = d.user_id
|
||||
WHERE d.is_private = 0
|
||||
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
|
||||
UNION ALL SELECT * FROM (
|
||||
SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL,
|
||||
NULL, i.username
|
||||
FROM users u LEFT JOIN users i ON i.id = u.invited_by
|
||||
ORDER BY u.created_at DESC LIMIT ${FEED_MAX})
|
||||
ORDER BY at DESC LIMIT ${FEED_MAX};`,
|
||||
).all();
|
||||
return parseRows(rows, isEventRow, eventOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the community feed for `GET /api/community/feed`: the latest
|
||||
* events, read now, and the last week's figures as of the last refresh.
|
||||
*/
|
||||
export function getCommunityFeed(): CommunityFeed {
|
||||
const { totals, chatMessages, top } = getCommunityStats("weekly");
|
||||
const [best] = top;
|
||||
return {
|
||||
events: listCommunityEvents(),
|
||||
week: {
|
||||
totals,
|
||||
chatMessages,
|
||||
top: best && {
|
||||
title: best.title,
|
||||
slug: best.slug,
|
||||
ownerUsername: best.ownerUsername,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user