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
38 changes: 23 additions & 15 deletions apps/extensions/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { PersonalFeed } from "../../web/src/components/personal-feed";
import { ReadLater } from "../../web/src/components/read-later";
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";
import { ReaderNavigationRecovery } from "../../web/src/components/reader-navigation-recovery";
import { extensionRoute, type ExtensionRoute } from "./routes";

Expand Down Expand Up @@ -258,28 +259,35 @@ function ExtensionReader() {
<>
<Reader route={readerRoute} match={readerMatch} />
{article && <Preview slug={article.slug} direct={!background} />}
<SignupNudge pathname={pathname} />
</>
);
}

function ExtensionSignupNudge() {
const route = useRoute();
return <SignupNudge pathname={new URL(route, publicOrigin).pathname} />;
}

startExtensionAnalytics();

createRoot(document.getElementById("root")!).render(
<AccountSession>
<ReaderNavigationRecovery />
<ThemePreferencesProvider>
<NotificationPreferencesProvider>
<FeedPreferencesProvider>
<ArticleNavigationProvider>
<SourceFollowsProvider>
<TopicFollowsProvider>
<ExtensionReader />
</TopicFollowsProvider>
</SourceFollowsProvider>
</ArticleNavigationProvider>
</FeedPreferencesProvider>
</NotificationPreferencesProvider>
</ThemePreferencesProvider>
<ReaderPromptsProvider>
<ReaderNavigationRecovery />
<ThemePreferencesProvider>
<NotificationPreferencesProvider>
<FeedPreferencesProvider>
<ArticleNavigationProvider>
<SourceFollowsProvider>
<TopicFollowsProvider>
<ExtensionReader />
<ExtensionSignupNudge />
</TopicFollowsProvider>
</SourceFollowsProvider>
</ArticleNavigationProvider>
</FeedPreferencesProvider>
</NotificationPreferencesProvider>
</ThemePreferencesProvider>
</ReaderPromptsProvider>
</AccountSession>,
);
7 changes: 7 additions & 0 deletions apps/extensions/src/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TopicsContent } from "../../web/src/components/topics-content";
import { SourcesContent } from "../../web/src/components/sources-content";
import { ProfileSettings } from "../../web/src/components/profile-settings";
import { Leaderboard } from "../../web/src/components/leaderboard";
import { DevCardCreator } from "../../web/src/components/dev-card-creator";
import { AppearanceSettings } from "../../web/src/components/appearance-settings";
import { FeedSettings } from "../../web/src/components/feed-settings";
import { NotificationSettings } from "../../web/src/components/notification-settings";
Expand Down Expand Up @@ -77,6 +78,12 @@ export function LocalPage({ route }: { route: string }) {
const url = new URL(route, "https://devfeed.tech");
const match = extensionRoute(url.pathname);
if (!match || match.type !== "local") return null;
if (match.page === "dev-card")
return (
<UserShell>
<DevCardCreator />
</UserShell>
);
if (match.page === "leaderboard")
return (
<UserShell section="leaderboard">
Expand Down
2 changes: 2 additions & 0 deletions apps/extensions/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ExtensionRoute =
| { type: "local"; page: "source-suggestion" }
| { type: "local"; page: "mcp" }
| { type: "local"; page: "leaderboard" }
| { type: "local"; page: "dev-card" }
| { type: "local"; page: "mcp-authorize" }
| { type: "article"; slug: string }
| { type: "profile"; username: string }
Expand Down Expand Up @@ -45,6 +46,7 @@ export function extensionRoute(pathname: string): ExtensionRoute | null {
if (pathname === "/mcp/authorize") return { type: "local", page: "mcp-authorize" };
if (pathname === "/mcp") return { type: "local", page: "mcp" };
if (pathname === "/leaderboard") return { type: "local", page: "leaderboard" };
if (pathname === "/dev-card") return { type: "local", page: "dev-card" };

const settings = settingsRoutes[pathname];
if (settings) return { type: "local", page: "settings", settings };
Expand Down
22 changes: 20 additions & 2 deletions apps/extensions/tests/auth.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { chromium } from "playwright";
import { checkFeedOnboarding } from "../../../scripts/testing/feed-onboarding.mjs";
import { checkFeedOnboarding } from "../../web/tests/browser/feed-onboarding.mjs";
import {
checkSignupPrompts,
signupPromptArticle,
} from "../../web/tests/browser/signup-prompts.mjs";
import { checkTopicFollow } from "../../../scripts/testing/topic-follow.mjs";
import {
onboardingTopics,
Expand Down Expand Up @@ -305,7 +309,12 @@ test(
: [],
next_cursor: onboarding ? "60" : null,
});
if (url.pathname.startsWith("/api/v1/articles/")) return send({ article, topic: null });
if (url.pathname.startsWith("/api/v1/articles/"))
return send({
article:
signupPromptArticle(article, url.pathname.slice("/api/v1/articles/".length)) ?? article,
topic: null,
});
if (url.pathname === "/api/v1/user/engagement")
return send([
{
Expand Down Expand Up @@ -499,6 +508,15 @@ test(
await checkDevCardPromo(page, path.resolve(extension, "../dev-card-promo-" + browser), {
extension: true,
});
await checkSignupPrompts(
context,
page.url(),
path.resolve(extension, `../${browser}-signup`),
{
extension: true,
},
);
await page.bringToFront();
// Synchronize the clock after promo checks without freezing Date.now():
// focus refreshes must be able to advance past the session throttle.
await page.clock.setSystemTime(await page.evaluate(() => Date.now()));
Expand Down
2 changes: 2 additions & 0 deletions apps/extensions/tests/newtab.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ test("one route registry classifies every extension-owned page", () => {
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" }));
assert.equal(route("/dev-card"), JSON.stringify({ type: "local", page: "dev-card" }));
assert.equal(linkDestination("/dev-card"), "#/dev-card");
assert.equal(linkDestination("/leaderboard"), "#/leaderboard");
assert.equal(
route("/settings/topics"),
Expand Down
48 changes: 37 additions & 11 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,17 @@ control. The browser suites exercise failure followed by successful retry.

Reader pages must not exhaust a catalog or feed to render the first screen. The shared web reader and Chrome/Edge extensions use the same incremental choices and scrolling controls.

| Surface | Initial work | Continuation |
| --- | --- | --- |
| Your topics / Your sources | Render account shell; after authentication and saved preferences, request one 60-item catalog page | Scroll requests one cursor; name search starts a new server-filtered page |
| My feed onboarding | Check saved topic IDs; eligible readers request one ranked topic page | Scroll within the dialog; server-filtered name search preserves selections |
| Public topics / sources | One 60-item directory page | Scroll fetches the next page |
| Latest and filtered feeds | One feed page, bounded topic suggestions, and filter options | Feed cursor requests on scroll |
| Topic/source detail feeds | Direct item lookup, then one feed page and bounded supporting reads | Feed cursor requests on scroll; extensions never scan directories to resolve a slug |
| Article preview | One article lookup and its featured topic lookup | No catalog pagination; extension cached-article fallback uses one direct topic lookup |
| My feed / Read later / Trending | One feed page | Cursor requests on scroll; My feed refreshes its first page when active |
| Search | One search response | Independent section cursors on scroll |
| Public Markdown catalog/feed routes | One bounded page | Next-page links, without draining catalogs |
| Surface | Initial work | Continuation |
| ----------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Your topics / Your sources | Render account shell; after authentication and saved preferences, request one 60-item catalog page | Scroll requests one cursor; name search starts a new server-filtered page |
| My feed onboarding | Check saved topic IDs; eligible readers request one ranked topic page | Scroll within the dialog; server-filtered name search preserves selections |
| Public topics / sources | One 60-item directory page | Scroll fetches the next page |
| Latest and filtered feeds | One feed page, bounded topic suggestions, and filter options | Feed cursor requests on scroll |
| Topic/source detail feeds | Direct item lookup, then one feed page and bounded supporting reads | Feed cursor requests on scroll; extensions never scan directories to resolve a slug |
| Article preview | One article lookup and its featured topic lookup | No catalog pagination; extension cached-article fallback uses one direct topic lookup |
| My feed / Read later / Trending | One feed page | Cursor requests on scroll; My feed refreshes its first page when active |
| Search | One search response | Independent section cursors on scroll |
| Public Markdown catalog/feed routes | One bounded page | Next-page links, without draining catalogs |

The settings server components do not preload catalogs for signed-out visitors. Saved topic/source IDs are loaded independently of visible catalog rows; saving a partially loaded selection must retain IDs outside that page. Search is debounced and applied by the public API before offset/limit, so it finds entries that have never been downloaded. Matching is case-insensitive literal name containment, including literal `%` and `_`. Existing publication, approval, enabled-source and active-topic filters still apply.

Expand All @@ -77,6 +77,32 @@ Shared browser regression helpers test preferences, public directories, onboardi

The public API adds the optional `q` parameter to topics/sources. Deploy that API before the corresponding reader/extension release: an older API ignores `q` and cannot provide correct global catalog search. New same-origin topic/source detail endpoints must also be live before distributing the updated extensions. No database migration is needed. These source changes and local validations do not prove a production latency improvement until a separately authorized deployment is measured.

## Reader invitations

The signup invitation appears after three distinct article routes, once sign-in
status has resolved. **Not now** dismisses automatic account invitations for the
session, including the anonymous Dev Card promotion. Dismissing an anonymous Dev
Card also silences the signup invitation. The banner sits above mobile navigation,
so readers can still change views without dismissing it. This reuses
`devfeed:dev-card-promo-dismissed`, so existing refusals stay respected across
navigation and reloads. Signed-in Dev Card dismissal remains account-specific.
When session storage is blocked, the mounted reader keeps dismissal in memory;
it cannot persist that refusal through a full reload without browser storage.

On the website, Chrome and Edge, topic onboarding takes priority while preferences
load. Invitations wait behind it and native article or
account dialogs; an active signup invitation also defers the automatic Dev Card.
Closing or completing topic onboarding pauses automatic invitations for 30 seconds.
Explicit **Preview your card** actions bypass that pause and session refusal, and
can replace a signup banner, while still waiting for an existing modal to close.
There is one active invitation/onboarding prompt per reader. The coordinator
releases it on unmount and restores the existing dialog scroll behavior.

`tests/browser/signup-prompts.mjs` runs the dismissal and coordination journeys
against the website and both built extensions; `feed-onboarding.mjs` also checks
onboarding priority and the pause. Review screenshots stay in ignored reports or
extension build output.

## Dev card signup preview

Anonymous feeds and signed-in feeds whose reader has not claimed a username show
Expand Down
33 changes: 18 additions & 15 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { UserProvider } from "@/components/user-account";
import { ThemePreferencesProvider } from "@/components/theme-preferences";
import { FeedPreferencesProvider } from "@/components/feed-preferences";
import { WebSignupNudge } from "@/components/web-signup-nudge";
import { ReaderPromptsProvider } from "@/components/reader-prompts";
import { ReaderNavigationRecovery } from "@/components/reader-navigation-recovery";
export async function generateMetadata(): Promise<Metadata> {
await connection();
Expand Down Expand Up @@ -81,21 +82,23 @@ twq('config','pc5f8');`}
<BrowserTelemetry {...browserSettings("web")} />
<JsonLd data={siteStructuredData(brandMark.src)} />
<UserProvider>
<ThemePreferencesProvider>
<NotificationPreferencesProvider>
<FeedPreferencesProvider>
<ArticleNavigationProvider>
<SourceFollowsProvider>
<TopicFollowsProvider>
{children}
{modal}
</TopicFollowsProvider>
</SourceFollowsProvider>
<WebSignupNudge />
</ArticleNavigationProvider>
</FeedPreferencesProvider>
</NotificationPreferencesProvider>
</ThemePreferencesProvider>
<ReaderPromptsProvider>
<ThemePreferencesProvider>
<NotificationPreferencesProvider>
<FeedPreferencesProvider>
<ArticleNavigationProvider>
<SourceFollowsProvider>
<TopicFollowsProvider>
{children}
{modal}
</TopicFollowsProvider>
</SourceFollowsProvider>
<WebSignupNudge />
</ArticleNavigationProvider>
</FeedPreferencesProvider>
</NotificationPreferencesProvider>
</ThemePreferencesProvider>
</ReaderPromptsProvider>
</UserProvider>
{gaId && <DeferredGoogleAnalytics gaId={gaId} />}
{clarityId && <DeferredClarity projectId={clarityId} />}
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/components/dev-card-promo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import type { UserProfile, UserStack } from "@/lib/user";
import type { Topic } from "@/lib/types";
import { trackEvent } from "@/lib/analytics";
import styles from "./dev-card-promo.module.css";
import {
anonymousInvitationDismissedKey as dismissedKey,
useReaderPromptCoordinator,
} from "./reader-prompts";

const dismissedKey = "devfeed:dev-card-promo-dismissed";
function remembered(key: string) {
try {
return sessionStorage.getItem(key) === "true";
Expand All @@ -32,6 +35,9 @@ function remember(key: string) {

export function DevCardPromo({ requested = false }: { requested?: boolean }) {
const { user, loading, unavailable, profile, profileUnavailable } = useUser();
const prompts = useReaderPromptCoordinator();
const acquire = prompts?.acquire;
const release = prompts?.release;
const dismissalKey = user ? `${dismissedKey}:${user.user_id}` : dismissedKey;
const [dismissed, setDismissed] = useState(true);
const [revealed, setRevealed] = useState(false);
Expand Down Expand Up @@ -63,7 +69,12 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) {
return () => cancelAnimationFrame(frame);
}, [requested, dismissalKey]);
const unclaimed = Boolean(profile && !profileUnavailable && !profile.username);
const shown = !dismissed && !loading && (!user || pending || unclaimed);
const shown =
!dismissed &&
!loading &&
(!prompts || prompts.ready) &&
(requested || !!user || !prompts?.guestDismissed) &&
(!user || pending || unclaimed);
useEffect(() => {
if (!shown || personal) return;
const controller = new AbortController();
Expand Down Expand Up @@ -103,8 +114,10 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) {
let timer: ReturnType<typeof setTimeout>;
let detailsTimer: ReturnType<typeof setTimeout>;
const open = () => {
// Let welcome, article, and account dialogs finish before presenting this one.
if (document.querySelector("dialog[open]")) {
if (
document.querySelector("dialog[open]") ||
(acquire && !acquire("dev-card", id, requested))
) {
timer = setTimeout(open, 250);
return;
}
Expand All @@ -130,10 +143,12 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) {
document.body.style.overflow = previousOverflow;
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });
}
release?.(id);
};
}, [shown, requested, cardReady]);
}, [shown, requested, cardReady, acquire, release, id]);
function dismiss() {
remember(dismissalKey);
if (!user) prompts?.dismissGuestInvitations();
setDismissed(true);
}
if (!shown || !cardReady) return null;
Expand Down
Loading
Loading