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
4 changes: 2 additions & 2 deletions apps/web/src/app/FollowControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import FollowButton from './FollowButton.jsx';
*
* @param {{
* endpoint: string,
* kind: 'feed'|'topic',
* kind: 'feed'|'topic'|'author',
* slug: string,
* segment?: string,
* following: boolean,
Expand Down Expand Up @@ -81,7 +81,7 @@ export default function FollowControls({
* this only says whether this one follow feeds them.
*
* @param {{
* kind: 'feed'|'topic',
* kind: 'feed'|'topic'|'author',
* slug: string,
* segment?: string,
* alerts: boolean,
Expand Down
19 changes: 16 additions & 3 deletions apps/web/src/app/account/alerts/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const dynamic = 'force-dynamic';

export const metadata = {
title: 'Alerts',
description: 'Where you are told about new posts from the blogs and topics you follow.',
description:
'Where you are told about new posts from the blogs, topics and people you follow.',
};

/** What each error code from /api/alerts/channels means to a reader. */
Expand Down Expand Up @@ -52,7 +53,8 @@ export default async function AlertsPage({ searchParams }) {
]);

const hasEmail = channels.some((c) => c.kind === 'email');
const watching = following.feeds.length + following.topics.length;
const watching =
following.feeds.length + following.topics.length + following.authors.length;

return (
<>
Expand Down Expand Up @@ -150,7 +152,8 @@ export default async function AlertsPage({ searchParams }) {
{watching === 0 ? (
<p className="empty">
Nothing yet. Open a blog you <a href="/following">follow</a> — or any{' '}
<a href="/topics">topic</a> — and press 🔔 beside the Follow button.
<a href="/topics">topic</a> or <a href="/authors">person</a> — and press 🔔 beside the
Follow button.
</p>
) : (
<>
Expand All @@ -167,6 +170,16 @@ export default async function AlertsPage({ searchParams }) {
</div>
)}

{following.authors.length > 0 && (
<div className="feed-meta detail">
{following.authors.map((a) => (
<a key={String(a.slug)} href={`/authors/${encodeURIComponent(String(a.slug))}`}>
{String(a.name)}
</a>
))}
</div>
)}

{following.feeds.length > 0 && (
<div className="feed-meta detail">
{following.feeds.map((f) => (
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/app/api/alerts/route.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { alerts, q } from '@rssamplifier/db';
import { alerts, authors, q } from '@rssamplifier/db';

import { db } from '../../../lib/db.js';
import { currentUser } from '../../../lib/auth.js';
Expand Down Expand Up @@ -52,7 +52,9 @@ export async function POST(req) {
return json({ error: 'bad-request' }, 400);
}

if (kind !== 'feed' && kind !== 'topic') return json({ error: 'bad-kind' }, 400);
if (kind !== 'feed' && kind !== 'topic' && kind !== 'author') {
return json({ error: 'bad-kind' }, 400);
}

// Only ever back to somewhere on this site. `next` arrives in a form field, so
// an absolute URL in it would make this endpoint an open redirect.
Expand All @@ -69,7 +71,9 @@ export async function POST(req) {
const changed =
kind === 'feed'
? await setForFeed(client, userId, slug, on)
: await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on);
: kind === 'author'
? await setForAuthor(client, userId, slug, on)
: await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on);

// Not following it — or, for a blog, no such blog. Either way there is nothing
// to flag, and saying so is more useful than reporting a success that did not
Expand Down Expand Up @@ -98,6 +102,24 @@ async function setForFeed(client, userId, slug, on) {
return alerts.setFeedAlerts(client, userId, String(feed.id), on);
}

/**
* Resolve a person's slug to their id, then flag the follow.
*
* The same indirection as the blog above and for the same reason: the table is
* keyed on an id the reader never sees, and the slug is what a page can send.
*
* @param {import('@libsql/client').Client} client
* @param {string} userId
* @param {string} slug
* @param {boolean} on
* @returns {Promise<boolean>}
*/
async function setForAuthor(client, userId, slug, on) {
const person = await authors.authorBySlug(client, String(slug).trim().toLowerCase());
if (!person) return false;
return alerts.setAuthorAlerts(client, userId, String(person.id), on);
}

/**
* Where a no-JavaScript submit lands when the form did not say.
*
Expand All @@ -107,6 +129,7 @@ async function setForFeed(client, userId, slug, on) {
* @returns {string}
*/
function fallbackPath(kind, slug, segment) {
if (kind === 'author') return `/authors/${encodeURIComponent(String(slug).toLowerCase())}`;
if (kind !== 'topic') return `/${slug}`;
const base = `/topics/${encodeURIComponent(slugFromUrl(slug))}`;
return segment ? `${base}/${encodeURIComponent(segment)}` : base;
Expand Down
15 changes: 8 additions & 7 deletions apps/web/src/app/api/following/feed/[format]/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function GET(req, { params }) {
);
}

const { feeds, topics, items } = await following(client, String(user.id), {
const { feeds, topics, authors, items } = await following(client, String(user.id), {
limit: RIVER_LIMIT,
});

Expand All @@ -85,10 +85,11 @@ export async function GET(req, { params }) {
format,
{
title: 'Following — RSS Amplifier',
description: `Recent posts from the ${count(topics.length, 'topic')} and ${count(
feeds.length,
'blog',
)} this RSS Amplifier account follows.`,
description: `Recent posts from the ${count(topics.length, 'topic')}, ${count(
authors.length,
'person',
'people',
)} and ${count(feeds.length, 'blog')} this RSS Amplifier account follows.`,
link: `${origin}/following`,
selfUrl: followingFeedUrl(origin, token, format),
},
Expand Down Expand Up @@ -116,8 +117,8 @@ export async function GET(req, { params }) {
* @param {string} noun
* @returns {string}
*/
function count(n, noun) {
return `${n} ${noun}${n === 1 ? '' : 's'}`;
function count(n, noun, plural = `${noun}s`) {
return `${n} ${n === 1 ? noun : plural}`;
}

/**
Expand Down
105 changes: 105 additions & 0 deletions apps/web/src/app/api/follows/authors/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { accounts, authors } from '@rssamplifier/db';

import { db } from '../../../../lib/db.js';
import { currentUser } from '../../../../lib/auth.js';

export const dynamic = 'force-dynamic';

/**
* Follow or unfollow a person.
*
* The third sibling of /api/follows and /api/follows/topics, kept apart from
* both for the reason the topics route already gives: they take different
* identifiers, validate differently, and land somewhere different afterwards.
*
* What is different here is the indirection. The table is keyed on the author's
* id, but the request carries their slug, because a slug is the public identity
* and accepting an internal id would mean publishing one. So this resolves the
* slug first, and that lookup is also where a follow on somebody who does not
* exist is refused.
*
* Form-first like every other write on the site: a plain POST answered with a
* 303 back to the page it came from, so following works with JavaScript off. A
* JSON caller gets JSON.
*
* @param {Request} req
*/
export async function POST(req) {
const user = await currentUser();
const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html');

let rawSlug = '';
let action = 'toggle';

try {
if ((req.headers.get('content-type') ?? '').includes('application/json')) {
const body = await req.json();
rawSlug = String(body?.slug ?? '');
action = String(body?.action ?? 'toggle');
} else {
const form = await req.formData();
rawSlug = String(form.get('slug') ?? '');
action = String(form.get('action') ?? 'toggle');
}
} catch {
return json({ error: 'bad-request' }, 400);
}

// Lowercased the way `authorBySlug` expects and the way the page's own URL is
// written, so a follow made from a link somebody typed in capitals is the
// same row as one made from the page.
const slug = rawSlug.trim().toLowerCase();
if (!slug) return wantsHtml ? redirect('/authors') : json({ error: 'bad-request' }, 400);

const page = `/authors/${encodeURIComponent(slug)}`;

if (!user) {
// Sent to sign in and then back to the person they were reading, rather
// than handed a bare error.
if (wantsHtml) return redirect(`/login?next=${encodeURIComponent(page)}`);
return json({ error: 'sign-in-required' }, 401);
}

const client = db();
const person = await authors.authorBySlug(client, slug);

// Unlike the topic route, this refuses in both directions rather than only on
// follow. A topic slug outlives the topic table by design, so an unfollow has
// to work for a slug that no longer resolves; an author id only exists while
// the author row does, and the cascade has already removed the follow by the
// time the row is gone. There is nothing left to delete and nothing to key it
// by, so a 404 is the honest answer.
if (!person) return wantsHtml ? redirect('/authors') : json({ error: 'not-found' }, 404);

const userId = String(user.id);
const authorId = String(person.id);

const following = await accounts.isFollowingAuthor(client, userId, authorId);
const shouldFollow = action === 'follow' || (action === 'toggle' && !following);

if (shouldFollow) await accounts.followAuthor(client, userId, authorId);
else await accounts.unfollowAuthor(client, userId, authorId);

if (wantsHtml) return redirect(page);
return json({ ok: true, slug, following: shouldFollow });
}

/**
* @param {string} location
* @returns {Response}
*/
function redirect(location) {
return new Response(null, { status: 303, headers: { location, 'cache-control': 'no-store' } });
}

/**
* @param {unknown} body
* @param {number} [status]
* @returns {Response}
*/
function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
}
36 changes: 35 additions & 1 deletion apps/web/src/app/authors/[slug]/page.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { notFound } from 'next/navigation';
import { authors } from '@rssamplifier/db';
import { alerts, authors } from '@rssamplifier/db';

import { db, siteUrl } from '../../../lib/db.js';
import { currentUser } from '../../../lib/auth.js';
import { feedAlternates } from '../../../lib/subscribe.js';
import AdBanner from '../../AdBanner.jsx';
import AuthorLinks from '../../AuthorLinks.jsx';
import FollowControls from '../../FollowControls.jsx';
import SubscribeLinks from '../../SubscribeLinks.jsx';
import { CATEGORIES } from '../../CategoryIndex.jsx';
import ListFilter from '../../ListFilter.jsx';
Expand Down Expand Up @@ -59,6 +61,15 @@ export default async function AuthorPage({ params }) {
const feeds = person.feeds ?? [];
const links = person.links ?? [];

// Whether this reader already follows them, and whether that follow is
// alerting. One round trip for both, the way the feed and topic pages do it:
// the button and the bell are rendered together and asking twice would be two
// queries for one row.
const user = await currentUser();
const follow = user
? await alerts.authorFollowState(db(), String(user.id), String(person.id))
: { following: false, alerts: false };

// What they have published lately, read off their own feeds' ids rather than
// searched for -- see `postsByAuthor`. A profile that lists the blogs but not
// the writing is a card catalogue entry; the point of a page about a person
Expand Down Expand Up @@ -142,6 +153,29 @@ export default async function AuthorPage({ params }) {

<AuthorLinks links={links} prominent />

{/* Follow the person, and then decide whether to be told. Above the
subscribe links deliberately: those hand the reader a document to take
somewhere else, and this keeps them here, which is the thing the page
could describe and not offer.

Only where there is something to follow. A profile with no credited
feeds would produce a follow that can never deliver anything, which is
the same reason the subscribe links below are conditional. */}
{feeds.length > 0 && (
<div className="detail-actions">
<FollowControls
endpoint="/api/follows/authors"
kind="author"
slug={slug}
following={follow.following}
alerts={follow.alerts}
signedIn={Boolean(user)}
next={`/authors/${slug}`}
label={`Follow ${person.name}`}
/>
</div>
)}

{/* Everything they publish, wherever they publish it, as one feed. Only
offered when there is something behind it: a subscribe link on a
profile with no credited feeds is a link to an empty document. */}
Expand Down
Loading
Loading