Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A Chrome and Microsoft Edge Manifest V3 extension that renders DevFeed directly
without redirecting to the website or embedding it in an iframe.

The extension bundles the web reader's React components and styles. Feed, search,
article previews, personalized feed, read later, topics, sources, source suggestions,
article previews, personalized feed, read later, trending, topics, sources, source suggestions,
and all six settings sections use local hash routes. Publisher links and website
sign-in open a separate tab. Chrome's own new-tab footer is browser UI and is not
part of the extension's layout.
Expand Down Expand Up @@ -109,6 +109,14 @@ Topics and sources use the existing paginated public catalog endpoints.

## Reader parity

Grid/List icons beside Filters share the website's article layout control. Grid is
the default; List uses compact rows across My feed, Latest, topic/source feeds,
Trending and Read later. The last two expose the control in their headings. Guest
choices stay in the extension's browser storage; signed-in choices use account feed
settings without changing content or language selections. Loaded pages, bookmarks,
engagement data and preview navigation survive layout changes. `/trending` is a
local reader route in both extensions.

My feed uses `/`, the public latest feed uses `/latest`, and signed-out new tabs
redirect to `/latest` without Read later navigation. Both extensions use the web
reader's recommendation component and retain its current generation during likes,
Expand Down
20 changes: 20 additions & 0 deletions apps/extensions/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { rememberArticles } from "./public-cache";
import { AccountSession, signedOut } from "./session";
import { PersonalFeed } from "../../web/src/components/personal-feed";
import { ReadLater } from "../../web/src/components/read-later";
import { TrendingContent } from "../../web/src/components/trending-content";
import { NotificationPreferencesProvider } from "../../web/src/components/notification-preferences-provider";
import { SignupNudge } from "../../web/src/components/signup-nudge";
import { ReaderPromptsProvider } from "../../web/src/components/reader-prompts";
Expand Down Expand Up @@ -72,6 +73,7 @@ function Reader({
const key = `${route}:${revision}:${sessionLoading ? "loading" : (user?.user_id ?? "guest")}:${user?.csrf_token ?? ""}`;
const url = new URL(route, publicOrigin);
const search = match.page === "search";
const trending = match.page === "trending";
const personal = match.page === "personal";
const router = useRouter();
useEffect(() => {
Expand Down Expand Up @@ -109,12 +111,23 @@ function Reader({
key: string;
feed?: FeedContentProps;
search?: SearchResponse | null;
trending?: PromiseSettledResult<FeedPage>;
}>();
useEffect(() => {
if (sessionLoading || personal || bookmarks) return;
const controller = new AbortController();
const { signal } = controller;
async function load() {
if (trending) {
const [feed] = await Promise.allSettled([
read<FeedPage>(
`/api/v1/user/trending?limit=24&cursor=${encodeURIComponent(filters.cursor)}`,
signal,
),
]);
if (!signal.aborted) setState({ key, trending: feed });
return;
}
if (search) {
const result = query
? await read<SearchResponse>(`/api/v1/search?${url.searchParams}`, signal).catch(
Expand Down Expand Up @@ -220,6 +233,13 @@ function Reader({
))}
</UserShell>
);
if (trending)
return (
<TrendingContent
feed={state?.key === key ? state.trending : undefined}
cursor={filters.cursor}
/>
);
return state?.key === key && state.feed ? (
<FeedContent key={key} {...state.feed} />
) : (
Expand Down
3 changes: 2 additions & 1 deletion apps/extensions/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ExtensionRoute =
| { type: "profile"; username: string }
| {
type: "reader";
page: "personal" | "bookmarks" | "search" | "feed";
page: "personal" | "bookmarks" | "search" | "feed" | "trending";
detail?: { kind: CatalogKind; slug: string; contentType?: string };
contentType?: string;
};
Expand All @@ -41,6 +41,7 @@ export function extensionRoute(pathname: string): ExtensionRoute | null {
if (pathname === "/latest") return { type: "reader", page: "feed" };
if (pathname === "/search") return { type: "reader", page: "search" };
if (pathname === "/read-later") return { type: "reader", page: "bookmarks" };
if (pathname === "/trending") return { type: "reader", page: "trending" };
if (pathname === "/sources/suggest") return { type: "local", page: "source-suggestion" };

if (pathname === "/mcp/authorize") return { type: "local", page: "mcp-authorize" };
Expand Down
12 changes: 12 additions & 0 deletions apps/extensions/tests/auth.browser.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { checkArticleViews } from "../../web/tests/browser/article-views.mjs";
import { dailyFixture, checkMustReads } from "../../../scripts/testing/must-reads.mjs";
import { checkAvatarUploads } from "../../../scripts/testing/avatar-uploads.mjs";
import { checkLeaderboard } from "../../../scripts/testing/leaderboard.mjs";
Expand Down Expand Up @@ -661,6 +662,12 @@ test(
await page.locator(".article-card").first().waitFor();
assert.equal(page.url(), personalUrl, "generation recovery stays inside the extension");
assert.equal(rejectNextPage, false, "the stale cursor was rejected");
await checkArticleViews(
page,
personalUrl,
path.resolve(extension, `../${browser}-personal-view`),
[personalUrl.replace(/#\/$/, "#/latest"), personalUrl],
);
const feedRequests = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/v1/user/feed")
Expand Down Expand Up @@ -747,6 +754,11 @@ test(
await page.getByRole("button", { name: "Save article for later", exact: true }).click();
await page.getByRole("button", { name: "Remove bookmark", exact: true }).waitFor();
await page.locator(".sidebar").getByRole("link", { name: "Read later", exact: true }).click();
await checkArticleViews(
page,
page.url(),
path.resolve(extension, `../${browser}-saved-view`),
);
await checkPreviewBackground(
page,
path.resolve(extension, `../${browser}-bookmarks-preview.png`),
Expand Down
1 change: 1 addition & 0 deletions apps/extensions/tests/newtab.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const { extensionRoute } = await bundled("../src/routes.ts");

test("one route registry classifies every extension-owned page", () => {
const route = (pathname) => JSON.stringify(extensionRoute(pathname));
assert.equal(route("/trending"), JSON.stringify({ type: "reader", page: "trending" }));
assert.equal(route("/mcp/authorize"), JSON.stringify({ type: "local", page: "mcp-authorize" }));
assert.equal(route("/mcp"), JSON.stringify({ type: "local", page: "mcp" }));
assert.equal(route("/leaderboard"), JSON.stringify({ type: "local", page: "leaderboard" }));
Expand Down
16 changes: 14 additions & 2 deletions apps/extensions/tests/reader.browser.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { checkArticleGrid } from "../../../scripts/testing/article-grid.mjs";
import { checkArticleViews } from "../../web/tests/browser/article-views.mjs";
import {
avatarFixtureVariants,
avatarFixtureImage,
Expand All @@ -10,7 +11,7 @@ import {
engagementFeed,
engagementRows,
checkEngagementPagination,
} from "../../../scripts/testing/engagement-pagination.mjs";
} from "../../web/tests/browser/engagement-pagination.mjs";
import { checkPreviewBackground } from "../../../scripts/testing/preview-background.mjs";
import { blockedFeed, checkArticleFirst } from "../../web/tests/browser/article-first.mjs";
import {
Expand Down Expand Up @@ -169,7 +170,9 @@ test(
if (url.pathname === "/api/v1/user/auth/login")
return route.fulfill(await signInResponse(url.pathname.slice(4) + url.search));
let json;
if (url.pathname === "/api/v1/feed") {
if (url.pathname === "/api/v1/user/trending") {
json = { items: feedItems, next_cursor: null };
} else if (url.pathname === "/api/v1/feed") {
if (
url.searchParams.has("cursor") &&
url.searchParams.get("sort") === "most_liked" &&
Expand Down Expand Up @@ -262,6 +265,15 @@ test(
await page.locator(".article-card").first().waitFor();
await checkManagedImages(page);
await checkArticleGrid(page, path.join(extension, "../grid-" + browser));
const base = page.url().split("#")[0];
await checkArticleViews(
page,
`${base}#/latest`,
path.join(extension, `../${browser}-article-view`),
[`${base}#/topics/javascript`, `${base}#/sources/publisher`, `${base}#/trending`],
);
await page.goto(`${base}#/latest`);
await page.locator(".article-card").first().waitFor();
assert.ok(page.url().startsWith("chrome-extension://"));
await page.waitForURL(/#\/latest$/);
for (const status of [404, 503]) {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,21 @@ controls. `/mcp/authorize` is a private, non-indexed consent page that preserves
the pending request through normal DevFeed sign-in. See `apps/mcp/README.md` for
OAuth ingress routes and user-service/Redis configuration.

### Article views

Use the Grid and List icons beside Filters to switch layouts without resetting the
loaded articles, order or pagination. The same choice applies to My feed, Latest,
topic/source feeds, Trending and Read later; feeds without filters show the control
in their heading. Grid is the default. Guests keep their choice in this browser,
and signed-in readers save it to the existing feed preference for their account.
Content and language selections stay unchanged. Failed saves restore the previous
layout with a retryable message.

List displays compact rows with article titles, source, publication date and article
actions. Narrow lists keep source/date details beneath the title and a 44px bookmark
target, while omitting secondary engagement controls and topic links. Layout changes
retain loaded engagement data and the article preview's background list.

### Responsive article cards

The shared web and extension card grid chooses columns from the available feed width,
Expand Down
6 changes: 0 additions & 6 deletions apps/web/src/app/article-share.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,16 +131,10 @@
.article-share-popover[data-state="closed"] {
animation: reader-popover 120ms ease-in reverse;
}
.article-table .activity-column {
width: 19%;
}
@media (max-width: 700px) {
.article-quick-actions {
gap: 4px;
}
.article-table .activity-column {
width: 29%;
}
}
@media (prefers-reduced-motion: reduce) {
.article-share-popover {
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/app/styles/feed.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,129 @@
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 12px;
}
.article-table-wrap {
container: article-list / inline-size;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--card);
}
.article-table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
font-size: 12px;
}
.article-table .source-column {
width: 18%;
}
.article-table .date-column {
width: 105px;
}
.article-table .activity-column {
width: 160px;
}
.article-table th {
padding: 9px 12px;
text-align: left;
font-weight: 500;
color: var(--muted-foreground);
background: var(--muted);
}
.article-table td {
border-top: 1px solid var(--border);
padding: 6px 12px;
vertical-align: middle;
overflow-wrap: anywhere;
}
.article-table tbody tr:hover {
background: var(--accent);
}
.article-list-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 13px;
line-height: 1.4;
font-weight: 600;
color: var(--foreground);
}
.article-list-title:hover {
color: var(--primary);
}
.article-list-meta {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
align-items: center;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.article-list-meta a {
color: var(--primary);
}
.article-list-type {
text-transform: capitalize;
}
.article-list-mobile-source {
display: none;
}
.article-list-meta .recommendation-reason {
margin: 0;
font-size: 11px;
}
.article-table .article-quick-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 6px;
}
.article-table .article-engagement {
flex-wrap: nowrap;
gap: 6px;
}
.article-table .heart-button {
padding: 4px;
}
.article-table td.source-column > a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container article-list (max-width: 700px) {
.article-table .source-column,
.article-table .date-column {
display: none;
}
.article-table .activity-column {
width: 60px;
}
.article-table th,
.article-table td {
padding: 6px 8px;
}
.article-table .article-engagement {
display: none;
}
.article-list-mobile-source {
display: block;
width: 100%;
}
.article-list-meta > a {
display: none;
}
.article-table .bookmark-button {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
}
}
.article-card {
container: article-card / inline-size;
position: relative;
Expand Down
Loading
Loading