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
24 changes: 23 additions & 1 deletion apps/extensions/tests/auth.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
signupPromptArticle,
} from "../../web/tests/browser/signup-prompts.mjs";
import { checkTopicFollow } from "../../../scripts/testing/topic-follow.mjs";
import { checkSourceFollow } from "../../web/tests/browser/source-follow.mjs";
import {
onboardingTopics,
onboardingSources,
Expand Down Expand Up @@ -104,6 +105,8 @@ test(
let rejectTopics = true;
let rejectTopicFollow = true;
let savedTopicIds = ["typescript"];
let savedSourceIds = [];
let rejectSourceFollow = true;
const errors = [];
const analytics = [];
const handle = async (route) => {
Expand Down Expand Up @@ -194,6 +197,19 @@ test(
: savedTopicIds.filter((id) => id !== "typescript");
return send({ followed: payload.followed });
}
if (url.pathname.startsWith("/api/v1/user/preferences/sources/")) {
const payload = route.request().postDataJSON();
assert.deepEqual(Object.keys(payload), ["followed"]);
if (payload.followed && rejectSourceFollow) {
rejectSourceFollow = false;
return send({}, 503);
}
const id = url.pathname.split("/").at(-1);
savedSourceIds = payload.followed
? [...new Set([...savedSourceIds, id])]
: savedSourceIds.filter((value) => value !== id);
return send({ followed: payload.followed });
}
if (url.pathname === "/api/v1/user/preferences") {
const payload = route.request().postDataJSON();
assert.deepEqual(payload, { topic_ids: ["typescript", "onboarding-0", "onboarding-1"] });
Expand Down Expand Up @@ -358,7 +374,7 @@ test(
return send(interactionChecks ? notificationFixture() : { items: [], next_cursor: null });
if (endpoint === "preferences") return send({ topic_ids: savedTopicIds });
if (endpoint.endsWith("/preferences")) return send({ preferences: [] });
if (endpoint === "preferences/sources") return send({ source_ids: [] });
if (endpoint === "preferences/sources") return send({ source_ids: savedSourceIds });
if (endpoint === "feed") {
if (onboarding) {
if (onboardingSaved) onboarding = false;
Expand Down Expand Up @@ -797,6 +813,12 @@ test(
`${localBase}#/topics/typescript/news?language=en`,
path.resolve(extension, `../${browser}-onboarding`),
);
await checkSourceFollow(
page,
`${localBase}#/sources`,
onboardingSources,
path.resolve(extension, `../${browser}-onboarding`),
);

interactionChecks = true;
const interactions = await context.newPage();
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@dualmark/core": "0.11.0",
"@dualmark/nextjs": "0.11.0",
"@microsoft/clarity": "1.0.2",
"@tanstack/react-query": "5.104.1",
"lucide-react": "^1.49.0",
"next": "16.3.8",
"radix-ui": "1.6.7",
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/reader-query-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";

/** Mounted inside UserProvider's account boundary; private data stays in this tab/session. */
export function ReaderQueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 0,
retry: false,
networkMode: "always",
refetchOnWindowFocus: false,
refetchOnReconnect: (query) => query.state.status === "error",
},
// Writes must fail visibly rather than being replayed on a different session.
mutations: { retry: false, networkMode: "always" },
},
}),
);
useEffect(() => () => client.clear(), [client]);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
98 changes: 6 additions & 92 deletions apps/web/src/components/source-follow.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { AccountError, userRequest } from "@/lib/user";
import { createContext, useContext } from "react";
import { useFollowPreferences } from "@/lib/use-follow-preferences";
import { useUser } from "./user-account";
import { FollowButton } from "./follow-button";

type Preferences = { source_ids: string[] };
const Context = createContext({
ids: [] as string[],
loading: true,
Expand All @@ -22,98 +21,13 @@ const Context = createContext({
});
export const useSourceFollows = () => useContext(Context);
export function SourceFollowsProvider({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser();
const owner = user?.user_id ?? "guest";
const [state, setState] = useState<{ owner: string; ids: string[]; unavailable: boolean } | null>(
null,
);
const [busy, setBusy] = useState<string[]>([]);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const request = useRef<AbortController | null>(null);
const mutations = useRef(new Set<string>());
useEffect(() => {
if (!user) return;
const controller = new AbortController();
request.current = controller;
userRequest<Preferences>("preferences/sources", {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]),
})
.then((value) => {
if (!controller.signal.aborted)
setState({ owner, ids: value.source_ids, unavailable: false });
})
.catch(() => {
if (!controller.signal.aborted) setState({ owner, ids: [], unavailable: true });
});
return () => controller.abort();
}, [user, owner, revision]);
const current = state?.owner === owner ? state : null;
async function mutate(ids: string[], id?: string) {
if (
!user ||
!current ||
current.unavailable ||
mutations.current.has(id ?? "all") ||
mutations.current.has("all") ||
(!id && mutations.current.size)
)
return false;
const token = id ?? "all";
const controller = request.current;
if (!controller || controller.signal.aborted) return false;
mutations.current.add(token);
setBusy([...mutations.current]);
setError("");
try {
const followed = id ? !current.ids.includes(id) : false;
const result = await userRequest<Preferences | { followed: boolean }>(
`preferences/sources${id ? `/${id}` : ""}`,
{
method: "PUT",
headers: { "Content-Type": "application/json", "X-CSRF-Token": user.csrf_token },
body: JSON.stringify(id ? { followed } : { source_ids: ids }),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]),
},
);
if (controller.signal.aborted) return false;
setState((previous) => ({
owner,
unavailable: false,
ids:
"source_ids" in result
? result.source_ids
: result.followed
? [...new Set([...(previous?.owner === owner ? previous.ids : []), id!])]
: (previous?.ids ?? []).filter((value) => value !== id),
}));
return true;
} catch (cause) {
if (!controller.signal.aborted)
setError(
cause instanceof AccountError && cause.status === 422
? "Choose up to 100 approved sources."
: "Couldn’t update your sources. Please try again.",
);
return false;
} finally {
mutations.current.delete(token);
setBusy([...mutations.current]);
}
}
const follows = useFollowPreferences("source");
return (
<Context.Provider
value={{
ids: current?.ids ?? [],
loading: loading || (!!user && !current),
unavailable: !!current?.unavailable,
busy,
error,
refresh: () => setRevision((value) => value + 1),
toggle: async (id) => {
await mutate([], id);
},
save: (ids) => mutate(ids),
...follows,
error: Object.values(follows.errors).find(Boolean) ?? "",
save: async (ids) => (await follows.save(ids)) !== null,
}}
>
{children}
Expand Down
164 changes: 8 additions & 156 deletions apps/web/src/components/topic-follows.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { createContext, useContext, useEffect, useRef, useState } from "react";
import { AccountError, userRequest, type Preferences } from "@/lib/user";
import { useUser } from "./user-account";
import { createContext, useContext } from "react";
import type { Preferences } from "@/lib/user";
import { useFollowPreferences } from "@/lib/use-follow-preferences";

const Context = createContext({
ids: [] as string[],
Expand All @@ -23,163 +23,15 @@ const Context = createContext({
export const useTopicFollows = () => useContext(Context);

export function TopicFollowsProvider({ children }: { children: React.ReactNode }) {
const { user, loading, sessionRevision } = useUser();
const owner = user?.user_id ?? "guest";
const [state, setState] = useState<{
owner: string;
ids: string[];
unavailable: boolean;
signal: AbortSignal;
} | null>(null);
const [feedback, setFeedback] = useState({
owner,
signal: null as AbortSignal | null,
busy: [] as string[],
errors: {} as Record<string, string>,
messages: {} as Record<string, string>,
});
const [revision, setRevision] = useState(0);
const request = useRef<{ owner: string; controller: AbortController; failed: boolean } | null>(
null,
);
const mutations = useRef(new Map<string, AbortController>());
useEffect(() => {
if (loading || owner === "guest") return;
const controller = new AbortController();
const active = { owner, controller, failed: false };
request.current = active;
userRequest<Preferences>("preferences", {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]),
})
.then((value) => {
if (!controller.signal.aborted)
setState({ owner, ids: value.topic_ids, unavailable: false, signal: controller.signal });
})
.catch(() => {
if (!controller.signal.aborted) {
active.failed = true;
setState({ owner, ids: [], unavailable: true, signal: controller.signal });
}
});
return () => controller.abort();
}, [owner, loading, revision, sessionRevision]);
useEffect(() => {
const resume = () => {
if (document.visibilityState === "visible" && request.current?.failed) {
request.current.failed = false;
setRevision((value) => value + 1);
}
};
window.addEventListener("focus", resume);
document.addEventListener("visibilitychange", resume);
return () => {
window.removeEventListener("focus", resume);
document.removeEventListener("visibilitychange", resume);
};
}, []);
const current = state?.owner === owner && !state.signal.aborted ? state : null;
const currentFeedback = feedback.owner === owner && !feedback.signal?.aborted ? feedback : null;
async function mutate(ids: string[], id?: string): Promise<Preferences | null> {
const active = request.current;
const token = id ?? "all";
const pending = [...mutations.current.entries()].filter(
([, controller]) => !controller.signal.aborted,
);
if (
!user ||
!current ||
current.unavailable ||
!active ||
active.owner !== owner ||
active.controller.signal.aborted ||
pending.some(([key]) => key === token || key === "all") ||
(!id && pending.length)
)
return null;
const controller = active.controller;
mutations.current.set(token, controller);
setFeedback((previous) => ({
owner,
signal: controller.signal,
busy: [...pending.map(([key]) => key), token],
errors: { ...(previous.owner === owner ? previous.errors : {}), [token]: "" },
messages: { ...(previous.owner === owner ? previous.messages : {}), [token]: "" },
}));
try {
const result = await userRequest<Preferences | { followed: boolean }>(
id ? `preferences/topics/${id}` : "preferences",
{
method: "PUT",
headers: { "Content-Type": "application/json", "X-CSRF-Token": user.csrf_token },
body: JSON.stringify(id ? { followed: !current.ids.includes(id) } : { topic_ids: ids }),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]),
},
);
if (controller.signal.aborted) return null;
setState((previous) => ({
owner,
signal: controller.signal,
unavailable: false,
ids:
"topic_ids" in result
? result.topic_ids
: result.followed
? [...new Set([...(previous?.owner === owner ? previous.ids : []), id!])]
: (previous?.owner === owner ? previous.ids : []).filter((value) => value !== id),
}));
if ("followed" in result)
setFeedback((previous) => ({
...previous,
messages: {
...previous.messages,
[token]: result.followed ? "Topic followed." : "Topic unfollowed.",
},
}));
return "topic_ids" in result
? result
: {
topic_ids: result.followed
? [...new Set([...current.ids, id!])]
: current.ids.filter((value) => value !== id),
};
} catch (error) {
if (!controller.signal.aborted)
setFeedback((previous) => ({
...previous,
errors: {
...previous.errors,
[token]:
error instanceof AccountError && error.status === 422
? "You can follow up to 100 active topics. Manage your topics to make room."
: "Couldn’t update this topic. Please try again.",
},
}));
return null;
} finally {
if (mutations.current.get(token) === controller) mutations.current.delete(token);
if (!controller.signal.aborted)
setFeedback((previous) => ({
...previous,
busy: [...mutations.current.entries()]
.filter(([, value]) => !value.signal.aborted)
.map(([key]) => key),
}));
}
}
const follows = useFollowPreferences("topic");
return (
<Context.Provider
value={{
ids: current?.ids ?? [],
loading: loading || (!!user && !current),
unavailable: !!current?.unavailable,
busy: currentFeedback?.busy ?? [],
errors: currentFeedback?.errors ?? {},
messages: currentFeedback?.messages ?? {},
refresh: () => setRevision((value) => value + 1),
toggle: async (id) => {
await mutate([], id);
...follows,
save: async (ids) => {
const result = await follows.save(ids);
return result ? { topic_ids: result } : null;
},
save: (ids) => mutate(ids),
}}
>
{children}
Expand Down
Loading
Loading