Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
edf766f
docs: design safe PWA version updates
absolutepraya Aug 16, 2026
88212ff
docs: tighten PWA update design contract
absolutepraya Aug 16, 2026
90c30b8
docs: add PWA version update implementation plan
absolutepraya Aug 16, 2026
c8c0f4f
docs: tighten PWA update implementation plan
absolutepraya Aug 16, 2026
e03a4be
test: define PWA update lifecycle behavior
absolutepraya Aug 16, 2026
8cdb1ab
test: define safe PWA update activation
absolutepraya Aug 16, 2026
72f3815
ci: add temporary PWA TDD check
absolutepraya Aug 16, 2026
9398459
feat: discover and stage PWA updates
absolutepraya Aug 16, 2026
9db4f5c
feat: activate PWA updates only when safe
absolutepraya Aug 16, 2026
1a19dde
test: define PWA lifecycle state and foreground checks
absolutepraya Aug 16, 2026
42354ab
feat: expose PWA lifecycle state
absolutepraya Aug 16, 2026
d2007e2
feat: provide PWA lifecycle to web UI
absolutepraya Aug 16, 2026
bc47235
fix: keep React in PWA provider scope
absolutepraya Aug 16, 2026
ddb98df
test: use native text assertions in PWA lifecycle test
absolutepraya Aug 16, 2026
f9c8363
test: define controlled PWA handoff reload
absolutepraya Aug 16, 2026
031ea55
feat: reload after safe PWA update handoff
absolutepraya Aug 16, 2026
d163f9f
ci: extend temporary PWA UI TDD check
absolutepraya Aug 16, 2026
521ed36
test: define shared PWA build presentation
absolutepraya Aug 16, 2026
711836d
test: define Marka profile future features
absolutepraya Aug 16, 2026
c41018c
test: keep React in sidebar version scope
absolutepraya Aug 16, 2026
ee043b6
test: keep React in profile menu scope
absolutepraya Aug 16, 2026
74dfe05
test: isolate profile menu primitives
absolutepraya Aug 16, 2026
922b1bd
test: isolate sidebar version links
absolutepraya Aug 16, 2026
89ddd4b
feat: show running PWA build state
absolutepraya Aug 16, 2026
703dca2
feat: replace upstream profile actions with Marka features
absolutepraya Aug 16, 2026
6c05d3e
feat: use browser PWA build in sidebar
absolutepraya Aug 16, 2026
d3679b1
feat: add profile menu translations
absolutepraya Aug 16, 2026
db14a58
feat: type profile menu translations
absolutepraya Aug 16, 2026
f3bd87e
feat: use typed profile menu translations
absolutepraya Aug 16, 2026
2743b41
feat: use typed Marka profile translations
absolutepraya Aug 16, 2026
9f59b23
test: align shared build copy namespace
absolutepraya Aug 16, 2026
d57e487
test: align Marka profile translation namespace
absolutepraya Aug 16, 2026
f150dd3
test: clean up profile menu renders
absolutepraya Aug 16, 2026
008e921
fix: satisfy PWA lifecycle lint rules
absolutepraya Aug 16, 2026
9f5a855
test: satisfy PWA lifecycle lint rules
absolutepraya Aug 16, 2026
2370ae1
ci: diagnose PWA React Doctor findings
absolutepraya Aug 16, 2026
09b0c72
test: preserve an already waiting PWA update
absolutepraya Aug 16, 2026
905e338
test: clean up installing worker listeners
absolutepraya Aug 16, 2026
c53fc08
fix: preserve waiting PWA updates and clean listeners
absolutepraya Aug 16, 2026
a8ac939
docs: align offline PWA update lifecycle
absolutepraya Aug 16, 2026
374e1d2
fix: avoid nested service worker listener subscriptions
absolutepraya Aug 16, 2026
b64863f
test: cover installing worker handler cleanup
absolutepraya Aug 16, 2026
21c140d
ci: run full web and format diagnostics
absolutepraya Aug 16, 2026
35a07e4
test: track readiness for the deployed worker
absolutepraya Aug 16, 2026
54098db
fix: match PWA readiness to the deployed worker
absolutepraya Aug 16, 2026
dea2963
test: format profile menu regressions
absolutepraya Aug 16, 2026
9988a45
test: identify installing worker build in cleanup case
absolutepraya Aug 16, 2026
d9b0077
test: clean up PWA lifecycle renders
absolutepraya Aug 16, 2026
2c1600b
test: reject stale waiting PWA handoffs
absolutepraya Aug 16, 2026
ab5f62f
fix: reject stale waiting PWA handoffs
absolutepraya Aug 16, 2026
b0c6ad2
test: model sequential deployed PWA updates
absolutepraya Aug 16, 2026
5f3fc7c
test: require bounded PWA version checks
absolutepraya Aug 16, 2026
4a95977
fix: localize PWA build link title
absolutepraya Aug 16, 2026
8faa482
fix: localize PWA build link title
absolutepraya Aug 16, 2026
9944d8d
fix: keep default translations narrowly typed
absolutepraya Aug 16, 2026
ffdc665
chore: remove temporary PWA diagnostics workflow
absolutepraya Aug 16, 2026
699af9e
fix: bound PWA version checks
absolutepraya Aug 16, 2026
510b9de
test: make PWA timeout callback assertion safe
absolutepraya Aug 16, 2026
fea0088
style: format PWA lifecycle provider
absolutepraya Aug 16, 2026
7d50c5f
test: account for bounded PWA version fetches
absolutepraya Aug 16, 2026
b2700ca
docs: align PWA update plan with implementation
absolutepraya Aug 16, 2026
689e604
chore: add temporary PWA verification workflow
absolutepraya Aug 16, 2026
407db42
chore: remove temporary PWA verification workflow
absolutepraya Aug 16, 2026
a44bda3
fix: use top-level React type import
absolutepraya Aug 16, 2026
66d77c1
fix: preserve default translation namespace typing
absolutepraya Aug 16, 2026
7a6c8bc
style: format translation overload implementation
absolutepraya Aug 16, 2026
0bb674b
fix(i18n): preserve default server namespace
absolutepraya Aug 16, 2026
df8dde1
test(pwa): reproduce installed-worker readiness race
absolutepraya Aug 16, 2026
3d19221
ci: add temporary PWA readiness regression check
absolutepraya Aug 16, 2026
bac137b
fix(pwa): close installed-worker readiness race
absolutepraya Aug 16, 2026
dcc3ef2
refactor(i18n): use native client translation typing
absolutepraya Aug 16, 2026
4c8d56a
test(web): cover non-SHA build display
absolutepraya Aug 16, 2026
98d8e24
docs: align PWA plan with final fixes
absolutepraya Aug 16, 2026
0f993d3
ci: broaden temporary PWA web verification
absolutepraya Aug 16, 2026
e8fec23
fix(i18n): preserve server namespace through getFixedT
absolutepraya Aug 16, 2026
fedad54
fix(i18n): keep server translations on default namespace
absolutepraya Aug 16, 2026
36a7769
refactor(i18n): reuse configured default namespace
absolutepraya Aug 16, 2026
dec1011
docs: describe final PWA i18n contract
absolutepraya Aug 16, 2026
60425ed
chore: remove temporary PWA verification workflow
absolutepraya Aug 16, 2026
3773b29
Merge main into feat/pwa-version-updates
absolutepraya Aug 16, 2026
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
2 changes: 2 additions & 0 deletions apps/web/@types/i18next.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import "i18next";

import profileMenu from "../lib/i18n/locales/en/profile_menu.json";
import translation from "../lib/i18n/locales/en/translation.json";

declare module "i18next" {
// Extend CustomTypeOptions
interface CustomTypeOptions {
defaultNS: "translation";
resources: {
profile_menu: typeof profileMenu;
translation: typeof translation;
};
}
Expand Down
152 changes: 152 additions & 0 deletions apps/web/components/dashboard/header/ProfileOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// @vitest-environment jsdom

import React from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import ProfileOptions from "./ProfileOptions";

const mocks = vi.hoisted(() => ({
push: vi.fn(),
toggleTheme: vi.fn(),
}));

vi.mock("next/navigation", () => ({
redirect: vi.fn(),
useRouter: () => ({ push: mocks.push }),
}));

vi.mock("@/components/theme-provider", () => ({
useToggleTheme: () => mocks.toggleTheme,
}));

vi.mock("@teispace/next-themes", () => ({
useTheme: () => ({ theme: "light" }),
}));

vi.mock("@/lib/auth/client", () => ({
useSession: () => ({
data: {
user: {
email: "daffa@example.com",
name: "Daffa",
role: "user",
},
},
}),
}));

vi.mock("@karakeep/shared-react/hooks/users", () => ({
useWhoAmI: () => ({ data: { image: null } }),
}));

vi.mock("@/lib/i18n/client", () => ({
useTranslation: (namespace?: string) => ({
t: (key: string) => {
if (namespace === "profile_menu") {
const profileTranslations: Record<string, string> = {
apps_extensions: "Apps & extensions",
coming_soon: "Coming soon",
documentation: "Documentation",
};
return profileTranslations[key] ?? key;
}

const translations: Record<string, string> = {
"actions.sign_out": "Sign out",
"cleanups.cleanups": "Cleanups",
"options.dark_mode": "Dark mode",
"options.light_mode": "Light mode",
"settings.user_settings": "Settings",
};
return translations[key] ?? key;
},
}),
}));

vi.mock("../../admin/AdminNotices", () => ({
AdminNoticeBadge: () => null,
}));

vi.mock("@/components/shared/sidebar/SidebarVersion", () => ({
default: ({ placement }: { placement?: string }) => (
<div data-placement={placement} data-testid="sidebar-version" />
),
}));

vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));

vi.mock("@/components/ui/separator", () => ({
Separator: ({ className }: { className?: string }) => (
<div className={className} role="separator" />
),
}));

vi.mock("@/components/ui/user-avatar", () => ({
UserAvatar: ({ name }: { name?: string | null }) => (
<div data-testid="user-avatar">{name}</div>
),
}));

vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({
children,
disabled,
}: {
children: React.ReactNode;
disabled?: boolean;
}) => <div data-disabled={disabled ? "true" : "false"}>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));

describe("ProfileOptions", () => {
beforeEach(() => {
mocks.push.mockReset();
mocks.toggleTheme.mockReset();
});

afterEach(() => {
cleanup();
});

it("replaces upstream actions with disabled Marka coming-soon features", () => {
const { container } = render(<ProfileOptions />);

expect(container.textContent).toContain("Apps & extensions");
expect(container.textContent).toContain("Documentation");
expect(container.textContent?.match(/Coming soon/g)?.length).toBe(2);

const hrefs = [...container.querySelectorAll("a")].map((link) =>
link.getAttribute("href"),
);
expect(hrefs).not.toContain("https://karakeep.app/apps");
expect(hrefs).not.toContain("https://docs.karakeep.app");
expect(hrefs).not.toContain("https://x.com/karakeep_app");
expect(container.textContent).not.toContain(
"Follow upstream Karakeep on X",
);

const disabledItems = container.querySelectorAll('[data-disabled="true"]');
expect(disabledItems).toHaveLength(2);
});

it("shows the shared build footer only in the mobile profile layout", () => {
render(<ProfileOptions />);

const version = screen.getByTestId("sidebar-version");
expect(version.getAttribute("data-placement")).toBe("profile");
expect(version.parentElement?.className).toContain("sm:hidden");
});
});
59 changes: 39 additions & 20 deletions apps/web/components/dashboard/header/ProfileOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { useMemo } from "react";
import React, { useMemo } from "react";
import Link from "next/link";
import { redirect, useRouter } from "next/navigation";
import SidebarVersion from "@/components/shared/sidebar/SidebarVersion";
import { useToggleTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -24,7 +25,6 @@ import {
Settings,
Shield,
Sun,
Twitter,
} from "lucide-react";
import { useTheme } from "@teispace/next-themes";

Expand Down Expand Up @@ -53,8 +53,31 @@ function DarkModeToggle() {
}
}

function ComingSoonFeature({
icon,
label,
comingSoon,
}: {
icon: React.ReactNode;
label: string;
comingSoon: string;
}) {
return (
<DropdownMenuItem disabled className="items-start">
<span className="mr-2 mt-0.5 shrink-0">{icon}</span>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate">{label}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{comingSoon}
</span>
</div>
</DropdownMenuItem>
);
}

export default function SidebarProfileOptions() {
const { t } = useTranslation();
const { t: tProfile } = useTranslation("profile_menu");
const toggleTheme = useToggleTheme();
const { data: session } = useSession();
const { data: whoami } = useWhoAmI();
Expand Down Expand Up @@ -126,25 +149,21 @@ export default function SidebarProfileOptions() {
<DarkModeToggle />
</DropdownMenuItem>
<Separator className="my-2" />
<DropdownMenuItem asChild>
<a href="https://karakeep.app/apps" target="_blank" rel="noreferrer">
<Puzzle className="mr-2 size-4" />
Upstream Karakeep apps and extensions
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="https://docs.karakeep.app" target="_blank" rel="noreferrer">
<BookOpen className="mr-2 size-4" />
Upstream Karakeep documentation
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="https://x.com/karakeep_app" target="_blank" rel="noreferrer">
<Twitter className="mr-2 size-4" />
Follow upstream Karakeep on X
</a>
</DropdownMenuItem>
<ComingSoonFeature
icon={<Puzzle className="size-4" />}
label={tProfile("apps_extensions")}
comingSoon={tProfile("coming_soon")}
/>
<ComingSoonFeature
icon={<BookOpen className="size-4" />}
label={tProfile("documentation")}
comingSoon={tProfile("coming_soon")}
/>
<Separator className="my-2" />
<div className="sm:hidden">
<SidebarVersion placement="profile" />
</div>
<Separator className="my-2 sm:hidden" />
<DropdownMenuItem onClick={() => router.push("/logout")}>
<LogOut className="mr-2 size-4" />
<span>{t("actions.sign_out")}</span>
Expand Down
111 changes: 111 additions & 0 deletions apps/web/components/pwa/ServiceWorkerRegistration.readiness.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment jsdom

import React from "react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import ServiceWorkerRegistration, {
usePwaLifecycle,
} from "./ServiceWorkerRegistration";

const mocks = vi.hoisted(() => ({
register: vi.fn(),
getRegistration: vi.fn(),
fetch: vi.fn(),
messagePort: { postMessage: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));

vi.mock("@/lib/auth/client", () => ({
useSession: () => ({
data: { user: { id: "user-1" } },
status: "authenticated",
}),
}));

function Probe() {
const { updateStatus } = usePwaLifecycle();
return <span data-testid="update-status">{updateStatus}</span>;
}

function installServiceWorkerMock() {
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: {
addEventListener: mocks.addEventListener,
controller: mocks.messagePort,
getRegistration: mocks.getRegistration,
register: mocks.register,
removeEventListener: mocks.removeEventListener,
},
});
}

describe("ServiceWorkerRegistration readiness", () => {
beforeEach(() => {
installServiceWorkerMock();
vi.stubGlobal("fetch", mocks.fetch);
mocks.getRegistration.mockResolvedValue(undefined);
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ version: "bbbbbbb" }), {
headers: { "content-type": "application/json" },
status: 200,
}),
);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
});

afterEach(() => {
cleanup();
mocks.register.mockReset();
mocks.getRegistration.mockReset();
mocks.fetch.mockReset();
mocks.messagePort.postMessage.mockReset();
mocks.addEventListener.mockReset();
mocks.removeEventListener.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("marks an update ready when installation finished before the state listener was attached", async () => {
const installedWorker = {
state: "installed",
onstatechange: null as (() => void) | null,
scriptURL: "https://karakeep.test/sw.js?v=bbbbbbb",
};
let waitingReads = 0;
const updateRegistration = {
active: mocks.messagePort,
installing: installedWorker,
get waiting() {
waitingReads += 1;
return waitingReads === 1 ? null : installedWorker;
},
};

mocks.register
.mockResolvedValueOnce({ active: mocks.messagePort })
.mockResolvedValueOnce(updateRegistration);

render(
<ServiceWorkerRegistration>
<Probe />
</ServiceWorkerRegistration>,
);

await waitFor(() => {
expect(mocks.register).toHaveBeenCalledWith("/sw.js?v=bbbbbbb", {
scope: "/",
updateViaCache: "none",
});
});

await waitFor(() => {
expect(screen.getByTestId("update-status").textContent).toBe("ready");
});
});
});
Loading
Loading