v4: the followed feed's two views are pages — "From people" and "From playlists" on a feed's "follow" tab were a state of the page, which a reload, Back or a shared link lost, while a profile's same split is /@name/follow/playlists. They are now /follow and /follow/playlists, and /music/follow/playlists in a category, feedPath taking the view as userPath does (FOLLOW_VIEWS); picking a category keeps it. Their dumps come from GET /api/dumps?follow=users|playlists, signed in, which the feed's word for it names, instead of GET /api/me/feed?from=; an unknown category is a 404 there, as on the rest of the feed, rather than an empty list.
A feed's tab, as a profile's, a search's or the community page's, now takes the place of the current history entry, since it is a view of the same page; picking a category, another page, still adds one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,12 @@ import { useNavigate } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
|
||||
import { feedPath, type FeedTab } from "../../../shared/paths.ts";
|
||||
import {
|
||||
feedPath,
|
||||
type FeedTab,
|
||||
FOLLOW_VIEWS,
|
||||
type FollowView,
|
||||
} from "../../../shared/paths.ts";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
|
||||
@@ -19,22 +24,34 @@ export interface FeedTabBarProps {
|
||||
categorySlug?: string;
|
||||
/** The tab shown, the viewer's default one on a bare path. */
|
||||
tab: FeedTab;
|
||||
/** The view of the "follow" tab, which picking a category keeps. */
|
||||
followView?: FollowView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in
|
||||
* user. A tab leads to the same scope: `/new` for every dump, `/music/new` for
|
||||
* a category, and the viewer's default tab to the bare `/` or `/music`.
|
||||
* Picking a category keeps the tab, from `/music/new` to `/cinema/new`.
|
||||
* a category, and the viewer's default tab to the bare `/` or `/music`. A tab
|
||||
* is a view of the same page, so it takes the place of the current history
|
||||
* entry, while picking a category, another page, adds one and keeps the tab
|
||||
* and its view, from `/music/follow/playlists` to `/cinema/follow/playlists`.
|
||||
* `Index` renders it twice, in the header and below it, and CSS shows one by
|
||||
* screen width.
|
||||
*/
|
||||
export function FeedTabBar(
|
||||
{ categorySlug, tab }: FeedTabBarProps,
|
||||
{ categorySlug, tab, followView }: FeedTabBarProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const defaultTab = useViewerDefaultFeedTab();
|
||||
// The default tab is the bare path, but a view of it other than its default
|
||||
// needs the tab's segment before its own.
|
||||
const pathOf = (slug: string | undefined, t: FeedTab, view?: FollowView) =>
|
||||
feedPath(
|
||||
slug,
|
||||
t === defaultTab && (!view || view === FOLLOW_VIEWS[0]) ? undefined : t,
|
||||
view,
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
|
||||
@@ -49,14 +66,12 @@ export function FeedTabBar(
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTab={tab}
|
||||
onChange={(t) =>
|
||||
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
|
||||
onChange={(t) => navigate(pathOf(categorySlug, t), { replace: true })}
|
||||
leading={
|
||||
<CategorySwitcher
|
||||
kind="dump"
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}
|
||||
onSelect={(slug) => navigate(pathOf(slug, tab, followView))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -26,20 +27,20 @@ import {
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
type FollowedSection = "users" | "playlists";
|
||||
import { feedPath, type FollowView } from "../../../shared/paths.ts";
|
||||
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
|
||||
|
||||
/**
|
||||
* Returns one section's feed, from `GET /api/me/feed?from=<section>`, and the
|
||||
* call that loads its next page. A section cached like the index's feed starts
|
||||
* loaded and isn't fetched.
|
||||
* Returns one view's feed, from `GET /api/dumps?follow=<view>`, and the call
|
||||
* that loads its next page. A view cached like the index's feed starts loaded
|
||||
* and isn't fetched.
|
||||
*/
|
||||
function useFollowedSection(
|
||||
section: FollowedSection,
|
||||
function useFollowedView(
|
||||
section: FollowView,
|
||||
categorySlug: string | undefined,
|
||||
): { state: DumpFeedState; loadMore: () => void } {
|
||||
const { user, authFetch } = useAuth();
|
||||
const url = `${API_URL}/api/me/feed?from=${section}${
|
||||
const url = `${API_URL}/api/dumps?follow=${section}${
|
||||
categorySlug ? `&category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
|
||||
@@ -108,22 +109,26 @@ function FollowedSubFeed(
|
||||
* signed-in user only. Unlike the other tabs, it loads its own dumps.
|
||||
*/
|
||||
export interface FollowedFeedProps {
|
||||
/** Scopes both sections to a category. */
|
||||
/** Scopes both views to a category. */
|
||||
categorySlug?: string;
|
||||
/** The view the path names: `/follow`, or `/follow/playlists`. */
|
||||
view: FollowView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "followed" tab in two sections, "from people" and "from
|
||||
* playlists", from `GET /api/me/feed?from=users` and
|
||||
* `GET /api/me/feed?from=playlists`. Both load at once, and each keeps its own
|
||||
* pages, cached like the index's feed.
|
||||
* Renders the "follow" tab in two views, "from people" at `/follow` and "from
|
||||
* playlists" at `/follow/playlists`, from `GET /api/dumps?follow=users` and
|
||||
* `GET /api/dumps?follow=playlists`. Both load at once, so switching is
|
||||
* instant, and each keeps its own pages, cached like the index's feed.
|
||||
* Switching takes the place of the current history entry, as a tab does.
|
||||
*/
|
||||
export function FollowedFeed(
|
||||
{ categorySlug }: FollowedFeedProps,
|
||||
{ categorySlug, view }: FollowedFeedProps,
|
||||
): React.JSX.Element {
|
||||
const users = useFollowedSection("users", categorySlug);
|
||||
const playlists = useFollowedSection("playlists", categorySlug);
|
||||
const [section, setSection] = useState<FollowedSection>("users");
|
||||
const users = useFollowedView("users", categorySlug);
|
||||
const playlists = useFollowedView("playlists", categorySlug);
|
||||
const navigate = useNavigate();
|
||||
const defaultTab = useViewerDefaultFeedTab();
|
||||
|
||||
return (
|
||||
<div className="followed-feed">
|
||||
@@ -132,12 +137,20 @@ export function FollowedFeed(
|
||||
{ key: "users", label: <Trans>From people</Trans> },
|
||||
{ key: "playlists", label: <Trans>From playlists</Trans> },
|
||||
]}
|
||||
activeTab={section}
|
||||
onChange={setSection}
|
||||
activeTab={view}
|
||||
onChange={(v) =>
|
||||
navigate(
|
||||
feedPath(
|
||||
categorySlug,
|
||||
defaultTab === "follow" && v === "users" ? undefined : "follow",
|
||||
v,
|
||||
),
|
||||
{ replace: true },
|
||||
)}
|
||||
innerClassName="followed-sub-nav"
|
||||
/>
|
||||
|
||||
{section === "users" && (
|
||||
{view === "users" && (
|
||||
<FollowedSubFeed
|
||||
state={users.state}
|
||||
emptyMessage={t`Follow some users to see their dumps here.`}
|
||||
@@ -145,7 +158,7 @@ export function FollowedFeed(
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "playlists" && (
|
||||
{view === "playlists" && (
|
||||
<FollowedSubFeed
|
||||
state={playlists.state}
|
||||
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
||||
|
||||
Reference in New Issue
Block a user