Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions apps/web/src/app/QueueAll.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,35 @@ import { LANE_LABEL } from '../lib/queue.js';
* undoing it, and it should undo *this* rather than empty the lane and take the
* rest of the reader's queue with it.
*
* Names either a topic or a single feed. Both post the same shape to the same
* endpoint and differ only in which query the server re-runs, so a feed page
* gets the control it was missing without a second component drifting away from
* this one.
*
* @param {{
* topic: string,
* topic?: string|null,
* feed?: string|null,
* group?: string|null,
* total: number,
* queued: number,
* lanes: ('read'|'listen'|'watch')[],
* next: string,
* }} props
*/
export default function QueueAll({ topic, group = null, total, queued, lanes, next }) {
export default function QueueAll({
topic = null,
feed = null,
group = null,
total,
queued,
lanes,
next,
}) {
if (total === 0) return null;

// Which playlist this is, and therefore which pair of actions it posts.
const scope = feed ? 'feed' : 'topic';

// "All of it" rather than "every single one": a playlist whose entries are
// already in the queue for other reasons should not offer to add them again.
const all = queued >= total;
Expand All @@ -46,8 +63,12 @@ export default function QueueAll({ topic, group = null, total, queued, lanes, ne
return (
<div className="queue-all">
<form method="post" action="/api/queue" className="inline-form" data-soft>
<input type="hidden" name="action" value={all ? 'remove-topic' : 'add-topic'} />
<input type="hidden" name="topic" value={topic} />
<input type="hidden" name="action" value={`${all ? 'remove' : 'add'}-${scope}`} />
{feed ? (
<input type="hidden" name="slug" value={feed} />
) : (
<input type="hidden" name="topic" value={topic} />
)}
{group && <input type="hidden" name="group" value={group} />}
<input type="hidden" name="next" value={next} />
<button type="submit" className={`queue-button${all ? ' on' : ''}`} aria-pressed={all}>
Expand Down
31 changes: 29 additions & 2 deletions apps/web/src/app/[slug]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { q, alerts, queue, authors as people } from '@rssamplifier/db';
import { db, siteUrl } from '../../lib/db.js';
import { currentUser } from '../../lib/auth.js';
import { feedAdPlan } from '../../lib/feedAdPlan.js';
import { lanesOffered, trackFor } from '../../lib/queue.js';
import {
FEED_QUEUE_LIMIT,
alreadyQueued,
entryLanes,
lanesOffered,
playableEntries,
trackFor,
} from '../../lib/queue.js';
import { shareText } from '../../lib/share.js';
import { feedCard, postThumb } from '../../lib/thumbs.js';
import { feedAlternates } from '../../lib/subscribe.js';
Expand All @@ -16,6 +23,7 @@ import ListFilter from '../ListFilter.jsx';
import { FILTER_FROM } from '../../lib/listFilter.js';
import Freshness from '../Freshness.jsx';
import PlayButton from '../PlayButton.jsx';
import QueueAll from '../QueueAll.jsx';
import QueueButton from '../QueueButton.jsx';
import Share from '../Share.jsx';
import SubscribeLinks from '../SubscribeLinks.jsx';
Expand Down Expand Up @@ -107,7 +115,7 @@ export default async function FeedPage({ params }) {
if (!feed) notFound();

const [posts, nav, topics, credited, feedLinks, user] = await Promise.all([
q.itemsForFeed(client, String(feed.id), 50),
q.itemsForFeed(client, String(feed.id), FEED_QUEUE_LIMIT),
q.neighbours(client, String(feed.created_at)),
q.keywordsForFeed(client, String(feed.id)),
people.authorsForFeed(client, String(feed.id)),
Expand All @@ -134,6 +142,12 @@ export default async function FeedPage({ params }) {
/** @type {Record<string, ('read'|'listen'|'watch')[]>} */ ({}),
];

// What "queue all" would act on, worked out from the posts already in hand
// rather than asked for separately. The endpoint runs this same function over
// this same query when the form comes back, which is what stops the number on
// the button and the rows it adds from ever being two different sets.
const playable = playableEntries(posts);

// A blog page is the longest read on the site — up to fifty summaries — so it
// is the one place a rectangle earns its keep, sat in the flow where somebody
// has already stopped to read. At most three across fifty posts, alternating
Expand Down Expand Up @@ -356,6 +370,19 @@ export default async function FeedPage({ params }) {

<h2>Latest {category.item}</h2>

{/* Above the list, on the same reasoning the topic player uses: somebody
who has decided to keep the whole show has decided that on the
strength of the blurb, and should not have to scroll fifty rows to act
on it. Renders nothing at all when the feed carries no files, which is
every blog — QueueAll returns null on a total of zero. */}
<QueueAll
feed={slug}
total={playable.length}
queued={alreadyQueued(playable, queued)}
lanes={entryLanes(playable)}
next={`/${slug}`}
/>

{/* An archive page can carry a hundred entries, and looking for one you
half-remember the title of is the commonest thing to do with it. */}
{posts.length >= FILTER_FROM && (
Expand Down
30 changes: 29 additions & 1 deletion apps/web/src/app/api/queue/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { q, queue } from '@rssamplifier/db';

import { db } from '../../../lib/db.js';
import { currentUser } from '../../../lib/auth.js';
import { laneFor, trackFor } from '../../../lib/queue.js';
import { FEED_QUEUE_LIMIT, laneFor, playableEntries, trackFor } from '../../../lib/queue.js';
import { PLAYLIST_LIMIT } from '../../../lib/topicFeed.js';
import { topicGroup } from '../../../lib/topicGroups.js';

Expand All @@ -14,6 +14,8 @@ const ACTIONS = new Set([
'remove',
'add-topic',
'remove-topic',
'add-feed',
'remove-feed',
'done',
'undone',
'up',
Expand Down Expand Up @@ -112,6 +114,32 @@ export async function POST(req) {
return json({ ok: true, action, changed, counts: await queue.counts(client, userId) });
}

if (action === 'add-feed' || action === 'remove-feed') {
// One podcast's episodes, on the same terms as a topic's playlist above:
// named by the page rather than carried by the form, and re-queried here so
// the button acts on exactly what is listed under it.
//
// A feed's archive is not a playlist, though, and that is the one
// difference. `mediaForTopic` filters to the playable rows in SQL because a
// topic spans thousands of feeds; a feed page has already read its fifty
// posts, so `playableEntries` applies the same filter to the same query the
// page itself runs — which is what keeps the count on the button and the
// rows this adds from ever being two different sets.
const feed = await q.feedBySlug(client, slug);
if (!feed) return wantsHtml ? redirect(back) : json({ error: 'not-found' }, 404);

const rows = await q.itemsForFeed(client, String(feed.id), FEED_QUEUE_LIMIT);
const entries = playableEntries(rows);

const changed =
action === 'add-feed'
? await queue.addMany(client, userId, entries)
: await queue.removeMany(client, userId, entries);

if (wantsHtml) return redirect(back);
return json({ ok: true, action, changed, counts: await queue.counts(client, userId) });
}

if (action === 'add' || (action === 'remove' && !entryId)) {
if (!queue.isLane(lane)) return json({ error: 'bad-lane' }, 400);

Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/lib/queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,70 @@ export const LANE_LABEL = { read: 'Read', listen: 'Listen', watch: 'Watch' };
/** The verb on the button that puts a post in that lane. */
export const LANE_VERB = { read: 'Read later', listen: 'Listen later', watch: 'Watch later' };

/**
* How much of a feed "Queue all" covers.
*
* The same slice the feed page draws, because the button names the feed rather
* than carrying its episodes — the endpoint re-runs the page's own query, so
* the two numbers have to agree or the button adds something the reader was
* never shown. Lives here rather than in either caller for the same reason
* PLAYLIST_LIMIT lives beside the topic river: it is a fact about the pair.
*/
export const FEED_QUEUE_LIMIT = 50;

/**
* What "queue all" on a feed page acts on: its posts that carry a file.
*
* The one thing this function exists to guarantee is that the page and the
* endpoint agree. The button names the feed rather than carrying its episodes,
* so the server re-runs the page's query when the form comes back — and if the
* two sides filtered that result even slightly differently, the control would
* report one number and queue another. They call this instead.
*
* A blog is excluded rather than queued to Read. The control says "play all",
* and quietly filling somebody's reading queue with fifty essays is not what
* they pressed; a feed with nothing playable yields nothing, and QueueAll draws
* no button at all on a total of zero.
*
* @param {Array<{ id: unknown, audio_url?: unknown, audio_type?: unknown, url?: unknown }>} posts
* @returns {Array<{ itemId: string, lane: 'listen'|'watch' }>} newest first, as given
*/
export function playableEntries(posts) {
return posts
.map((post) => ({ itemId: String(post.id), lane: laneFor(post) }))
.filter((entry) => entry.lane !== 'read');
}

/**
* The lanes a set of entries lands in.
*
* Both are counted rather than assuming a feed is one kind of thing: a show
* that publishes episodes and the occasional video splits across two, and a
* reader told "queued" who then found nothing in the lane they were looking at
* would reasonably conclude the button was broken.
*
* @param {Array<{ lane: 'listen'|'watch' }>} entries
* @returns {('listen'|'watch')[]}
*/
export function entryLanes(entries) {
return [...new Set(entries.map((entry) => entry.lane))];
}

/**
* How many of these the reader already has lined up, in the lane they'd land in.
*
* Judged per lane rather than per item, because an episode kept in Read is not
* the same intention as one kept in Listen — counting it would make "queue all"
* claim to be done when pressing it would still add something.
*
* @param {Array<{ itemId: string, lane: 'listen'|'watch' }>} entries
* @param {Record<string, ('read'|'listen'|'watch')[]>} queued
* @returns {number}
*/
export function alreadyQueued(entries, queued) {
return entries.filter((entry) => (queued[entry.itemId] ?? []).includes(entry.lane)).length;
}

/**
* The lane a post belongs in if nobody says otherwise.
*
Expand Down
63 changes: 62 additions & 1 deletion apps/web/test/queue.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { dockCarries, dockable, embedded, laneFor, lanesOffered, trackFor } from '../src/lib/queue.js';
import {
alreadyQueued,
dockCarries,
dockable,
embedded,
entryLanes,
laneFor,
lanesOffered,
playableEntries,
trackFor,
} from '../src/lib/queue.js';

/** An episode: an mp3 enclosure on a post. */
const EPISODE = {
Expand Down Expand Up @@ -104,3 +114,54 @@ test('an embed becomes a track, and says which kind it is', () => {
test('a post with nothing attached is not offered to the dock as a track', () => {
assert.equal(trackFor(POST, { slug: 'blog', feedTitle: 'Blog' }), null);
});

test('queue-all acts on the posts that carry a file, and nothing else', () => {
// A podcast's page is the case this exists for: every row is an episode, so
// all of them queue. The article in the middle is the one that must not — the
// control says "play all", and a blog post has nothing to play.
const entries = playableEntries([
{ id: 'a', ...EPISODE },
{ id: 'b', ...POST },
{ id: 'c', ...YOUTUBE },
]);

assert.deepEqual(entries, [
{ itemId: 'a', lane: 'listen' },
{ itemId: 'c', lane: 'watch' },
]);
});

test('a blog offers nothing to queue at all', () => {
// Not an empty button but no button: QueueAll returns null on a total of
// zero, so this is what keeps the control off every blog in the directory.
assert.deepEqual(playableEntries([{ id: 'a', ...POST }]), []);
});

test('a feed that publishes both kinds names both lanes', () => {
// A reader told "queued" who then found nothing in the lane they were looking
// at would reasonably conclude the button was broken, so the note names every
// lane the press will touch rather than assuming a feed is one kind of thing.
const mixed = playableEntries([
{ id: 'a', ...EPISODE },
{ id: 'c', ...YOUTUBE },
]);
assert.deepEqual(entryLanes(mixed), ['listen', 'watch']);
assert.deepEqual(entryLanes(playableEntries([{ id: 'a', ...EPISODE }])), ['listen']);
assert.deepEqual(entryLanes([]), []);
});

test('what is already queued is counted in the lane it would land in', () => {
const entries = playableEntries([
{ id: 'a', ...EPISODE },
{ id: 'c', ...YOUTUBE },
]);

assert.equal(alreadyQueued(entries, {}), 0);
assert.equal(alreadyQueued(entries, { a: ['listen'] }), 1);
assert.equal(alreadyQueued(entries, { a: ['listen'], c: ['watch'] }), 2);

// Kept to read later is a different intention from kept to listen to, and
// counting it would let the button claim to be done while pressing it would
// still add the episode.
assert.equal(alreadyQueued(entries, { a: ['read'] }), 0);
});
Loading