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:
6
API.md
6
API.md
@@ -216,6 +216,12 @@ there: those endpoints answer it 400.
|
||||
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
|
||||
new members, new public playlists and totals over the last week (by default)
|
||||
or month, as of the server's last refresh.
|
||||
- `GET /api/community/feed` — signed in. The community feed: the 12 latest
|
||||
things done in public, newest first (a public dump posted, a public playlist
|
||||
created, a comment or an upvote on a public dump, someone joining), and the
|
||||
last week's totals and most upvoted dump, as of the server's last refresh. A
|
||||
dump has one comment event and one upvote event at most, its latest. Every
|
||||
reader gets the same feed.
|
||||
|
||||
### chat
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* Serves the figures of the client's `/community` page, which
|
||||
* `community-service.ts` recomputes on a timer.
|
||||
* `community-service.ts` recomputes on a timer, and the community feed.
|
||||
*
|
||||
* - `GET /api/community?period=weekly|monthly` — signed in. Answers the
|
||||
* site's figures over the last week (by default) or month.
|
||||
* - `GET /api/community/feed` — signed in. Answers the latest things done in
|
||||
* public and the week's figures.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { CommunityStats } from "../../shared/community.ts";
|
||||
import type { CommunityFeed, CommunityStats } from "../../shared/community.ts";
|
||||
import { isDigestCadence } from "../../shared/settings.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { getCommunityStats } from "../services/community-service.ts";
|
||||
import {
|
||||
getCommunityFeed,
|
||||
getCommunityStats,
|
||||
} from "../services/community-service.ts";
|
||||
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
@@ -34,4 +39,12 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/feed", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<CommunityFeed> = {
|
||||
success: true,
|
||||
data: getCommunityFeed(),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createUser, db, get, insertDump, test } from "../helpers.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import type { CommunityStats } from "../../../shared/community.ts";
|
||||
import type {
|
||||
CommunityFeed,
|
||||
CommunityStats,
|
||||
} from "../../../shared/community.ts";
|
||||
import { refreshCommunityStats } from "../../services/community-service.ts";
|
||||
|
||||
function vote(dumpId: string, n: number) {
|
||||
@@ -33,3 +36,25 @@ test("GET /api/community: signed in only, for a week or a month", async () => {
|
||||
});
|
||||
assertEquals(month.data.totals.dumps, 1);
|
||||
});
|
||||
|
||||
test("GET /api/community/feed: signed in only, the latest events and the week's figures", async () => {
|
||||
const ann = await createUser();
|
||||
insertDump({
|
||||
userId: ann.id,
|
||||
title: "Hit",
|
||||
createdAt: new Date(Date.now() - 1000).toISOString(),
|
||||
});
|
||||
refreshCommunityStats();
|
||||
|
||||
assertEquals((await get("/api/community/feed")).status, 401);
|
||||
const feed = await get<CommunityFeed>("/api/community/feed", {
|
||||
auth: ann.auth,
|
||||
});
|
||||
assertEquals(feed.status, 200);
|
||||
assertEquals(feed.data.events.map((e) => e.type), [
|
||||
"user_joined",
|
||||
"dump_posted",
|
||||
]);
|
||||
assertEquals(feed.data.week.totals.dumps, 1);
|
||||
assertEquals(feed.data.week.top, undefined);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { assertEquals } from "@std/assert";
|
||||
import {
|
||||
communityStatsOf,
|
||||
getCommunityFeed,
|
||||
getCommunityStats,
|
||||
listCommunityEvents,
|
||||
refreshCommunityStats,
|
||||
} from "../../services/community-service.ts";
|
||||
|
||||
@@ -102,3 +104,109 @@ test("getCommunityStats: serves the last refresh, of the week or the month", asy
|
||||
"Later",
|
||||
]);
|
||||
});
|
||||
|
||||
function castVote(dumpId: string, userId: string, at: string) {
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(dumpId, userId, at);
|
||||
}
|
||||
|
||||
test("listCommunityEvents: what was done in public, newest first, a dump's latest comment and upvote alone", async () => {
|
||||
const ann = await createUser({ createdAt: ago(9 * DAY) });
|
||||
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(8 * DAY) });
|
||||
const hit = insertDump({
|
||||
userId: ann.id,
|
||||
title: "Hit",
|
||||
createdAt: ago(7 * DAY),
|
||||
});
|
||||
const secret = insertDump({
|
||||
userId: ann.id,
|
||||
title: "Secret",
|
||||
isPrivate: true,
|
||||
});
|
||||
insertComment({ dumpId: hit.id, userId: ann.id, createdAt: ago(6 * DAY) });
|
||||
const last = insertComment({
|
||||
dumpId: hit.id,
|
||||
userId: bob.id,
|
||||
createdAt: ago(5 * DAY),
|
||||
});
|
||||
const gone = insertComment({ dumpId: hit.id, userId: ann.id });
|
||||
db.prepare(`UPDATE comments SET deleted = 1 WHERE id = ?;`).run(gone);
|
||||
insertComment({ dumpId: secret.id, userId: ann.id });
|
||||
castVote(hit.id, bob.id, ago(4 * DAY));
|
||||
castVote(hit.id, ann.id, ago(3 * DAY));
|
||||
castVote(secret.id, ann.id, ago(0));
|
||||
const mix = insertPlaylist({ userId: bob.id, title: "Mix" });
|
||||
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
|
||||
|
||||
const events = listCommunityEvents();
|
||||
assertEquals(
|
||||
events.map((e) => [e.type, e.username]),
|
||||
[
|
||||
["playlist_created", bob.username],
|
||||
["dump_upvoted", ann.username],
|
||||
["dump_commented", bob.username],
|
||||
["dump_posted", ann.username],
|
||||
["user_joined", bob.username],
|
||||
["user_joined", ann.username],
|
||||
],
|
||||
);
|
||||
const [playlist, upvote, comment, , joined] = events;
|
||||
assertEquals(playlist.type === "playlist_created" && playlist.playlist, {
|
||||
title: "Mix",
|
||||
slug: mix.slug,
|
||||
ownerUsername: bob.username,
|
||||
});
|
||||
assertEquals(upvote.type === "dump_upvoted" && upvote.dump, {
|
||||
title: "Hit",
|
||||
slug: hit.slug,
|
||||
ownerUsername: ann.username,
|
||||
});
|
||||
assertEquals(upvote.id, `dump_upvoted:${hit.id}`);
|
||||
assertEquals(comment.type === "dump_commented" && comment.commentId, last);
|
||||
assertEquals(
|
||||
joined.type === "user_joined" && joined.invitedByUsername,
|
||||
ann.username,
|
||||
);
|
||||
});
|
||||
|
||||
test("listCommunityEvents: keeps the 12 latest", async () => {
|
||||
const ann = await createUser({ createdAt: ago(DAY) });
|
||||
for (let i = 0; i < 14; i++) {
|
||||
insertDump({
|
||||
userId: ann.id,
|
||||
title: `Dump ${i}`,
|
||||
createdAt: ago(i * 1000),
|
||||
});
|
||||
}
|
||||
const events = listCommunityEvents();
|
||||
assertEquals(events.length, 12);
|
||||
assertEquals(
|
||||
events.map((e) => e.type === "dump_posted" && e.dump.title),
|
||||
Array.from({ length: 12 }, (_, i) => `Dump ${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("getCommunityFeed: the events as they are now, the week's figures as of the last refresh", async () => {
|
||||
const ann = await createUser({ createdAt: ago(20 * DAY) });
|
||||
const hit = insertDump({
|
||||
userId: ann.id,
|
||||
title: "Hit",
|
||||
createdAt: ago(1000),
|
||||
});
|
||||
vote(hit.id, 3);
|
||||
refreshCommunityStats();
|
||||
insertDump({ userId: ann.id, title: "Later" });
|
||||
|
||||
const feed = getCommunityFeed();
|
||||
assertEquals(
|
||||
feed.events.map((e) => e.type === "dump_posted" && e.dump.title),
|
||||
["Later", "Hit", false],
|
||||
);
|
||||
assertEquals(feed.week.totals.dumps, 1);
|
||||
assertEquals(feed.week.top, {
|
||||
title: "Hit",
|
||||
slug: hit.slug,
|
||||
ownerUsername: ann.username,
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -231,17 +231,37 @@ msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:114
|
||||
#: src/pages/Notifications.tsx:239
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> commented on <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: event.playlist.title
|
||||
#: src/components/social/CommunityFeed.tsx:127
|
||||
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> created the playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:199
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#: src/components/social/CommunityFeed.tsx:144
|
||||
msgid "<0>{0}</0> joined"
|
||||
msgstr "<0>{0}</0> joined"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: event.invitedByUsername
|
||||
#: src/components/social/CommunityFeed.tsx:138
|
||||
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> joined, invited by <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:249
|
||||
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:94
|
||||
#: src/pages/Notifications.tsx:209
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> started following you"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:104
|
||||
#: src/pages/Notifications.tsx:229
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||
|
||||
#: src/components/social/CommunityFeed.tsx:155
|
||||
msgid "<0>Online now:</0> {names}"
|
||||
msgstr "<0>Online now:</0> {names}"
|
||||
|
||||
#: src/components/social/CommunityFeed.tsx:162
|
||||
msgid "<0>This week:</0> <1>{figures}</1>"
|
||||
msgstr "<0>This week:</0> <1>{figures}</1>"
|
||||
|
||||
#. placeholder {0}: top.title
|
||||
#: src/components/social/CommunityFeed.tsx:170
|
||||
msgid "<0>Top this week:</0> <1>{0}</1>"
|
||||
msgstr "<0>Top this week:</0> <1>{0}</1>"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:778
|
||||
msgid "1 new message"
|
||||
msgstr "1 new message"
|
||||
@@ -396,10 +435,6 @@ msgstr "Back to login"
|
||||
msgid "Back to the login page"
|
||||
msgstr "Back to the login page"
|
||||
|
||||
#: src/components/layout/Marquee.tsx:27
|
||||
msgid "Best viewed in Netscape Navigator at 800×600"
|
||||
msgstr "Best viewed in Netscape Navigator at 800×600"
|
||||
|
||||
#: src/components/federation/PeerManager.tsx:184
|
||||
#: src/components/federation/PeerManager.tsx:190
|
||||
msgid "Block"
|
||||
@@ -1864,10 +1899,6 @@ msgstr "This month"
|
||||
msgid "This page does not exist."
|
||||
msgstr "This page does not exist."
|
||||
|
||||
#: src/components/layout/Marquee.tsx:28
|
||||
msgid "This page is under construction"
|
||||
msgstr "This page is under construction"
|
||||
|
||||
#: src/pages/FederationCallback.tsx:40
|
||||
msgid "This sign-in link is incomplete."
|
||||
msgstr "This sign-in link is incomplete."
|
||||
@@ -2099,10 +2130,6 @@ msgstr "Volume"
|
||||
msgid "digest.weekly"
|
||||
msgstr "Weekly digest"
|
||||
|
||||
#: src/components/layout/Marquee.tsx:26
|
||||
msgid "Welcome to {SITE_NAME}!"
|
||||
msgstr "Welcome to {SITE_NAME}!"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: api/mail/welcome.ts:69
|
||||
msgid "email.welcome.subject"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -231,17 +231,37 @@ msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:114
|
||||
#: src/pages/Notifications.tsx:239
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: event.playlist.title
|
||||
#: src/components/social/CommunityFeed.tsx:127
|
||||
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a créé la playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:199
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#: src/components/social/CommunityFeed.tsx:144
|
||||
msgid "<0>{0}</0> joined"
|
||||
msgstr "<0>{0}</0> a rejoint le site"
|
||||
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: event.invitedByUsername
|
||||
#: src/components/social/CommunityFeed.tsx:138
|
||||
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a rejoint le site, sur invitation de <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:249
|
||||
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:94
|
||||
#: src/pages/Notifications.tsx:209
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:104
|
||||
#: src/pages/Notifications.tsx:229
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a validé <1>{1}</1>"
|
||||
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
|
||||
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
|
||||
|
||||
#: src/components/social/CommunityFeed.tsx:155
|
||||
msgid "<0>Online now:</0> {names}"
|
||||
msgstr "<0>En ligne :</0> {names}"
|
||||
|
||||
#: src/components/social/CommunityFeed.tsx:162
|
||||
msgid "<0>This week:</0> <1>{figures}</1>"
|
||||
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
|
||||
|
||||
#. placeholder {0}: top.title
|
||||
#: src/components/social/CommunityFeed.tsx:170
|
||||
msgid "<0>Top this week:</0> <1>{0}</1>"
|
||||
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:778
|
||||
msgid "1 new message"
|
||||
msgstr "1 nouveau message"
|
||||
@@ -396,10 +435,6 @@ msgstr "Retour à la connexion"
|
||||
msgid "Back to the login page"
|
||||
msgstr "Retour à la page de connexion"
|
||||
|
||||
#: src/components/layout/Marquee.tsx:27
|
||||
msgid "Best viewed in Netscape Navigator at 800×600"
|
||||
msgstr "Optimisé pour Netscape Navigator en 800×600"
|
||||
|
||||
#: src/components/federation/PeerManager.tsx:184
|
||||
#: src/components/federation/PeerManager.tsx:190
|
||||
msgid "Block"
|
||||
@@ -1864,10 +1899,6 @@ msgstr "Ce mois-ci"
|
||||
msgid "This page does not exist."
|
||||
msgstr "Rien à voir, circulez."
|
||||
|
||||
#: src/components/layout/Marquee.tsx:28
|
||||
msgid "This page is under construction"
|
||||
msgstr "Cette page est en construction"
|
||||
|
||||
#: src/pages/FederationCallback.tsx:40
|
||||
msgid "This sign-in link is incomplete."
|
||||
msgstr "Ce lien de connexion est incomplet."
|
||||
@@ -2099,10 +2130,6 @@ msgstr "Volume"
|
||||
msgid "digest.weekly"
|
||||
msgstr "Résumé hebdomadaire"
|
||||
|
||||
#: src/components/layout/Marquee.tsx:26
|
||||
msgid "Welcome to {SITE_NAME}!"
|
||||
msgstr "Bienvenue sur {SITE_NAME} !"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: api/mail/welcome.ts:69
|
||||
msgid "email.welcome.subject"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* What the whole site did over a period, one of the digests' cadences: its
|
||||
* most upvoted dumps, who joined, the new public playlists and the totals.
|
||||
* This module types it for the `/community` page, which shows it as it was at
|
||||
* the server's last refresh; the digests gather the same figures for each
|
||||
* reader's own period.
|
||||
* What the whole site did, told two ways. Over a period, one of the digests'
|
||||
* cadences: its most upvoted dumps, who joined, the new public playlists and
|
||||
* the totals, which the `/community` page shows as they were at the server's
|
||||
* last refresh and the digests gather for each reader's own period. And as it
|
||||
* happens: the community feed, the latest things its members did in public,
|
||||
* which the GeoCities style's marquee scrolls.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "./dump.ts";
|
||||
import type { OwnedItem } from "./paths.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
@@ -30,3 +32,86 @@ export interface CommunityStats {
|
||||
/** Public dumps posted, upvotes cast and comments written in it. */
|
||||
totals: { dumps: number; upvotes: number; comments: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a dump or a playlist in a {@link CommunityEvent}: its title, and what
|
||||
* `dumpPath` or `playlistPath` builds its page's path from.
|
||||
*/
|
||||
export interface CommunityItem extends OwnedItem {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** What every {@link CommunityEvent} carries. */
|
||||
interface CommunityEventBase {
|
||||
/**
|
||||
* The event's kind and what it is about, `dump_upvoted:<dump id>`: unique in
|
||||
* a feed, where a dump has one event of each kind at most.
|
||||
*/
|
||||
id: string;
|
||||
at: Date;
|
||||
/** Who did it, by their current name. */
|
||||
username: string;
|
||||
}
|
||||
|
||||
/** A public dump was posted. A dump made public later keeps its date. */
|
||||
export interface DumpPostedEvent extends CommunityEventBase {
|
||||
type: "dump_posted";
|
||||
dump: CommunityItem;
|
||||
}
|
||||
|
||||
/** A public playlist was created. */
|
||||
export interface PlaylistCreatedEvent extends CommunityEventBase {
|
||||
type: "playlist_created";
|
||||
playlist: CommunityItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* A public dump got a comment. However many it got, the feed tells of its
|
||||
* latest alone.
|
||||
*/
|
||||
export interface DumpCommentedEvent extends CommunityEventBase {
|
||||
type: "dump_commented";
|
||||
dump: CommunityItem;
|
||||
/** For the `#comment-<id>` anchor on the dump's page. */
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
/** A public dump got an upvote, told as {@link DumpCommentedEvent} is. */
|
||||
export interface DumpUpvotedEvent extends CommunityEventBase {
|
||||
type: "dump_upvoted";
|
||||
dump: CommunityItem;
|
||||
}
|
||||
|
||||
/** Someone joined the site: `username` is theirs. */
|
||||
export interface UserJoinedEvent extends CommunityEventBase {
|
||||
type: "user_joined";
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
/** One line of the community feed, told apart by `type`. */
|
||||
export type CommunityEvent =
|
||||
| DumpPostedEvent
|
||||
| PlaylistCreatedEvent
|
||||
| DumpCommentedEvent
|
||||
| DumpUpvotedEvent
|
||||
| UserJoinedEvent;
|
||||
|
||||
/**
|
||||
* Returned by `GET /api/community/feed` to `CommunityFeedProvider`, which
|
||||
* asks again as the socket tells of changes, and rendered by the
|
||||
* `CommunityFeed` component with who is online, which the client knows from
|
||||
* its socket. `community-service.ts` gathers it. Everything in it is public:
|
||||
* every reader gets the same feed.
|
||||
*/
|
||||
export interface CommunityFeed {
|
||||
/** The latest events, newest first, 12 at most. */
|
||||
events: CommunityEvent[];
|
||||
/**
|
||||
* The last week's figures, as `/community` shows them: as of the server's
|
||||
* last refresh.
|
||||
*/
|
||||
week: Pick<CommunityStats, "totals" | "chatMessages"> & {
|
||||
/** The week's most upvoted dump, when one was upvoted. */
|
||||
top?: CommunityItem;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7833,3 +7833,12 @@ body.has-player .chat-fab {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Community feed ── */
|
||||
/* A bare list: the style that shows the feed lays it out, as GeoCities does
|
||||
along its marquee. */
|
||||
.community-feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -62,6 +62,7 @@ import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
|
||||
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
|
||||
import { WSProvider } from "./contexts/ws/WSProvider.tsx";
|
||||
import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
|
||||
import { CommunityFeedProvider } from "./contexts/social/CommunityFeedProvider.tsx";
|
||||
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
|
||||
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
|
||||
@@ -305,14 +306,16 @@ export function App(): React.JSX.Element {
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
<CommunityFeedProvider>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
</CommunityFeedProvider>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
|
||||
@@ -1,40 +1,128 @@
|
||||
/**
|
||||
* Renders the GeoCities style's marquee: a line of welcome that crawls across
|
||||
* Renders the GeoCities style's marquee: the community feed crawling across
|
||||
* the page under the header, as a `<marquee>` did on a homepage of that era.
|
||||
* `AppHeader` renders it on every page, and it draws nothing in any other
|
||||
* style.
|
||||
* `AppHeader` renders it on every page. It draws nothing in any other style,
|
||||
* nor for a visitor, the feed being for signed-in users.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
|
||||
import type { OnlineUser } from "../../../shared/ws.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useCommunityFeed } from "../../hooks/social/useCommunityFeed.ts";
|
||||
import { useTheme } from "../../hooks/theme/useTheme.ts";
|
||||
import { SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
import { useWSPresence } from "../../hooks/ws/useWS.ts";
|
||||
import { CommunityFeed } from "../social/CommunityFeed.tsx";
|
||||
|
||||
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function onMotionChange(callback: () => void): () => void {
|
||||
const query = matchMedia(REDUCED_MOTION);
|
||||
query.addEventListener("change", callback);
|
||||
return () => query.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
// When the crawl began its current loop. It is kept out of the component,
|
||||
// which every page mounts anew with its header: the next page's marquee
|
||||
// carries on from where the last one's had got to, rather than from the
|
||||
// start.
|
||||
let loopStart: number | undefined;
|
||||
|
||||
/** What one loop of the crawl shows, from its start to its end. */
|
||||
interface Loop {
|
||||
feed: CommunityFeedData;
|
||||
online: OnlineUser[];
|
||||
/** Counts the loops, and keys the track: a new loop is a new animation. */
|
||||
turn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the banner's text twice, side by side: geocities.css slides the
|
||||
* pair left by half its width, so the second copy arrives where the first
|
||||
* began and the loop has no seam. It is decoration, hidden from assistive
|
||||
* technology, and stands still for a reader who asked for reduced motion.
|
||||
* Renders the feed twice, side by side: geocities.css slides the pair left by
|
||||
* the width of one, so the second copy arrives where the first began and the
|
||||
* loop has no seam. The second copy is hidden from assistive technology and
|
||||
* from the Tab key.
|
||||
*
|
||||
* A loop shows what the feed held when it began. Text that changed mid-crawl
|
||||
* would jump, the track's width being what the slide is measured by, so what
|
||||
* came meanwhile waits for the next loop, which also brings the lines' times
|
||||
* up to date. The crawl's speed is the style's: this sets `--marquee-length`,
|
||||
* one copy's width in pixels, from which the stylesheet works out how long a
|
||||
* loop lasts.
|
||||
*
|
||||
* For a reader who asked for reduced motion nothing moves or repeats: the
|
||||
* line shows once, and follows the feed as it changes.
|
||||
*/
|
||||
export function Marquee(): React.JSX.Element | null {
|
||||
const { style } = useTheme();
|
||||
if (style !== "geocities") return null;
|
||||
|
||||
const line = (
|
||||
<span>
|
||||
✦ <Trans>Welcome to {SITE_NAME}!</Trans> ✦{" "}
|
||||
<Trans>Best viewed in Netscape Navigator at 800×600</Trans> ✦{" "}
|
||||
<Trans>This page is under construction</Trans> ✦
|
||||
</span>
|
||||
function FeedMarquee(): React.JSX.Element {
|
||||
const feed = useCommunityFeed();
|
||||
const { onlineUsers: online } = useWSPresence();
|
||||
const still = useSyncExternalStore(
|
||||
onMotionChange,
|
||||
() => matchMedia(REDUCED_MOTION).matches,
|
||||
);
|
||||
const [loop, setLoop] = useState<Loop | null>(null);
|
||||
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
||||
const shown = still ? feed && { feed, online } : loop;
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const track = trackRef.current;
|
||||
const length = track?.firstElementChild?.getBoundingClientRect().width;
|
||||
if (!track || !length) return;
|
||||
track.style.setProperty("--marquee-length", String(Math.round(length)));
|
||||
loopStart ??= performance.now();
|
||||
// A negative delay starts the animation that far in.
|
||||
track.style.animationDelay = `${(loopStart - performance.now()) / 1000}s`;
|
||||
}, [loop?.turn, still]);
|
||||
|
||||
return (
|
||||
<div className="site-marquee" aria-hidden="true">
|
||||
<div className="site-marquee-track">
|
||||
{line}
|
||||
{line}
|
||||
</div>
|
||||
<div
|
||||
className="site-marquee"
|
||||
// A focused link stops the crawl, and the browser scrolls the line to
|
||||
// show it: the line goes back to its start once the focus has left.
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
e.currentTarget.scrollLeft = 0;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shown && (
|
||||
<div
|
||||
key={loop?.turn}
|
||||
ref={trackRef}
|
||||
className="site-marquee-track"
|
||||
onAnimationIteration={(e) => {
|
||||
// Not a child's animation ending a round of its own.
|
||||
if (e.target !== e.currentTarget || !feed || !loop) return;
|
||||
loopStart = performance.now();
|
||||
setLoop({ feed, online, turn: loop.turn + 1 });
|
||||
}}
|
||||
>
|
||||
<CommunityFeed feed={shown.feed} online={shown.online} />
|
||||
{!still && (
|
||||
<div aria-hidden="true">
|
||||
<CommunityFeed
|
||||
feed={shown.feed}
|
||||
online={shown.online}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the marquee for a signed-in user in the GeoCities style, an empty
|
||||
* band until the feed is loaded, so that the page under it doesn't move when
|
||||
* it comes.
|
||||
*/
|
||||
export function Marquee(): React.JSX.Element | null {
|
||||
const { style } = useTheme();
|
||||
const { user } = useAuth();
|
||||
if (style !== "geocities" || !user) return null;
|
||||
return <FeedMarquee />;
|
||||
}
|
||||
|
||||
196
src/components/social/CommunityFeed.tsx
Normal file
196
src/components/social/CommunityFeed.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Renders the community feed: who is online, the week's figures, and the
|
||||
* latest things the site's members did in public, each a line with links to
|
||||
* who and what it tells of. The GeoCities style's `Marquee` scrolls it, and
|
||||
* any style may show it: it is a plain list, which the style lays out.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import type {
|
||||
CommunityEvent,
|
||||
CommunityFeed as CommunityFeedData,
|
||||
} from "../../../shared/community.ts";
|
||||
import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
|
||||
import {
|
||||
communityPath,
|
||||
dumpPath,
|
||||
playlistPath,
|
||||
userPath,
|
||||
} from "../../../shared/paths.ts";
|
||||
import type { OnlineUser } from "../../../shared/ws.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
|
||||
// Past this many, the online line counts the others: "+3".
|
||||
const ONLINE_MAX = 8;
|
||||
|
||||
/** Passed to {@link CommunityFeed} by whatever shows the feed. */
|
||||
export interface CommunityFeedProps {
|
||||
/** From `useCommunityFeed`. */
|
||||
feed: CommunityFeedData;
|
||||
/** From `useWSPresence`: the feed opens on them. */
|
||||
online: OnlineUser[];
|
||||
/**
|
||||
* Given to every link. `-1` takes them out of the tab order, for a second
|
||||
* copy of the list such as the one the marquee's loop needs.
|
||||
*/
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a name to its profile. It wraps the name, rather than take it as a
|
||||
* prop, so that a sentence reads `<0>{0}</0> posted <1>{1}</1>` to Lingui, as
|
||||
* the same sentence of the notifications page does, and shares its
|
||||
* translation.
|
||||
*/
|
||||
function User(
|
||||
{ children, tabIndex }: { children: string; tabIndex?: number },
|
||||
) {
|
||||
return <Link to={userPath(children)} tabIndex={tabIndex}>{children}</Link>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the feed as a `<ul>`: a line for who is online, one for the week's
|
||||
* figures, which links to `/community`, one for its most upvoted dump, then
|
||||
* one per event, newest first, ending on how long ago it was. A line with
|
||||
* nothing to say is left out. The times are computed at render.
|
||||
*/
|
||||
export function CommunityFeed(
|
||||
{ feed, online, tabIndex }: CommunityFeedProps,
|
||||
): React.JSX.Element {
|
||||
const { i18n } = useLingui();
|
||||
const { totals, chatMessages, top } = feed.week;
|
||||
const figures = ([
|
||||
[totals.dumps, DIGEST_MESSAGES.statDumps],
|
||||
[totals.upvotes, DIGEST_MESSAGES.countUpvotes],
|
||||
[totals.comments, DIGEST_MESSAGES.countComments],
|
||||
[chatMessages, DIGEST_MESSAGES.statChat],
|
||||
] as const)
|
||||
.filter(([n]) => n > 0)
|
||||
.map(([n, label]) =>
|
||||
`${i18n.number(n)} ${i18n._({ ...label, values: { n } })}`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const names = (
|
||||
<>
|
||||
{online.slice(0, ONLINE_MAX).map((u, i) => (
|
||||
<span key={u.userId}>
|
||||
{i > 0 && ", "}
|
||||
<User tabIndex={tabIndex}>{u.username}</User>
|
||||
</span>
|
||||
))}
|
||||
{online.length > ONLINE_MAX && ` +${online.length - ONLINE_MAX}`}
|
||||
</>
|
||||
);
|
||||
|
||||
function sentence(event: CommunityEvent) {
|
||||
switch (event.type) {
|
||||
case "dump_posted":
|
||||
return (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User>
|
||||
{" posted "}
|
||||
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
|
||||
{event.dump.title}
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
case "dump_upvoted":
|
||||
return (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User>
|
||||
{" upvoted "}
|
||||
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
|
||||
{event.dump.title}
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
case "dump_commented":
|
||||
return (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User>
|
||||
{" commented on "}
|
||||
<Link
|
||||
to={`${dumpPath(event.dump)}#comment-${event.commentId}`}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{event.dump.title}
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
case "playlist_created":
|
||||
return (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User>
|
||||
{" created the playlist "}
|
||||
<Link to={playlistPath(event.playlist)} tabIndex={tabIndex}>
|
||||
{event.playlist.title}
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
case "user_joined":
|
||||
return event.invitedByUsername
|
||||
? (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User>
|
||||
{" joined, invited by "}
|
||||
<User tabIndex={tabIndex}>{event.invitedByUsername}</User>
|
||||
</Trans>
|
||||
)
|
||||
: (
|
||||
<Trans>
|
||||
<User tabIndex={tabIndex}>{event.username}</User> joined
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="community-feed"
|
||||
aria-label={i18n._(DIGEST_MESSAGES.community)}
|
||||
>
|
||||
{online.length > 0 && (
|
||||
<li className="community-feed-item community-feed-item--online">
|
||||
<Trans>
|
||||
<span className="community-feed-label">Online now:</span> {names}
|
||||
</Trans>
|
||||
</li>
|
||||
)}
|
||||
{figures && (
|
||||
<li className="community-feed-item community-feed-item--week">
|
||||
<Trans>
|
||||
<span className="community-feed-label">This week:</span>{" "}
|
||||
<Link to={communityPath()} tabIndex={tabIndex}>{figures}</Link>
|
||||
</Trans>
|
||||
</li>
|
||||
)}
|
||||
{top && (
|
||||
<li className="community-feed-item community-feed-item--top">
|
||||
<Trans>
|
||||
<span className="community-feed-label">Top this week:</span>{" "}
|
||||
<Link to={dumpPath(top)} tabIndex={tabIndex}>{top.title}</Link>
|
||||
</Trans>
|
||||
</li>
|
||||
)}
|
||||
{feed.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className={`community-feed-item community-feed-item--${event.type}`}
|
||||
>
|
||||
{sentence(event)}{" "}
|
||||
<time
|
||||
className="community-feed-time"
|
||||
dateTime={event.at.toISOString()}
|
||||
>
|
||||
{relativeTime(event.at)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export const WS_URL: string = API_URL
|
||||
|
||||
/** The community endpoint, for the community page. */
|
||||
export const COMMUNITY_URL = `${API_URL}/api/community`;
|
||||
/** The community feed's endpoint, for `CommunityFeedProvider`. */
|
||||
export const COMMUNITY_FEED_URL = `${COMMUNITY_URL}/feed`;
|
||||
/** The search endpoint, for the search page. */
|
||||
export const SEARCH_URL = `${API_URL}/api/search`;
|
||||
/** How many items a page of a feed or list asks the API for. */
|
||||
|
||||
36
src/contexts/social/CommunityFeedContext.ts
Normal file
36
src/contexts/social/CommunityFeedContext.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Hands the community feed, the latest things the site's members did in
|
||||
* public, to whatever shows it: the GeoCities style's marquee, for now.
|
||||
* `CommunityFeedProvider` provides this context and `useCommunityFeed` reads
|
||||
* it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
import type { CommunityFeed } from "../../../shared/community.ts";
|
||||
|
||||
/** Provided by `CommunityFeedProvider` through {@link CommunityFeedContext}. */
|
||||
export interface CommunityFeedContextValue {
|
||||
/**
|
||||
* Null until it is fetched, which waits for a first {@link watch}, and
|
||||
* always for a visitor: the feed is for signed-in users.
|
||||
*/
|
||||
feed: CommunityFeed | null;
|
||||
/**
|
||||
* Says a component is showing the feed, until the returned call. The feed
|
||||
* is fetched, and kept up to date, only while one is: most styles show it
|
||||
* nowhere. `useCommunityFeed` calls it.
|
||||
*/
|
||||
watch: () => () => void;
|
||||
}
|
||||
|
||||
/** Given to a consumer outside `CommunityFeedProvider`: no feed. */
|
||||
export const defaultCommunityFeedValue: CommunityFeedContextValue = {
|
||||
feed: null,
|
||||
watch: () => () => {},
|
||||
};
|
||||
|
||||
/** Provided by `CommunityFeedProvider`, and read with `useCommunityFeed`. */
|
||||
export const CommunityFeedContext: React.Context<CommunityFeedContextValue> =
|
||||
createContext(defaultCommunityFeedValue);
|
||||
118
src/contexts/social/CommunityFeedProvider.tsx
Normal file
118
src/contexts/social/CommunityFeedProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Keeps the community feed for the components that show it, loaded from
|
||||
* `GET /api/community/feed` and again whenever the socket tells of something
|
||||
* the feed may hold. `App` mounts it around every page, so that the feed
|
||||
* outlives the header it is shown in, which every page renders anew.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { CommunityFeed } from "../../../shared/community.ts";
|
||||
import { COMMUNITY_FEED_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useFetchState } from "../../hooks/shared/useFetchState.ts";
|
||||
import { useOnReconnect, useWS, useWSEvent } from "../../hooks/ws/useWS.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { deserializeCommunityFeed } from "../../utils/shared/deserialize.ts";
|
||||
import {
|
||||
CommunityFeedContext,
|
||||
type CommunityFeedContextValue,
|
||||
} from "./CommunityFeedContext.ts";
|
||||
|
||||
/** Passed to {@link CommunityFeedProvider} by `App`. */
|
||||
export interface CommunityFeedProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// The feed is fetched again this long after the last change the socket told
|
||||
// of, so that a run of upvotes costs one request.
|
||||
const RELOAD_MS = 2000;
|
||||
|
||||
/**
|
||||
* Provides {@link CommunityFeedContext}. It fetches the feed once a component
|
||||
* watches it, for the signed-in user alone. The socket's messages don't carry
|
||||
* a feed's lines, an upvote's naming neither its dump nor its voter, so each
|
||||
* one that may change the feed has it fetched again, and the server's query
|
||||
* stays the one place that decides what the feed holds. A change that comes
|
||||
* while nothing watches is caught up with on the next watch.
|
||||
*/
|
||||
export function CommunityFeedProvider(
|
||||
{ children }: CommunityFeedProviderProps,
|
||||
): React.JSX.Element {
|
||||
const { user, authFetch } = useAuth();
|
||||
const [watched, setWatched] = useState(false);
|
||||
const load = useCallback(
|
||||
async (signal: AbortSignal) =>
|
||||
deserializeCommunityFeed(
|
||||
await expectOk<Wire<CommunityFeed>>(
|
||||
await authFetch(COMMUNITY_FEED_URL, { signal }),
|
||||
),
|
||||
),
|
||||
[authFetch],
|
||||
);
|
||||
const { state, reload } = useFetchState(user?.id, load, {
|
||||
enabled: watched,
|
||||
});
|
||||
|
||||
const watchers = useRef(0);
|
||||
// A change came that the feed hasn't been fetched again for.
|
||||
const due = useRef(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const refresh = useCallback(() => {
|
||||
if (!due.current || watchers.current === 0) return;
|
||||
due.current = false;
|
||||
reload();
|
||||
}, [reload]);
|
||||
const changed = useCallback(() => {
|
||||
due.current = true;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(refresh, RELOAD_MS);
|
||||
}, [refresh]);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
useWSEvent("dump", changed);
|
||||
useWSEvent("vote", changed);
|
||||
useWSEvent("comment", (e) => e.type !== "updated" && changed());
|
||||
useWSEvent("playlist", (e) => e.type !== "dumps_updated" && changed());
|
||||
// A rename changes the names the feed writes.
|
||||
useWSEvent("user", changed);
|
||||
useOnReconnect(changed);
|
||||
// A deleted dump comes as no event, only as a change of this set.
|
||||
const { deletedDumpIds } = useWS();
|
||||
const seenDeleted = useRef(deletedDumpIds);
|
||||
useEffect(() => {
|
||||
if (seenDeleted.current === deletedDumpIds) return;
|
||||
seenDeleted.current = deletedDumpIds;
|
||||
changed();
|
||||
}, [deletedDumpIds, changed]);
|
||||
|
||||
const watch = useCallback(() => {
|
||||
watchers.current++;
|
||||
setWatched(true);
|
||||
refresh();
|
||||
return () => {
|
||||
watchers.current--;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const feed = state.status === "loaded" ? state.data : null;
|
||||
const value: CommunityFeedContextValue = useMemo(
|
||||
() => ({ feed, watch }),
|
||||
[feed, watch],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommunityFeedContext.Provider value={value}>
|
||||
{children}
|
||||
</CommunityFeedContext.Provider>
|
||||
);
|
||||
}
|
||||
21
src/hooks/social/useCommunityFeed.ts
Normal file
21
src/hooks/social/useCommunityFeed.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Gives a component the community feed, by reading `CommunityFeedContext`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext, useEffect } from "react";
|
||||
import type { CommunityFeed } from "../../../shared/community.ts";
|
||||
import { CommunityFeedContext } from "../../contexts/social/CommunityFeedContext.ts";
|
||||
|
||||
/**
|
||||
* Returns the community feed, kept up to date for as long as the component is
|
||||
* mounted, or null until it is loaded and for a visitor. The `CommunityFeed`
|
||||
* component renders it. Needs `CommunityFeedProvider` above, which `App`
|
||||
* mounts around every page.
|
||||
*/
|
||||
export function useCommunityFeed(): CommunityFeed | null {
|
||||
const { feed, watch } = useContext(CommunityFeedContext);
|
||||
useEffect(() => watch(), [watch]);
|
||||
return feed;
|
||||
}
|
||||
8
src/test/before-react.ts
Normal file
8
src/test/before-react.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Runs before every test file, and before setup.ts, whose imports load React:
|
||||
// what jsdom leaves out that React looks for as it loads.
|
||||
|
||||
// Finding no AnimationEvent, React listens for the animation events under
|
||||
// their old WebKit names, and `fireEvent.animationIteration` reaches no
|
||||
// handler.
|
||||
(globalThis as { AnimationEvent?: unknown }).AnimationEvent ??=
|
||||
class AnimationEvent extends Event {};
|
||||
105
src/test/components/layout/Marquee.test.tsx
Normal file
105
src/test/components/layout/Marquee.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import type { CommunityFeed } from "../../../../shared/community.ts";
|
||||
import { Marquee } from "../../../components/layout/Marquee.tsx";
|
||||
import { CommunityFeedContext } from "../../../contexts/social/CommunityFeedContext.ts";
|
||||
import { ThemeContext } from "../../../contexts/theme/ThemeContext.ts";
|
||||
import type { StyleName } from "../../../../shared/theme.ts";
|
||||
import { makeCommunityFeed, makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const joined = (username: string) =>
|
||||
makeCommunityFeed({
|
||||
events: [{
|
||||
id: `user_joined:${username}`,
|
||||
at: new Date(),
|
||||
type: "user_joined",
|
||||
username,
|
||||
}],
|
||||
});
|
||||
|
||||
function marquee(feed: CommunityFeed | null, style: StyleName = "geocities") {
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
style,
|
||||
colorScheme: "light",
|
||||
setStyle() {},
|
||||
setColorScheme() {},
|
||||
}}
|
||||
>
|
||||
<CommunityFeedContext.Provider value={{ feed, watch: () => () => {} }}>
|
||||
<Marquee />
|
||||
</CommunityFeedContext.Provider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const session = makeSession({ username: "me" });
|
||||
const track = () => document.querySelector(".site-marquee-track");
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("Marquee", () => {
|
||||
test("draws nothing in another style, nor for a visitor", () => {
|
||||
const band = () => document.querySelector(".site-marquee");
|
||||
const { rerender, unmount } = renderWithContexts(
|
||||
marquee(joined("ann"), "smooth"),
|
||||
{ session },
|
||||
);
|
||||
expect(band()).toBeNull();
|
||||
rerender(marquee(joined("ann")));
|
||||
expect(band()).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithContexts(marquee(joined("ann")));
|
||||
expect(band()).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty band until the feed is loaded, then the feed twice over", () => {
|
||||
const { rerender } = renderWithContexts(marquee(null), { session });
|
||||
expect(document.querySelector(".site-marquee")).toBeEmptyDOMElement();
|
||||
|
||||
rerender(marquee(joined("ann")));
|
||||
// The second copy is the loop's: neither read out nor tabbed to.
|
||||
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
|
||||
const [first, second] = [...document.querySelectorAll("a[href='/@ann']")];
|
||||
expect(first).not.toHaveAttribute("tabindex");
|
||||
expect(second).toHaveAttribute("tabindex", "-1");
|
||||
expect(second.closest("[aria-hidden='true']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("what changes mid-crawl waits for the next loop", () => {
|
||||
const { rerender } = renderWithContexts(marquee(joined("ann")), {
|
||||
session,
|
||||
ws: { onlineUsers: [] },
|
||||
});
|
||||
rerender(marquee(joined("bob")));
|
||||
expect(screen.getByRole("link", { name: "ann" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "bob" })).toBeNull();
|
||||
|
||||
const before = track()!;
|
||||
fireEvent.animationIteration(before);
|
||||
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "ann" })).toBeNull();
|
||||
// A new loop is a new track, whose animation starts over.
|
||||
expect(track()).not.toBe(before);
|
||||
});
|
||||
|
||||
test("with reduced motion, the line shows once and follows the feed", () => {
|
||||
vi.spyOn(globalThis, "matchMedia").mockImplementation((query) =>
|
||||
({
|
||||
matches: query.includes("reduce"),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}) as unknown as MediaQueryList
|
||||
);
|
||||
const { rerender } = renderWithContexts(marquee(joined("ann")), {
|
||||
session,
|
||||
});
|
||||
expect(document.querySelectorAll("a[href='/@ann']")).toHaveLength(1);
|
||||
rerender(marquee(joined("bob")));
|
||||
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
118
src/test/components/social/CommunityFeed.test.tsx
Normal file
118
src/test/components/social/CommunityFeed.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { CommunityFeed } from "../../../components/social/CommunityFeed.tsx";
|
||||
import { makeCommunityFeed } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const at = new Date(Date.now() - 2 * 3_600_000);
|
||||
const hit = { title: "Big hit", slug: "big-hit", ownerUsername: "ann" };
|
||||
const online = (...names: string[]) =>
|
||||
names.map((username) => ({ userId: username, username, hasAvatar: false }));
|
||||
|
||||
describe("CommunityFeed", () => {
|
||||
test("a line for who is online, the week and its top dump, then one per event", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
online={online("ann", "bob")}
|
||||
feed={makeCommunityFeed({
|
||||
week: {
|
||||
totals: { dumps: 1284, upvotes: 1, comments: 0 },
|
||||
chatMessages: 3,
|
||||
top: hit,
|
||||
},
|
||||
events: [
|
||||
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
|
||||
{ id: "b", at, type: "dump_upvoted", username: "bob", dump: hit },
|
||||
{
|
||||
id: "c",
|
||||
at,
|
||||
type: "dump_commented",
|
||||
username: "cy",
|
||||
dump: hit,
|
||||
commentId: "c9",
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
at,
|
||||
type: "playlist_created",
|
||||
username: "dee",
|
||||
playlist: { title: "Mix", slug: "mix", ownerUsername: "dee" },
|
||||
},
|
||||
{
|
||||
id: "e",
|
||||
at,
|
||||
type: "user_joined",
|
||||
username: "eve",
|
||||
invitedByUsername: "ann",
|
||||
},
|
||||
{ id: "f", at, type: "user_joined", username: "fay" },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const lines = within(screen.getByRole("list", { name: "Community" }))
|
||||
.getAllByRole("listitem").map((li) => li.textContent);
|
||||
expect(lines).toEqual([
|
||||
"Online now: ann, bob",
|
||||
// A figure at zero is left out.
|
||||
"This week: 1,284 dumps, 1 upvote, 3 chat messages",
|
||||
"Top this week: Big hit",
|
||||
"ann posted Big hit 2 hours ago",
|
||||
"bob upvoted Big hit 2 hours ago",
|
||||
"cy commented on Big hit 2 hours ago",
|
||||
"dee created the playlist Mix 2 hours ago",
|
||||
"eve joined, invited by ann 2 hours ago",
|
||||
"fay joined 2 hours ago",
|
||||
]);
|
||||
|
||||
const href = (name: string, n = 0) =>
|
||||
screen.getAllByRole("link", { name })[n].getAttribute("href");
|
||||
expect(href("bob")).toBe("/@bob");
|
||||
expect(href("1,284 dumps, 1 upvote, 3 chat messages")).toBe("/community");
|
||||
expect(href("Big hit")).toBe("/@ann/dumps/big-hit");
|
||||
// The comment's line leads to the comment.
|
||||
expect(href("Big hit", 3)).toBe("/@ann/dumps/big-hit#comment-c9");
|
||||
expect(href("Mix")).toBe("/@dee/playlists/mix");
|
||||
});
|
||||
|
||||
test("a line with nothing to say is left out", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed online={[]} feed={makeCommunityFeed()} />,
|
||||
);
|
||||
expect(screen.queryAllByRole("listitem")).toEqual([]);
|
||||
});
|
||||
|
||||
test("past eight online, the others are counted", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
online={online("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")}
|
||||
feed={makeCommunityFeed()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("listitem")).toHaveTextContent(
|
||||
"Online now: a, b, c, d, e, f, g, h +2",
|
||||
);
|
||||
});
|
||||
|
||||
test("tabIndex goes to every link", () => {
|
||||
renderWithContexts(
|
||||
<CommunityFeed
|
||||
tabIndex={-1}
|
||||
online={online("ann")}
|
||||
feed={makeCommunityFeed({
|
||||
week: {
|
||||
totals: { dumps: 1, upvotes: 0, comments: 0 },
|
||||
chatMessages: 0,
|
||||
top: hit,
|
||||
},
|
||||
events: [
|
||||
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(5);
|
||||
for (const link of links) expect(link).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal file
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { CommunityFeedProvider } from "../../../contexts/social/CommunityFeedProvider.tsx";
|
||||
import { useCommunityFeed } from "../../../hooks/social/useCommunityFeed.ts";
|
||||
import { calls, route } from "../../fetch.ts";
|
||||
import {
|
||||
makeCommunityFeed,
|
||||
makeDump,
|
||||
makeSession,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../../fixtures.ts";
|
||||
import { liveWS } from "../../live.tsx";
|
||||
|
||||
const FEED = "GET /api/community/feed";
|
||||
const joined = (username: string) =>
|
||||
wire(makeCommunityFeed({
|
||||
events: [{
|
||||
id: `user_joined:${username}`,
|
||||
at: new Date("2026-10-01T00:00:00Z"),
|
||||
type: "user_joined",
|
||||
username,
|
||||
}],
|
||||
}));
|
||||
|
||||
function setup({ signedIn = true } = {}) {
|
||||
if (signedIn) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
}
|
||||
const ws = liveWS();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>
|
||||
<ws.Wrapper>
|
||||
<CommunityFeedProvider>{children}</CommunityFeedProvider>
|
||||
</ws.Wrapper>
|
||||
</AuthProvider>
|
||||
);
|
||||
return { ws, wrapper };
|
||||
}
|
||||
|
||||
describe("CommunityFeedProvider", () => {
|
||||
test("loads the feed for a component that shows it", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current!.events[0].username).toBe("ann");
|
||||
expect(result.current!.events[0].at).toEqual(
|
||||
new Date("2026-10-01T00:00:00Z"),
|
||||
);
|
||||
expect(calls(FEED)[0].headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
test("asks for nothing while no component shows it", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper: Wrapper } = setup();
|
||||
render(<Wrapper>a page that shows no feed</Wrapper>);
|
||||
await act(async () => {});
|
||||
expect(calls(FEED)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a visitor has no feed, and never asks", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { wrapper } = setup({ signedIn: false });
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
await act(async () => {});
|
||||
expect(result.current).toBeNull();
|
||||
expect(calls(FEED)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("fetches it again once the socket's changes have settled", async () => {
|
||||
let name = "ann";
|
||||
route(FEED, () => joined(name));
|
||||
const { ws, wrapper } = setup();
|
||||
const { result } = renderHook(useCommunityFeed, { wrapper });
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
name = "bob";
|
||||
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
ws.emit("dump", { type: "created", dump: makeDump() });
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
// The second change put the fetch off.
|
||||
expect(calls(FEED)).toHaveLength(1);
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
expect(calls(FEED)).toHaveLength(2);
|
||||
|
||||
// What changes no line of the feed fetches nothing.
|
||||
ws.emit("comment", { type: "updated", dumpId: "d" });
|
||||
ws.emit("playlist", { type: "dumps_updated", playlistId: "p" });
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(calls(FEED)).toHaveLength(2);
|
||||
|
||||
// A deleted dump and a reconnection do.
|
||||
ws.set({ deletedDumpIds: new Set(["d"]) });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(calls(FEED)).toHaveLength(3);
|
||||
ws.set({ connectionEpoch: 1 });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(calls(FEED)).toHaveLength(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await waitFor(() => expect(result.current!.events[0].username).toBe("bob"));
|
||||
});
|
||||
|
||||
test("a change that came while nothing showed the feed is caught up with", async () => {
|
||||
route(FEED, () => joined("ann"));
|
||||
const { ws, wrapper } = setup();
|
||||
// The provider stays, as it does under the pages: the consumer comes and
|
||||
// goes.
|
||||
const Show = () => {
|
||||
useCommunityFeed();
|
||||
return null;
|
||||
};
|
||||
const view = render(<Show />, { wrapper });
|
||||
await waitFor(() => expect(calls(FEED)).toHaveLength(1));
|
||||
view.rerender(null);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(calls(FEED)).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
view.rerender(<Show />);
|
||||
await waitFor(() => expect(calls(FEED)).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { Session } from "../utils/auth/storage.ts";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import type { ChatMessage } from "../../shared/chat.ts";
|
||||
import type { Comment } from "../../shared/comment.ts";
|
||||
import type { CommunityFeed } from "../../shared/community.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { Notification } from "../../shared/notification.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
@@ -142,6 +143,17 @@ export function makeNotification(
|
||||
};
|
||||
}
|
||||
|
||||
/** A feed with nothing in it: a test gives it the lines it reads. */
|
||||
export function makeCommunityFeed(
|
||||
over: Partial<CommunityFeed> = {},
|
||||
): CommunityFeed {
|
||||
return {
|
||||
events: [],
|
||||
week: { totals: { dumps: 0, upvotes: 0, comments: 0 }, chatMessages: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A value as it crosses the wire: dates become ISO strings. */
|
||||
export function wire<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type FollowContextValue,
|
||||
} from "../contexts/social/FollowContext.ts";
|
||||
import { FollowProvider } from "../contexts/social/FollowProvider.tsx";
|
||||
import { CommunityFeedProvider } from "../contexts/social/CommunityFeedProvider.tsx";
|
||||
import {
|
||||
CategoriesContext,
|
||||
type CategoriesContextValue,
|
||||
@@ -264,20 +265,22 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
<CategoriesProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={entries}
|
||||
initialIndex={entries.length - 1}
|
||||
>
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
{extraRoutes}
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</CategoriesProvider>
|
||||
<CommunityFeedProvider>
|
||||
<CategoriesProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={entries}
|
||||
initialIndex={entries.length - 1}
|
||||
>
|
||||
<ChatProvider>
|
||||
<Routes>
|
||||
<Route path={path} element={children} />
|
||||
{extraRoutes}
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</CategoriesProvider>
|
||||
</CommunityFeedProvider>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
|
||||
@@ -214,8 +214,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The track is the banner's text twice over, so sliding it left by half its
|
||||
width ends where it began and the loop has no seam. */
|
||||
/* The track is the feed twice over, so sliding it left by half its width
|
||||
ends where it began and the loop has no seam. */
|
||||
@keyframes geo-marquee {
|
||||
to {
|
||||
translate: -50% 0;
|
||||
@@ -601,7 +601,8 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ── The marquee — a line of welcome crawling under the taskbar ──── */
|
||||
/* ── The marquee — the community feed crawling under the taskbar ─── */
|
||||
/* The band keeps its height while it is empty, before the feed has loaded. */
|
||||
[data-style="geocities"] .site-marquee {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -611,22 +612,57 @@
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.9;
|
||||
min-height: 1.9em;
|
||||
}
|
||||
|
||||
/* Marquee.tsx sets --marquee-length, one copy's width in pixels: a loop lasts
|
||||
what it takes to cross it at 50 pixels a second, however much the feed
|
||||
holds. */
|
||||
[data-style="geocities"] .site-marquee-track {
|
||||
display: inline-block;
|
||||
animation: geo-marquee 36s linear infinite;
|
||||
animation: geo-marquee calc(var(--marquee-length, 2000) * 1s / 50) linear
|
||||
infinite;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee-track > span {
|
||||
[data-style="geocities"] .site-marquee-track > *,
|
||||
[data-style="geocities"] .site-marquee .community-feed {
|
||||
display: inline-block;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee .community-feed-item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* A star before each line, the first included, so that the two copies meet
|
||||
on one as well. */
|
||||
[data-style="geocities"] .site-marquee .community-feed-item::before {
|
||||
content: "✦";
|
||||
margin-inline: 1.25rem;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee a:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee .community-feed-time {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* The crawl stops under the pointer, and for a link that has the focus: the
|
||||
line then stands at its start, and the browser scrolls it to that link. */
|
||||
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee:focus-within .site-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ── Rainbow dividers — on each element's real edge ──────────────── */
|
||||
[data-style="geocities"] .profile-section > h2,
|
||||
[data-style="geocities"] .profile-section-header,
|
||||
@@ -816,24 +852,21 @@
|
||||
}
|
||||
|
||||
/* Nothing blinks or crawls for someone who asked for less motion: the
|
||||
marquee then shows its line once, centred and still. */
|
||||
marquee then shows its line once and still, from its start, and scrolls by
|
||||
hand to the rest. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-style="geocities"] :is(.notification-badge, .notif-dot, .unread-dot) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee {
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-style="geocities"] .site-marquee-track > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Casual web-1.0 voice: lowercase everything (code excepted) ──── */
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { Wire } from "../../../shared/api.ts";
|
||||
import type { Category } from "../../../shared/category.ts";
|
||||
import type { ChatMessage } from "../../../shared/chat.ts";
|
||||
import type { Comment } from "../../../shared/comment.ts";
|
||||
import type { CommunityStats } from "../../../shared/community.ts";
|
||||
import type {
|
||||
CommunityFeed,
|
||||
CommunityStats,
|
||||
} from "../../../shared/community.ts";
|
||||
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
|
||||
import type {
|
||||
FederationGrant,
|
||||
@@ -153,6 +156,16 @@ export function deserializeCommunityStats(
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns `raw` as a {@link CommunityFeed}. */
|
||||
export function deserializeCommunityFeed(
|
||||
raw: Wire<CommunityFeed>,
|
||||
): CommunityFeed {
|
||||
return {
|
||||
...raw,
|
||||
events: raw.events.map((e) => ({ ...e, at: new Date(e.at) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns `raw` as a {@link FederationPeer}. */
|
||||
export function deserializeFederationPeer(
|
||||
raw: Wire<FederationPeer>,
|
||||
|
||||
@@ -10,7 +10,8 @@ export default mergeConfig(
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
// before-react.ts first: setup.ts imports React.
|
||||
setupFiles: ["src/test/before-react.ts", "src/test/setup.ts"],
|
||||
restoreMocks: true,
|
||||
unstubGlobals: true,
|
||||
css: false,
|
||||
|
||||
Reference in New Issue
Block a user