From b6d8c621f8a515dd9316ce5be728af0a38ca1d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 09:03:38 +0200 Subject: [PATCH] Add Followed/Public toggle to /feed for signed-in users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream A from docs/information-architecture.md: signed-in users now have a single /feed destination with two views — Followed (default, people they accepted-follow) and Public (instance-wide). Logging in no longer hides the public instance feed; switching is a query-param flip that's bookmarkable and SSR-rendered. - apps/journal/app/routes/feed.tsx — loader reads ?view=, branches fetch (listSocialFeed vs listRecentPublicActivities, both already exist), passes view to the component. Component renders a tab strip at the top using plain s so the toggle works without JS. Per- view title and empty state. The "see public feed" escape from the empty Followed view now points at ?view=public instead of /, keeping the user on /feed. - packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ }, social.feed.public.{heading,empty}, and social.feed.seePublic keys; old social.feed.publicFeedLink renamed to seePublic. - openspec/specs/social-follows/spec.md — Social activity feed requirement extended with the two-view structure, including the Public view, the toggle, and the unrecognized-value fallback. - openspec/specs/activity-feed/spec.md — Instance-wide public activity feed requirement notes the Public view of /feed is now a consumer alongside the visitor home. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/feed.tsx | 87 +++++++++++++++++++++++---- openspec/specs/activity-feed/spec.md | 12 ++-- openspec/specs/social-follows/spec.md | 32 +++++++--- packages/i18n/src/locales/de.ts | 10 ++- packages/i18n/src/locales/en.ts | 10 ++- 5 files changed, 122 insertions(+), 29 deletions(-) diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index ce7ff89..755ac3b 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -1,17 +1,28 @@ import { data, redirect } from "react-router"; +import { Link } 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 { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +type View = "followed" | "public"; + 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); + const url = new URL(request.url); + const view: View = url.searchParams.get("view") === "public" ? "public" : "followed"; + + const rows = + view === "public" + ? await listRecentPublicActivities(50) + : await listSocialFeed(user.id, 50); + return data({ + view, activities: rows.map((a) => ({ id: a.id, name: a.name, @@ -27,27 +38,77 @@ export async function loader({ request }: Route.LoaderArgs) { }); } -export function meta(_args: Route.MetaArgs) { - return [{ title: "Feed — trails.cool" }]; +export function meta({ data: loaderData }: Route.MetaArgs) { + const view = loaderData?.view ?? "followed"; + const title = view === "public" ? "Public — trails.cool" : "Following — trails.cool"; + return [{ title }]; +} + +function ToggleLink({ + to, + active, + label, +}: { + to: string; + active: boolean; + label: string; +}) { + return ( + + {label} + + ); } export default function Feed({ loaderData }: Route.ComponentProps) { - const { activities } = loaderData; + const { view, activities } = loaderData; const { t } = useTranslation("journal"); + const heading = + view === "public" + ? t("social.feed.public.heading") + : t("social.feed.heading"); + + const emptyMessage = + view === "public" + ? t("social.feed.public.empty") + : t("social.feed.empty"); + return (
-

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

+

{heading}

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

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

- - {t("social.feed.publicFeedLink")} - +

{emptyMessage}

