Merge pull request #319 from trails-cool/feed-followed-public-toggle
Add Followed/Public toggle to /feed for signed-in users
This commit is contained in:
commit
dc7a781e74
5 changed files with 122 additions and 29 deletions
|
|
@ -1,17 +1,28 @@
|
||||||
import { data, redirect } from "react-router";
|
import { data, redirect } from "react-router";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/feed";
|
import type { Route } from "./+types/feed";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
|
||||||
|
type View = "followed" | "public";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) throw redirect("/auth/login");
|
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({
|
return data({
|
||||||
|
view,
|
||||||
activities: rows.map((a) => ({
|
activities: rows.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
|
@ -27,27 +38,77 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
export function meta({ data: loaderData }: Route.MetaArgs) {
|
||||||
return [{ title: "Feed — trails.cool" }];
|
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 (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={`-mb-px inline-flex items-center border-b-2 px-1 py-3 text-sm font-medium ${
|
||||||
|
active
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-600 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Feed({ loaderData }: Route.ComponentProps) {
|
export default function Feed({ loaderData }: Route.ComponentProps) {
|
||||||
const { activities } = loaderData;
|
const { view, activities } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t("social.feed.heading")}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-6 border-b border-gray-200">
|
||||||
|
<ToggleLink
|
||||||
|
to="/feed"
|
||||||
|
active={view === "followed"}
|
||||||
|
label={t("social.feed.toggle.followed")}
|
||||||
|
/>
|
||||||
|
<ToggleLink
|
||||||
|
to="/feed?view=public"
|
||||||
|
active={view === "public"}
|
||||||
|
label={t("social.feed.toggle.public")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<p className="text-gray-500">{t("social.feed.empty")}</p>
|
<p className="text-gray-500">{emptyMessage}</p>
|
||||||
<a
|
{view === "followed" && (
|
||||||
href="/"
|
<Link
|
||||||
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
|
to="/feed?view=public"
|
||||||
>
|
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
|
||||||
{t("social.feed.publicFeedLink")}
|
>
|
||||||
</a>
|
{t("social.feed.seePublic")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-6 space-y-4">
|
<ul className="mt-6 space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -62,15 +62,19 @@ Activities SHALL be allowed to exist without a linked route.
|
||||||
- **THEN** the activity is stored with route_id as null
|
- **THEN** the activity is stored with route_id as null
|
||||||
|
|
||||||
### Requirement: Instance-wide public activity feed
|
### 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
|
#### Scenario: Public activity appears in the visitor home feed
|
||||||
- **WHEN** an owner marks an activity as `public` and another visitor loads the home page
|
- **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
|
- **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
|
#### Scenario: Private or unlisted activities stay out of the feed
|
||||||
- **WHEN** an activity's visibility is `private` or `unlisted`
|
- **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
|
### Requirement: Activity visibility
|
||||||
The Journal SHALL persist a `visibility` value on every activity and SHALL allow the owner to change it.
|
The Journal SHALL persist a `visibility` value on every activity and SHALL allow the owner to change it.
|
||||||
|
|
|
||||||
|
|
@ -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`
|
- **THEN** they are redirected to `/auth/login`
|
||||||
|
|
||||||
### Requirement: Social activity feed
|
### 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
|
#### Scenario: Followed view aggregates accepted-followed users' public activities
|
||||||
- **WHEN** a signed-in user with one or more accepted follows loads `/feed`
|
- **WHEN** a signed-in user with one or more accepted follows loads `/feed` (or `/feed?view=followed`)
|
||||||
- **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
|
- **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
|
#### Scenario: Public view aggregates instance-wide public activities
|
||||||
- **WHEN** a signed-in user has only Pending follows (no acceptance yet)
|
- **WHEN** a signed-in user loads `/feed?view=public`
|
||||||
- **THEN** the feed renders the empty state — no Pending-target content is fetched or shown
|
- **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`
|
- **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=<anything-other-than-public>`
|
||||||
|
- **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
|
#### 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`
|
- **THEN** they are redirected to `/auth/login`
|
||||||
|
|
||||||
### Requirement: Schema is forward-compatible with federation
|
### Requirement: Schema is forward-compatible with federation
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,15 @@ export default {
|
||||||
title: "Feed",
|
title: "Feed",
|
||||||
heading: "Folge ich",
|
heading: "Folge ich",
|
||||||
empty: "Du folgst noch niemandem. Stöbere Profile durch und klicke auf Folgen, um deinen Feed aufzubauen.",
|
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: {
|
demo: {
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,15 @@ export default {
|
||||||
title: "Feed",
|
title: "Feed",
|
||||||
heading: "Following",
|
heading: "Following",
|
||||||
empty: "You're not following anyone yet. Browse profiles and tap Follow to start building your feed.",
|
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: {
|
demo: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue