Skip to content

Commit 965c3ab

Browse files
committed
fix(web): hide public sync alerts and handle digest emails
1 parent ae745f4 commit 965c3ab

20 files changed

Lines changed: 552 additions & 46 deletions

File tree

apps/web/app/[owner]/__tests__/owner-page-content.test.tsx

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { OWNER_PAGE_PUBLIC_REPOS_PREVIEW_LIMIT } from "@stackmatch/constants/social";
2+
import { SYNC_STUCK_REPO_THRESHOLD_MS } from "@stackmatch/constants/sync";
23
import { cleanup, render, screen } from "@testing-library/react";
34
import userEvent from "@testing-library/user-event";
45
import { renderToStaticMarkup } from "react-dom/server";
5-
import { afterEach, describe, expect, it } from "vitest";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
67
import { getWebAlertTitle } from "@/lib/feedback/alert-registry";
78
import {
89
getRepoSyncLastProgressAt,
910
hasNoPublicRepos,
1011
type OwnerPageData,
1112
OwnerPageProfileDetails,
1213
PublicPreviewBanner,
14+
resolveOwnerSyncPresentation,
1315
} from "../owner-page-content";
1416
import {
1517
isOwnerPublicPreview,
@@ -23,11 +25,17 @@ import { NotableProjectsSection } from "../sections/notable-projects-section";
2325
import { getNotableProjects, type NotableProjectRepo } from "../sections/notable-projects-utils";
2426

2527
afterEach(() => {
28+
vi.restoreAllMocks();
2629
cleanup();
2730
});
2831

2932
const REQUESTED_AT = Date.parse("2023-11-14T22:13:20.000Z");
3033
const SYNC_LAST_PROGRESS_AT = REQUESTED_AT + 1;
34+
const OWNER_SYNC_NOW = Date.parse("2026-06-01T00:00:00.000Z");
35+
const STALE_OWNER_SYNC_PROGRESS_AT = OWNER_SYNC_NOW - SYNC_STUCK_REPO_THRESHOLD_MS - 1;
36+
37+
type SyncPresentationData = Parameters<typeof resolveOwnerSyncPresentation>[0];
38+
type SyncPresentationRepo = SyncPresentationData["repos"][number];
3139

3240
function makeRepo(overrides: Partial<NotableProjectRepo> & { name: string }): NotableProjectRepo {
3341
const { name, ...repoOverrides } = overrides;
@@ -47,6 +55,42 @@ function makeRepo(overrides: Partial<NotableProjectRepo> & { name: string }): No
4755
};
4856
}
4957