+ {view === "followed" && ( + + {t("social.feed.seePublic")} + + )}
) : (
    diff --git a/openspec/specs/activity-feed/spec.md b/openspec/specs/activity-feed/spec.md index 4d98795..1687517 100644 --- a/openspec/specs/activity-feed/spec.md +++ b/openspec/specs/activity-feed/spec.md @@ -62,15 +62,19 @@ Activities SHALL be allowed to exist without a linked route. - **THEN** the activity is stored with route_id as null ### Requirement: Instance-wide public activity feed -The Journal SHALL expose a reverse-chronological feed of every activity on the instance with visibility `public`. This feed powers the instance home page (see `journal-landing`). `private` and `unlisted` activities SHALL NOT appear in this feed; `unlisted` stays reachable by direct URL only. +The Journal SHALL expose a reverse-chronological feed of every activity on the instance with visibility `public`. This feed powers the visitor home page (see `journal-landing`) for anonymous traffic and the Public view of `/feed` (see `social-follows` spec, "Social activity feed") for signed-in viewers. `private` and `unlisted` activities SHALL NOT appear in this feed; `unlisted` stays reachable by direct URL only. -#### Scenario: Public activity appears in the instance feed -- **WHEN** an owner marks an activity as `public` and another visitor loads the home page +#### Scenario: Public activity appears in the visitor home feed +- **WHEN** an owner marks an activity as `public` and an anonymous visitor loads the home page - **THEN** the visitor sees the activity in the instance feed, with the owner's display name, distance, and start date +#### Scenario: Public activity appears in the signed-in /feed?view=public view +- **WHEN** an owner marks an activity as `public` and a signed-in user loads `/feed?view=public` +- **THEN** the signed-in user sees the activity in the Public view, regardless of whether they follow the owner + #### Scenario: Private or unlisted activities stay out of the feed - **WHEN** an activity's visibility is `private` or `unlisted` -- **THEN** it does not appear in the instance feed, regardless of who is viewing +- **THEN** it does not appear in the instance feed on either surface, regardless of who is viewing ### Requirement: Activity visibility The Journal SHALL persist a `visibility` value on every activity and SHALL allow the owner to change it. diff --git a/openspec/specs/social-follows/spec.md b/openspec/specs/social-follows/spec.md index 56628d2..052ef9b 100644 --- a/openspec/specs/social-follows/spec.md +++ b/openspec/specs/social-follows/spec.md @@ -82,22 +82,34 @@ A signed-in user SHALL have an actionable surface listing every Pending follow r - **THEN** they are redirected to `/auth/login` ### Requirement: Social activity feed -The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, listing public activities from the local users they follow with an accepted relation. `private` and `unlisted` activities SHALL NOT appear regardless of follow state. Pending follows SHALL NOT contribute content to the feed. +The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, with two views selectable via `?view=`: **Followed** (default; public activities from the local users they follow with an accepted relation) and **Public** (instance-wide public activities — see `activity-feed` spec, "Instance-wide public activity feed"). The page SHALL render a tab/toggle at the top so the viewer can switch between the two views. `private` and `unlisted` activities SHALL NOT appear in either view. Pending follows SHALL NOT contribute content to the Followed view. -#### Scenario: Feed aggregates accepted-followed users' public activities -- **WHEN** a signed-in user with one or more accepted follows loads `/feed` -- **THEN** the page shows the most recent public activities across all accepted-followed users, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail +#### Scenario: Followed view aggregates accepted-followed users' public activities +- **WHEN** a signed-in user with one or more accepted follows loads `/feed` (or `/feed?view=followed`) +- **THEN** the Followed view is selected and shows the most recent public activities across all accepted-followed users, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail -#### Scenario: Pending follows don't contribute to the feed -- **WHEN** a signed-in user has only Pending follows (no acceptance yet) -- **THEN** the feed renders the empty state — no Pending-target content is fetched or shown +#### Scenario: Public view aggregates instance-wide public activities +- **WHEN** a signed-in user loads `/feed?view=public` +- **THEN** the Public view is selected and shows the most recent public activities across the entire instance, reverse-chronological, up to 50 per page, regardless of follow state -#### Scenario: Empty feed state +#### Scenario: Pending follows don't contribute to the Followed view +- **WHEN** a signed-in user has only Pending follows (no acceptance yet) and loads the Followed view +- **THEN** the view renders the empty state — no Pending-target content is fetched or shown + +#### Scenario: Empty Followed view links to the Public view - **WHEN** a signed-in user with zero accepted follows loads `/feed` -- **THEN** the page shows an empty-state message pointing them to `/users/:username` pages to follow someone, and suggests the instance public feed as an alternative +- **THEN** the Followed view shows an empty-state message and an in-page link to switch to the Public view (`?view=public`), so they can browse the instance without leaving `/feed` + +#### Scenario: Empty Public view +- **WHEN** the instance has zero public activities and a signed-in user loads `/feed?view=public` +- **THEN** the Public view shows an empty-state message; no link to elsewhere is required + +#### Scenario: Unrecognized view value falls back to Followed +- **WHEN** a signed-in user loads `/feed?view=` +- **THEN** the loader treats the request as the Followed view (default) without raising an error — the parameter is opaque from the client's perspective #### Scenario: Logged-out visitor cannot access the feed -- **WHEN** an unauthenticated visitor requests `/feed` +- **WHEN** an unauthenticated visitor requests `/feed` (any view) - **THEN** they are redirected to `/auth/login` ### Requirement: Schema is forward-compatible with federation diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3f7aff8..e651fd3 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -291,7 +291,15 @@ export default { title: "Feed", heading: "Folge ich", empty: "Du folgst noch niemandem. Stöbere Profile durch und klicke auf Folgen, um deinen Feed aufzubauen.", - publicFeedLink: "Oder durchstöbere den öffentlichen Feed dieser Instanz →", + seePublic: "Oder durchstöbere den öffentlichen Feed dieser Instanz →", + toggle: { + followed: "Folge ich", + public: "Öffentlich", + }, + public: { + heading: "Öffentlich", + empty: "Noch keine öffentlichen Aktivitäten auf dieser Instanz.", + }, }, }, demo: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b3ab391..e509866 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -291,7 +291,15 @@ export default { title: "Feed", heading: "Following", empty: "You're not following anyone yet. Browse profiles and tap Follow to start building your feed.", - publicFeedLink: "Or browse the instance public feed →", + seePublic: "Or browse the instance public feed →", + toggle: { + followed: "Following", + public: "Public", + }, + public: { + heading: "Public", + empty: "No public activities on this instance yet.", + }, }, }, demo: {