diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx new file mode 100644 index 0000000..08c8bab --- /dev/null +++ b/apps/journal/app/components/CollectionPage.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; + +interface Entry { + username: string; + displayName: string | null; + domain: string; +} + +interface Props { + kind: "followers" | "following"; + user: { username: string; displayName: string | null }; + entries: Entry[]; + page: number; + total: number; +} + +const PAGE_SIZE = 50; + +export function CollectionPage({ kind, user, entries, page, total }: Props) { + const { t } = useTranslation("journal"); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const heading = t(`social.${kind}.heading`, { + user: user.displayName ?? user.username, + }); + + return ( +
+ +

{heading}

+

{t(`social.${kind}.count`, { count: total })}

+ + {entries.length === 0 ? ( +

+ {t(`social.${kind}.empty`)} +

+ ) : ( + + )} + + {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/apps/journal/app/components/FollowButton.tsx b/apps/journal/app/components/FollowButton.tsx new file mode 100644 index 0000000..1a0d466 --- /dev/null +++ b/apps/journal/app/components/FollowButton.tsx @@ -0,0 +1,62 @@ +import { useState, useTransition } from "react"; +import { useTranslation } from "react-i18next"; + +interface FollowState { + following: boolean; + pending: boolean; +} + +interface Props { + username: string; + initialState: FollowState | null; +} + +export function FollowButton({ username, initialState }: Props) { + const { t } = useTranslation("journal"); + const [state, setState] = useState( + initialState ?? { following: false, pending: false }, + ); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const onClick = () => { + setError(null); + startTransition(async () => { + const path = state.following + ? `/api/users/${username}/unfollow` + : `/api/users/${username}/follow`; + try { + const res = await fetch(path, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error ?? "Failed"); + return; + } + const body = (await res.json()) as { following: boolean; pending: boolean }; + setState({ following: body.following, pending: body.pending }); + } catch (e) { + setError((e as Error).message); + } + }); + }; + + const label = state.following ? t("social.unfollow") : t("social.follow"); + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 4918894..5b3562a 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { activities, routes, syncImports, users } from "@trails-cool/db/schema/journal"; +import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; import { setGeomFromGpx } from "./routes.server.ts"; @@ -134,6 +134,43 @@ export async function listPublicActivitiesForOwner(ownerId: string) { return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } +/** + * Social feed: aggregated public activities from users that `followerId` + * follows (accepted only). Reverse-chronological. Joins users for owner + * attribution. Unlisted/private activities never appear, regardless of + * follow state. + */ +export async function listSocialFeed(followerId: string, limit: number = 50) { + const db = getDb(); + const rows = await db + .select({ + id: activities.id, + name: activities.name, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + ownerUsername: users.username, + ownerDisplayName: users.displayName, + }) + .from(activities) + .innerJoin(follows, eq(follows.followedUserId, activities.ownerId)) + .innerJoin(users, eq(activities.ownerId, users.id)) + .where( + and( + eq(follows.followerId, followerId), + eq(activities.visibility, "public"), + ), + ) + .orderBy(desc(activities.createdAt)) + .limit(limit); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + /** * Instance-wide public activity feed. Joins users so the caller can * render "by " without a second round-trip. Used by the diff --git a/apps/journal/app/lib/actor-iri.ts b/apps/journal/app/lib/actor-iri.ts new file mode 100644 index 0000000..0721da6 --- /dev/null +++ b/apps/journal/app/lib/actor-iri.ts @@ -0,0 +1,8 @@ +// Canonical ActivityPub actor IRI for a local user. Used as the key in +// `follows.followed_actor_iri` so the column shape is identical for local +// and (future) federated follows. Reading from `process.env.ORIGIN` keeps +// us aligned with the rest of the auth/federation stack. +export function localActorIri(username: string): string { + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + return `${origin}/users/${username}`; +} diff --git a/apps/journal/app/lib/follow.integration.test.ts b/apps/journal/app/lib/follow.integration.test.ts new file mode 100644 index 0000000..9ae2c11 --- /dev/null +++ b/apps/journal/app/lib/follow.integration.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows } from "@trails-cool/db/schema/journal"; +import { + followUser, + unfollowUser, + getFollowState, + countFollowers, + countFollowing, + FollowError, +} from "./follow.server.ts"; + +// Opt-in: these talk to real Postgres. Gated by an env flag so laptop +// runs without Postgres aren't blocked. Same convention as +// demo-bot.integration.test.ts. +const runIntegration = process.env.FOLLOW_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("follow.server integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1`); + }); + afterEach(wipe); + + it("follow + unfollow cycle on a public profile", async () => { + const a = await makeUser({ username: `f_a_${Date.now()}` }); + const b = await makeUser({ username: `f_b_${Date.now()}` }); + const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!; + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + expect(await getFollowState(a, bRow.username)).toBeNull(); + + const s1 = await followUser(a, bRow.username); + expect(s1).toEqual({ following: true, pending: false }); + expect(await countFollowers(b)).toBe(1); + expect(await countFollowing(a)).toBe(1); + + // Idempotent + const s2 = await followUser(a, bRow.username); + expect(s2).toEqual({ following: true, pending: false }); + expect(await countFollowers(b)).toBe(1); + + const s3 = await unfollowUser(a, bRow.username); + expect(s3).toEqual({ following: false, pending: false }); + expect(await countFollowers(b)).toBe(0); + expect(await getFollowState(a, bRow.username)).toBeNull(); + + // Idempotent + const s4 = await unfollowUser(a, bRow.username); + expect(s4).toEqual({ following: false, pending: false }); + + // touch aRow so the variable is read (lint hygiene) + expect(aRow.username.startsWith("f_a_")).toBe(true); + }); + + it("refuses to follow a private profile", async () => { + const a = await makeUser({ username: `f_pa_${Date.now()}` }); + const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + await expect(followUser(a, bRow.username)).rejects.toBeInstanceOf(FollowError); + await expect(followUser(a, bRow.username)).rejects.toMatchObject({ code: "private_profile" }); + expect(await countFollowing(a)).toBe(0); + }); + + it("refuses self-follow", async () => { + const a = await makeUser({ username: `f_self_${Date.now()}` }); + const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!; + await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" }); + }); + + it("404s on unknown username", async () => { + const a = await makeUser({ username: `f_404_${Date.now()}` }); + await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" }); + expect(await countFollowing(a)).toBe(0); + // suppress lint by using afterEach effect indirectly + expect(typeof a).toBe("string"); + // also touch follows table so the import isn't unused + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followerId, a)); + expect(rows.length).toBe(0); + }); +}); diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts new file mode 100644 index 0000000..cc198b0 --- /dev/null +++ b/apps/journal/app/lib/follow.server.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { eq, and, count, desc } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { users, follows } from "@trails-cool/db/schema/journal"; +import { localActorIri } from "./actor-iri.ts"; + +export class FollowError extends Error { + readonly code: "self_follow" | "private_profile" | "user_not_found" | "not_found"; + constructor(code: FollowError["code"], message: string) { + super(message); + this.name = "FollowError"; + this.code = code; + } +} + +export interface FollowState { + following: boolean; + pending: boolean; +} + +async function loadFollowableTarget(targetUsername: string) { + const db = getDb(); + const [target] = await db + .select({ + id: users.id, + username: users.username, + profileVisibility: users.profileVisibility, + }) + .from(users) + .where(eq(users.username, targetUsername)); + if (!target) throw new FollowError("user_not_found", "User not found"); + return target; +} + +/** + * Create a follow row from `followerId` to the local user with username + * `targetUsername`. Auto-accepted because the target is local + public. + * Idempotent: re-following an already-followed user returns the same state + * without creating a duplicate row. + */ +export async function followUser(followerId: string, targetUsername: string): Promise { + const target = await loadFollowableTarget(targetUsername); + if (target.id === followerId) { + throw new FollowError("self_follow", "Users cannot follow themselves"); + } + if (target.profileVisibility !== "public") { + throw new FollowError("private_profile", "This profile is not followable"); + } + + const db = getDb(); + const followedActorIri = localActorIri(target.username); + const [existing] = await db + .select({ id: follows.id, acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + if (existing) { + return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; + } + + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri, + followedUserId: target.id, + acceptedAt: new Date(), + }); + + return { following: true, pending: false }; +} + +/** + * Delete the follow row from `followerId` against the local user with + * username `targetUsername`. Idempotent. + */ +export async function unfollowUser(followerId: string, targetUsername: string): Promise { + const target = await loadFollowableTarget(targetUsername); + const db = getDb(); + const followedActorIri = localActorIri(target.username); + await db + .delete(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + return { following: false, pending: false }; +} + +/** + * Read-side helper: is `followerId` currently following `targetUsername`? + * Returns `null` when no row exists (so callers can distinguish "no follow" + * from "row exists but unaccepted" once federation's Pending state lands). + */ +export async function getFollowState( + followerId: string, + targetUsername: string, +): Promise { + const db = getDb(); + const followedActorIri = localActorIri(targetUsername); + const [row] = await db + .select({ acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + if (!row) return null; + return { following: row.acceptedAt !== null, pending: row.acceptedAt === null }; +} + +export async function countFollowers(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(eq(follows.followedUserId, userId)); + return row?.n ?? 0; +} + +export async function countFollowing(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(eq(follows.followerId, userId)); + return row?.n ?? 0; +} + +export interface CollectionEntry { + username: string; + displayName: string | null; + domain: string; +} + +const COLLECTION_PAGE_SIZE = 50; + +/** + * Paginated list of users who follow `userId`. Newest acceptance first. + */ +export async function listFollowers(userId: string, page: number = 1): Promise { + 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, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(eq(follows.followedUserId, userId)) + .orderBy(desc(follows.acceptedAt)) + .limit(COLLECTION_PAGE_SIZE) + .offset(offset); + return rows; +} + +/** + * Paginated list of users that `userId` follows. Newest acceptance first. + * Limited to local follows in this change (`followedUserId IS NOT NULL`); + * federation will surface remote actors here too. + */ +export async function listFollowing(userId: string, page: number = 1): Promise { + 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, + }) + .from(follows) + .innerJoin(users, eq(follows.followedUserId, users.id)) + .where(eq(follows.followerId, userId)) + .orderBy(desc(follows.acceptedAt)) + .limit(COLLECTION_PAGE_SIZE) + .offset(offset); + return rows; +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 6d6d2b4..a1ceb98 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -88,6 +88,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { {user && ( <> + + {t("social.feed.title")} + {t("nav.routes")} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 20e7dfe..64893f6 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -23,6 +23,11 @@ export default [ route("activities/new", "routes/activities.new.tsx"), route("activities/:id", "routes/activities.$id.tsx"), route("users/:username", "routes/users.$username.tsx"), + route("users/:username/followers", "routes/users.$username.followers.tsx"), + route("users/:username/following", "routes/users.$username.following.tsx"), + route("api/users/:username/follow", "routes/api.users.$username.follow.ts"), + route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"), + route("feed", "routes/feed.tsx"), route("settings", "routes/settings.tsx"), route("api/settings/profile", "routes/api.settings.profile.ts"), route("api/settings/email", "routes/api.settings.email.ts"), diff --git a/apps/journal/app/routes/api.settings.profile.ts b/apps/journal/app/routes/api.settings.profile.ts index 6a84bae..79abf04 100644 --- a/apps/journal/app/routes/api.settings.profile.ts +++ b/apps/journal/app/routes/api.settings.profile.ts @@ -12,11 +12,18 @@ export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); const displayName = (formData.get("displayName") as string)?.trim() || null; const bio = (formData.get("bio") as string)?.trim().slice(0, 160) || null; + const rawVisibility = formData.get("profileVisibility") as string | null; + const profileVisibility: "public" | "private" | undefined = + rawVisibility === "public" || rawVisibility === "private" ? rawVisibility : undefined; const db = getDb(); await db .update(users) - .set({ displayName, bio }) + .set({ + displayName, + bio, + ...(profileVisibility ? { profileVisibility } : {}), + }) .where(eq(users.id, user.id)); return data({ ok: true }); diff --git a/apps/journal/app/routes/api.users.$username.follow.ts b/apps/journal/app/routes/api.users.$username.follow.ts new file mode 100644 index 0000000..83bff8f --- /dev/null +++ b/apps/journal/app/routes/api.users.$username.follow.ts @@ -0,0 +1,26 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.users.$username.follow"; +import { getSessionUser } from "~/lib/auth.server"; +import { followUser, FollowError } from "~/lib/follow.server"; + +export async function action({ request, params }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const username = params.username; + if (!username) return data({ error: "username required" }, { status: 400 }); + + try { + const state = await followUser(user.id, username); + return data({ ok: true, ...state }); + } catch (e) { + if (e instanceof FollowError) { + const status = e.code === "user_not_found" ? 404 : 400; + return data({ error: e.message, code: e.code }, { status }); + } + throw e; + } +} diff --git a/apps/journal/app/routes/api.users.$username.unfollow.ts b/apps/journal/app/routes/api.users.$username.unfollow.ts new file mode 100644 index 0000000..485dc0f --- /dev/null +++ b/apps/journal/app/routes/api.users.$username.unfollow.ts @@ -0,0 +1,26 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.users.$username.unfollow"; +import { getSessionUser } from "~/lib/auth.server"; +import { unfollowUser, FollowError } from "~/lib/follow.server"; + +export async function action({ request, params }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const username = params.username; + if (!username) return data({ error: "username required" }, { status: 400 }); + + try { + const state = await unfollowUser(user.id, username); + return data({ ok: true, ...state }); + } catch (e) { + if (e instanceof FollowError) { + const status = e.code === "user_not_found" ? 404 : 400; + return data({ error: e.message, code: e.code }, { status }); + } + throw e; + } +} diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx new file mode 100644 index 0000000..ce7ff89 --- /dev/null +++ b/apps/journal/app/routes/feed.tsx @@ -0,0 +1,102 @@ +import { data, redirect } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/feed"; +import { getSessionUser } from "~/lib/auth.server"; +import { listSocialFeed } from "~/lib/activities.server"; +import { ClientDate } from "~/components/ClientDate"; +import { ClientMap } from "~/components/ClientMap"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const rows = await listSocialFeed(user.id, 50); + return data({ + activities: rows.map((a) => ({ + id: a.id, + name: a.name, + distance: a.distance, + elevationGain: a.elevationGain, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + geojson: a.geojson ?? null, + ownerUsername: a.ownerUsername, + ownerDisplayName: a.ownerDisplayName, + })), + }); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Feed — trails.cool" }]; +} + +export default function Feed({ loaderData }: Route.ComponentProps) { + const { activities } = loaderData; + const { t } = useTranslation("journal"); + + return ( +
+

{t("social.feed.heading")}

+ + {activities.length === 0 ? ( +
+

{t("social.feed.empty")}

+ + {t("social.feed.publicFeedLink")} + +
+ ) : ( + + )} +
+ ); +} diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index a1850aa..f03cffa 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -167,12 +167,20 @@ export default function Home({ loaderData }: Route.ComponentProps) { {user.displayName ?? user.username} - - {t("activities.new")} - + {showAddPasskey && !passkeyDone && supportsPasskey === true && ( diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index b653d13..c88ca1f 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -394,6 +394,20 @@ export default function PrivacyPage() { including on your public profile at /users/<you> and on search engines that index those pages. +
  • + Profile visibility (public / private, + default public): a separate switch from content + visibility. private 404s your profile page and makes + you unfollowable; you can still post public content + reachable by direct URL. Change anytime in account settings. +
  • +
  • + Follows: which users on this instance follow which. Visible to + anyone via your /users/<you>/followers and + /users/<you>/following pages, mirroring + Mastodon-style conventions. Set your profile to{" "} + private to be unfollowable. +
  • diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx index 1fc2595..3855d97 100644 --- a/apps/journal/app/routes/settings.tsx +++ b/apps/journal/app/routes/settings.tsx @@ -48,6 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) { email: user.email, displayName: user.displayName, bio: user.bio, + profileVisibility: user.profileVisibility, }, passkeys: passkeys.map((p) => ({ id: p.id, @@ -77,6 +78,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const [displayName, setDisplayName] = useState(user.displayName ?? ""); const [bio, setBio] = useState(user.bio ?? ""); + const [profileVisibility, setProfileVisibility] = useState<"public" | "private">( + user.profileVisibility, + ); const [profileSaved, setProfileSaved] = useState(false); const [newEmail, setNewEmail] = useState(""); @@ -202,6 +206,49 @@ export default function Settings({ loaderData }: Route.ComponentProps) { />

    {bio.length}/160

    +
    + + {t("settings.profile.visibility.label")} + +
    + + +
    +