Merge branch 'main' into fix/tombstone-aware-retraction
This commit is contained in:
commit
5d31563008
20 changed files with 780 additions and 72 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -95,12 +95,18 @@ describe("actor object", () => {
|
|||
expect(actor.preferredUsername).toBe("bruno");
|
||||
expect(actor.name).toBe("Bruno");
|
||||
expect(actor.summary).toBe("Riding bikes");
|
||||
// Profile-metadata field Mastodon renders ("this is a trails profile")
|
||||
const attachments = Array.isArray(actor.attachment) ? actor.attachment : [actor.attachment];
|
||||
const field = attachments.find((a: { type: string }) => a?.type === "PropertyValue");
|
||||
expect(field?.name).toBe("🥾 trails.cool");
|
||||
expect(field?.value).toContain('href="http://localhost:3000/users/bruno"');
|
||||
expect(field?.value).toContain('rel="me"');
|
||||
// Profile-metadata fields Mastodon renders ("this is a trails
|
||||
// profile"). MUST serialize as a JSON array — Mastodon's parser
|
||||
// silently ignores a bare attachment object, which is what Fedify
|
||||
// compacts single-element arrays into (hence two fields).
|
||||
expect(Array.isArray(actor.attachment)).toBe(true);
|
||||
const fields = actor.attachment.filter((a: { type: string }) => a?.type === "PropertyValue");
|
||||
expect(fields.length).toBeGreaterThanOrEqual(2);
|
||||
const trails = fields.find((f: { name: string }) => f.name === "🥾 trails.cool");
|
||||
expect(trails?.value).toContain('href="http://localhost:3000/users/bruno"');
|
||||
expect(trails?.value).toContain('rel="me"');
|
||||
const instance = fields.find((f: { name: string }) => f.name === "Instance");
|
||||
expect(instance?.value).toContain("localhost:3000");
|
||||
});
|
||||
|
||||
it("404s the actor for a private user", async () => {
|
||||
|
|
|
|||
|
|
@ -180,11 +180,19 @@ function buildFederation(): Federation<void> {
|
|||
// metadata table — a human-visible "this is a trails profile"
|
||||
// marker. (The machine-readable marker is NodeInfo; this is
|
||||
// flair.) Mastodon strips most HTML in values but keeps links.
|
||||
// NOTE: Mastodon's parser requires `attachment` to be a JSON
|
||||
// *array* and silently ignores a bare object — and Fedify
|
||||
// compacts single-element arrays to bare objects. Keep at least
|
||||
// two fields here so the array survives serialization.
|
||||
attachments: [
|
||||
new PropertyValue({
|
||||
name: "🥾 trails.cool",
|
||||
value: `<a href="${localActorIri(identifier)}" rel="me">${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}</a>`,
|
||||
}),
|
||||
new PropertyValue({
|
||||
name: "Instance",
|
||||
value: `<a href="${getOrigin()}">${getOrigin().replace(/^https?:\/\//, "")}</a>`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue