fix(journal): show remote followers in follower/following lists

countFollowers counts every accepted follow row, but listFollowers
inner-joined users on follower_id — so a federated follower (NULL
follower_id, follower_actor_iri set) bumped the count while never
appearing in the list. Observed live: profile said '1 follower', list
below was empty. listFollowing had the same latent bug for outbound
trails-to-trails follows.

Both lists now left-join users + remote_actors: local entries link to
the local profile as before; remote entries display cached actor data
(IRI parsing as fallback) and link out to the remote profile.

Integration test pins count/list consistency for a remote follower.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-07 09:13:44 +02:00
parent 95b21e2c44
commit 4e63f7631a
3 changed files with 109 additions and 15 deletions

View file

@ -4,6 +4,9 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
}
interface Props {
@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => (
<li key={entry.username} className="px-4 py-3">
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
<a
href={`/users/${entry.username}`}
href={entry.profileUrl}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center justify-between hover:underline"
>
<span className="text-sm font-medium text-gray-900">

View file

@ -64,6 +64,23 @@ describe.runIf(runIntegration)("federation inbox (integration)", () => {
expect(rows[0]!.acceptedAt).not.toBeNull();
});
it("remote followers appear in the followers list (count/list consistency)", async () => {
const username = `fed-list-${Date.now()}`;
const userId = await makeUser({ username });
await recordRemoteFollow(REMOTE_ACTOR, username);
const { listFollowers, countFollowers } = await import("./follow.server.ts");
const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]);
expect(total).toBe(1);
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry.remote).toBe(true);
expect(entry.profileUrl).toBe(REMOTE_ACTOR);
// No remote_actors cache row in this test → identity parsed from the IRI.
expect(entry.username).toBe("alice");
expect(entry.domain).toBe("other-trails.example");
});
it("inbound Follow is idempotent — replays don't double-insert", async () => {
const username = `fed-replay-${Date.now()}`;
const userId = await makeUser({ username });

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
import { users, follows } from "@trails-cool/db/schema/journal";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
@ -283,35 +283,89 @@ export interface CollectionEntry {
username: string;
displayName: string | null;
domain: string;
/** Where the entry's profile lives: local path or remote actor URL. */
profileUrl: string;
remote: boolean;
}
const COLLECTION_PAGE_SIZE = 50;
/**
* Best-effort identity for a remote actor we may or may not have
* cached: prefer the remote_actors row, fall back to parsing the IRI
* (canonical fediverse shape `https://host/users/name`).
*/
function remoteEntry(
actorIri: string,
cached: { username: string | null; displayName: string | null; domain: string | null },
): CollectionEntry {
let host = "";
let lastSegment: string;
try {
const url = new URL(actorIri);
host = url.host;
lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? "";
} catch {
// Malformed IRI in the DB — display it raw rather than hide the row.
lastSegment = actorIri;
}
return {
username: cached.username ?? lastSegment,
displayName: cached.displayName,
domain: cached.domain ?? host,
profileUrl: actorIri,
remote: true,
};
}
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
* Pending requests are excluded they live in the Requests tab on
* /notifications.
* Includes remote (federated) followers `follower_actor_iri` rows so the
* list matches countFollowers; display data comes from the remote_actors
* cache when present, IRI parsing otherwise. Pending requests are excluded
* they live in the Requests tab on /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise<CollectionEntry[]> {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followerActorIri: follows.followerActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followerId, users.id))
.leftJoin(users, eq(follows.followerId, users.id))
.leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followerActorIri ?? "", {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}
/**
* Paginated list of accepted follows from `userId`. Newest acceptance first.
* Includes remote (trails-to-trails) targets rows whose followed side is
* only an actor IRI for the same count/list consistency as listFollowers.
* Pending outgoing follows (against private/locked targets) are excluded.
*/
export async function listFollowing(userId: string, page: number = 1): Promise<CollectionEntry[]> {
@ -319,15 +373,34 @@ export async function listFollowing(userId: string, page: number = 1): Promise<C
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followedActorIri: follows.followedActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followedUserId, users.id))
.leftJoin(users, eq(follows.followedUserId, users.id))
.leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri))
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followedActorIri, {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}