58+
function makeSyncPresentationRepo(
59+
overrides: Partial<SyncPresentationRepo> & Pick<SyncPresentationRepo, "name" | "syncStatus">
60+
): SyncPresentationRepo {
61+
const { name, syncStatus, ...repoOverrides } = overrides;
62+
63+
return {
64+
repoId: `repo:${name}`,
65+
name,
66+
fullName: `octocat/${name}`,
67+
syncStatus,
68+
scannedPackageCount: 0,
69+
scannedManifestCount: 0,
70+
stars: 0,
71+
requestedAt: REQUESTED_AT,
72+
isExcluded: false,
73+
...repoOverrides,
74+
} as SyncPresentationRepo;
75+
}
76+
77+
function makeSyncPresentationData(repos: SyncPresentationRepo[]): SyncPresentationData {
78+
return {
79+
owner: "octocat",
80+
repos,
81+
syncCounts: {
82+
total: repos.length,
83+
pending: repos.filter((repo) => repo.syncStatus === "pending").length,
84+
queued: repos.filter((repo) => repo.syncStatus === "queued").length,
85+
syncing: repos.filter((repo) => repo.syncStatus === "syncing").length,
86+
synced: repos.filter((repo) => repo.syncStatus === "synced").length,
87+
error: repos.filter((repo) => repo.syncStatus === "error").length,
88+
},
89+
isOwnerViewer: true,
90+
publicLastSyncedAt: OWNER_SYNC_NOW,
91+
} as SyncPresentationData;
92+
}
93+
5094
describe("public preview UI", () => {
5195
it("renders an exit link back to the normal owner profile", () => {
5296
const html = renderToStaticMarkup(<PublicPreviewBanner owner="TheDavidDias" />);
@@ -148,6 +192,70 @@ describe("owner page data hydration", () => {
148192
expect(hasNoPublicRepos({ total: 1 })).toBe(false);
149193
});
150194

195+
it("keeps stale plain pending repos in the queued state", () => {
196+
vi.spyOn(Date, "now").mockReturnValue(OWNER_SYNC_NOW);
197+
198+
const result = resolveOwnerSyncPresentation(
199+
makeSyncPresentationData([
200+
makeSyncPresentationRepo({
201+
name: "old-pending",
202+
syncStatus: "pending",
203+
syncLastProgressAt: STALE_OWNER_SYNC_PROGRESS_AT,
204+
}),
205+
])
206+
);
207+
208+
expect(result.syncAlertState).toEqual({
209+
status: "queued",
210+
repoCount: 1,
211+
pendingRepoCount: 1,
212+
nextRepoName: "old-pending",
213+
});
214+
});
215+
216+
it("treats stale queued repos as stalled", () => {
217+
vi.spyOn(Date, "now").mockReturnValue(OWNER_SYNC_NOW);
218+
219+
const result = resolveOwnerSyncPresentation(
220+
makeSyncPresentationData([
221+
makeSyncPresentationRepo({
222+
name: "old-queued",
223+
syncStatus: "queued",
224+
syncLastProgressAt: STALE_OWNER_SYNC_PROGRESS_AT,
225+
}),
226+
])
227+
);
228+
229+
expect(result.syncAlertState).toMatchObject({
230+
status: "stalled",
231+
repoCount: 1,
232+
pendingRepoCount: 1,
233+
stalledRepoName: "old-queued",
234+
});
235+
});
236+
237+
it("treats stale syncing repos as stalled", () => {
238+
vi.spyOn(Date, "now").mockReturnValue(OWNER_SYNC_NOW);
239+
240+
const result = resolveOwnerSyncPresentation(
241+
makeSyncPresentationData([
242+
makeSyncPresentationRepo({
243+
name: "old-syncing",
244+
syncStatus: "syncing",
245+
syncLastProgressAt: STALE_OWNER_SYNC_PROGRESS_AT,
246+
syncStage: "scanning_packages",
247+
}),
248+
])
249+
);
250+
251+
expect(result.syncAlertState).toMatchObject({
252+
status: "stalled",
253+
repoCount: 1,
254+
pendingRepoCount: 0,
255+
stalledRepoName: "old-syncing",
256+
});
257+
});
258+
151259
it("resolves query-string UI state on the client", () => {
152260
expect(resolveOwnerPageUrlState("?view=public")).toEqual({
153261
initialStatus: null,

apps/web/app/[owner]/owner-page-content.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,29 +288,30 @@ export function getRepoSyncLastProgressAt(repo: {
288288
return repo.syncLastProgressAt ?? repo.requestedAt;
289289
}
290290

291-
function resolveOwnerSyncPresentation(data: ResolvedOwnerPageData) {
292-
const pendingRepos = data.repos.filter(
291+
export function resolveOwnerSyncPresentation(data: ResolvedOwnerPageData) {
292+
const queuedRepos = data.repos.filter((repo) => repo.syncStatus === "queued");
293+
const waitingRepos = data.repos.filter(
293294
(repo) => repo.syncStatus === "pending" || repo.syncStatus === "queued"
294295
);
295296
const syncingRepos = data.repos.filter((repo) => repo.syncStatus === "syncing");
296-
const repoCount = pendingRepos.length + syncingRepos.length;
297+
const repoCount = waitingRepos.length + syncingRepos.length;
297298
const activeRepo = syncingRepos[0];
298299
const now = Date.now();
299300
const staleSyncingRepo = syncingRepos.find(
300301
(repo) => now - getRepoSyncLastProgressAt(repo) > SYNC_STUCK_REPO_THRESHOLD_MS
301302
);
302-
const stalePendingRepo =
303+
const staleQueuedRepo =
303304
syncingRepos.length === 0
304-
? pendingRepos.find(
305+
? queuedRepos.find(
305306
(repo) => now - getRepoSyncLastProgressAt(repo) > SYNC_STUCK_REPO_THRESHOLD_MS
306307
)
307308
: undefined;
308-
const stalledRepo = staleSyncingRepo ?? stalePendingRepo;
309+
const stalledRepo = staleSyncingRepo ?? staleQueuedRepo;
309310
const syncAlertState: SyncAlertState = stalledRepo
310311
? {
311312
status: "stalled",
312313
repoCount,
313-
pendingRepoCount: pendingRepos.length,
314+
pendingRepoCount: waitingRepos.length,
314315
stalledRepoName: stalledRepo.name,
315316
stageLabel:
316317
stalledRepo.syncStatus === "syncing"
@@ -321,16 +322,16 @@ function resolveOwnerSyncPresentation(data: ResolvedOwnerPageData) {
321322
? {
322323
status: "active",
323324
repoCount,
324-
pendingRepoCount: pendingRepos.length,
325+
pendingRepoCount: waitingRepos.length,
325326
activeRepoName: activeRepo.name,
326327
stageLabel: getSyncStageLabel(activeRepo.syncStage, activeRepo.syncCommitsFetched),
327328
}
328-
: pendingRepos.length > 0
329+
: waitingRepos.length > 0
329330
? {
330331
status: "queued",
331332
repoCount,
332-
pendingRepoCount: pendingRepos.length,
333-
nextRepoName: pendingRepos[0]?.name,
333+
pendingRepoCount: waitingRepos.length,
334+
nextRepoName: waitingRepos[0]?.name,
334335
}
335336
: {
336337
status: "idle",

apps/web/app/[owner]/sections/__tests__/sync-alerts.test.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { SyncAlerts } from "../sync-alerts";
66

77
function renderSyncAlerts(overrides: Partial<ComponentProps<typeof SyncAlerts>> = {}) {
88
const onRetryIndexing = vi.fn();
9-
render(
9+
const view = render(
1010
<SyncAlerts
1111
owner="octocat"
1212
isOwnerViewer
@@ -19,7 +19,7 @@ function renderSyncAlerts(overrides: Partial<ComponentProps<typeof SyncAlerts>>
1919
{...overrides}
2020
/>
2121
);
22-
return { onRetryIndexing };
22+
return { onRetryIndexing, ...view };
2323
}
2424

2525
describe("SyncAlerts", () => {
@@ -78,6 +78,74 @@ describe("SyncAlerts", () => {
7878
expect(onRetryIndexing).toHaveBeenCalledTimes(1);
7979
});
8080

81+
it("shows failed indexing and lets owners retry", () => {
82+
const failedAlert = getWebAlert("profile.sync.failed");
83+
const { onRetryIndexing } = renderSyncAlerts({
84+
hasOnlySyncErrors: true,
85+
firstSyncError: "GitHub token invalid",
86+
});
87+
88+
expect(screen.getByText(failedAlert.title)).toBeInTheDocument();
89+
expect(screen.getByText(/GitHub token invalid/i)).toBeInTheDocument();
90+
91+
fireEvent.click(screen.getByRole("button", { name: /retry indexing/i }));
92+
93+
expect(onRetryIndexing).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it.each([
97+
[
98+
"active",
99+
{
100+
syncAlertState: {
101+
status: "active" as const,
102+
repoCount: 1,
103+
pendingRepoCount: 0,
104+
activeRepoName: "octo-repo",
105+
stageLabel: "Fetching commits...",
106+
},
107+
},
108+
getWebAlert("profile.sync.active").title,
109+
],
110+
[
111+
"queued",
112+
{
113+
syncAlertState: {
114+
status: "queued" as const,
115+
repoCount: 1,
116+
pendingRepoCount: 1,
117+
nextRepoName: "hello-world",
118+
},
119+
},
120+
getWebAlert("profile.sync.queued").title,
121+
],
122+
[
123+
"stalled",
124+
{
125+
syncAlertState: {
126+
status: "stalled" as const,
127+
repoCount: 1,
128+
pendingRepoCount: 1,
129+
stalledRepoName: "stuck-repo",
130+
},
131+
},
132+
getWebAlert("profile.sync.stalled").title,
133+
],
134+
[
135+
"failed",
136+
{
137+
hasOnlySyncErrors: true,
138+
firstSyncError: "GitHub token invalid",
139+
},
140+
getWebAlert("profile.sync.failed").title,
141+
],
142+
])("does not show %s sync alerts to public visitors", (_label, overrides, title) => {
143+
renderSyncAlerts({ isOwnerViewer: false, ...overrides });
144+
145+
expect(screen.queryByText(title)).not.toBeInTheDocument();
146+
expect(screen.queryByRole("button", { name: /retry indexing/i })).not.toBeInTheDocument();
147+
});
148+
81149
it("shows an owner-only stale public stack alert", () => {
82150
const staleAlert = getWebAlert("profile.sync.stale-public-stack");
83151

apps/web/app/[owner]/sections/sync-alerts.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ export function SyncAlerts({
184184
isRetryingIndex,
185185
onRetryIndexing,
186186
}: SyncAlertsProps) {
187+
if (!isOwnerViewer) return null;
188+
187189
const staleAlert = getWebAlert("profile.sync.stale-public-stack");
188190
const failedAlert = getWebAlert("profile.sync.failed");
189191

apps/web/components/social/__tests__/message-button.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cleanup, render, screen } from "@testing-library/react";
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { MessageButton } from "../message-button";
@@ -41,6 +41,30 @@ afterEach(() => {
4141
});
4242

4343
describe("MessageButton", () => {
44+
it("uses the accent treatment when messaging is available", () => {
45+
render(<MessageButton targetOwner="octocat" viewerStackScore={100} />);
46+
47+
expect(screen.getByRole("button", { name: "Message @octocat" })).toHaveClass(
48+
"border-th-accent-1/30",
49+
"bg-th-accent-1/10",
50+
"text-th-accent-1-text"
51+
);
52+
});
53+
54+
it("uses the locked treatment while starting a conversation", async () => {
55+
mocks.startConversation.mockReturnValue(new Promise(() => {}));
56+
const user = userEvent.setup();
57+
58+
render(<MessageButton targetOwner="octocat" viewerStackScore={100} />);
59+
const button = screen.getByRole("button", { name: "Message @octocat" });
60+
61+
await user.click(button);
62+
63+
await waitFor(() => {
64+
expect(button).toHaveClass("border-border", "bg-muted/60", "opacity-50", "opacity-65");
65+
});
66+
});
67+
4468
it("explains when neither person has starred the other this week", async () => {
4569
mocks.canMessageResult = {
4670
canMessage: false,
@@ -51,7 +75,11 @@ describe("MessageButton", () => {
5175
const user = userEvent.setup();
5276

5377
render(<MessageButton targetOwner="octocat" viewerStackScore={100} />);
54-
await user.click(screen.getByRole("button", { name: "Star each other to message" }));
78+
const button = screen.getByRole("button", { name: "Star each other to message" });
79+
80+
expect(button).toHaveClass("border-border", "bg-muted/60", "text-muted-foreground");
81+
82+
await user.click(button);
5583

5684
expect(mocks.toastInfo).toHaveBeenCalledWith(
5785
"Star @octocat first. You can message once they star you back this week."
@@ -95,7 +123,11 @@ describe("MessageButton", () => {
95123
const user = userEvent.setup();
96124

97125
render(<MessageButton targetOwner="octocat" viewerStackScore={100} />);
98-
await user.click(screen.getByRole("button", { name: "Checking message availability" }));
126+
const button = screen.getByRole("button", { name: "Checking message availability" });
127+
128+
expect(button).toHaveClass("border-border", "bg-muted/60", "opacity-50", "opacity-65");
129+
130+
await user.click(button);
99131

100132
expect(mocks.toastInfo).toHaveBeenCalledWith("Checking whether messaging is available...");
101133
});

apps/web/components/social/message-button.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,13 @@ export function MessageButton({
116116
const featureLocked = canMessage?.canMessage === false && canMessage.reason === "feature_locked";
117117
const isUnavailable = canMessage?.canMessage === false;
118118
const isDisabledVisually =
119-
isCheckingMessage || isLocked || Boolean(noMatch) || blocked || featureLocked || isUnavailable;
119+
isLoading ||
120+
isCheckingMessage ||
121+
isLocked ||
122+
Boolean(noMatch) ||
123+
blocked ||
124+
featureLocked ||
125+
isUnavailable;
120126
const unavailableMessage = getUnavailableMessage({
121127
blocked,
122128
featureLocked,
@@ -175,7 +181,7 @@ export function MessageButton({
175181
aria-disabled={isLoading || isCheckingMessage}
176182
title={noMatch ? "Star each other to message" : undefined}
177183
className={profileActionButtonClassName({
178-
intent: isDisabledVisually ? "locked" : "neutral",
184+
intent: isDisabledVisually ? "locked" : "accent",
179185
size: "icon",
180186
className: cn(
181187
(isLoading || isCheckingMessage) && "opacity-50",

0 commit comments

Comments
 (0)