From 85cf94a08ced6d66f7142daf8517be4fdfe21e7d Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:09:03 +0530 Subject: [PATCH 1/5] feat: Star on github popup for new users --- .../dashboards/workspace/Dashboard.svelte | 16 +- .../github-star/GithubStarButton.spec.ts | 204 ++++++++++++++++++ .../github-star/GithubStarButton.svelte | 171 +++++++++++++++ .../github-star/github-star.svelte.spec.ts | 154 +++++++++++++ .../github-star/github-star.svelte.ts | 129 +++++++++++ .../src/layout/navigation/Footer.svelte | 2 + web-common/src/lib/i18n/messages/en.json | 5 + web-common/src/lib/i18n/messages/es.json | 5 + .../src/metrics/BehaviourEventHandler.ts | 7 + .../metrics/service/BehaviourEventFactory.ts | 18 ++ .../metrics/service/BehaviourEventTypes.ts | 6 + 11 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 web-common/src/features/github-star/GithubStarButton.spec.ts create mode 100644 web-common/src/features/github-star/GithubStarButton.svelte create mode 100644 web-common/src/features/github-star/github-star.svelte.spec.ts create mode 100644 web-common/src/features/github-star/github-star.svelte.ts diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index cbac3e2f4682..5a8630bda66f 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -12,8 +12,9 @@ import { dynamicHeight } from "@rilldata/web-common/layout/layout-settings.ts"; import { navigationOpen } from "@rilldata/web-common/layout/navigation/Navigation.svelte"; import Resizer from "@rilldata/web-common/layout/Resizer.svelte"; - import { onDestroy } from "svelte"; - import { readable, type Readable } from "svelte/store"; + import { githubStarNudge } from "@rilldata/web-common/features/github-star/github-star.svelte"; + import { onDestroy, onMount } from "svelte"; + import { get, readable, type Readable } from "svelte/store"; import { useExploreState } from "web-common/src/features/dashboards/stores/dashboard-stores"; import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb"; import { useRuntimeClient } from "../../../runtime-client/v2"; @@ -57,10 +58,19 @@ dashboardStore, } = StateManagers; - const { cloudDataViewer, readOnly } = featureFlags; + const { adminServer, cloudDataViewer, readOnly } = featureFlags; const timeControlsStore = useTimeControlStore(StateManagers); + onMount(() => { + // A rendered dashboard is the payoff moment that arms the GitHub star nudge. + // The nudge is Rill Developer only, so Cloud and embeds must not even arm it: + // web-admin sets adminServer synchronously during root layout init, and embeds + // reset it to false, hence both checks. `adminServer` is read once here rather + // than tracked, since a dashboard never moves between Cloud and Developer. + if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff(); + }); + let exploreContainerWidth: number; let exploreContainerHeight: number; let resizing = false; diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts new file mode 100644 index 000000000000..63cfc404c907 --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -0,0 +1,204 @@ +import { render, screen } from "@testing-library/svelte"; +import { readable } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The metadata query hits the local-only runtime service; stub it so the +// component can read `analyticsEnabled` without a server. +const analyticsEnabled = vi.hoisted(() => ({ value: true })); +vi.mock("@rilldata/web-common/runtime-client/local-service", () => ({ + createLocalServiceGetMetadata: () => + readable({ data: { analyticsEnabled: analyticsEnabled.value } }), +})); + +const fireGithubStarEvent = vi.hoisted(() => vi.fn()); +vi.mock("@rilldata/web-common/metrics/initMetrics", () => ({ + behaviourEvent: { fireGithubStarEvent }, +})); + +import { featureFlags } from "@rilldata/web-common/features/feature-flags"; +import GithubStarButton from "./GithubStarButton.svelte"; +import { GITHUB_STAR_URL, GithubStarNudge } from "./github-star.svelte"; + +/** Lets the auto-open timer and the resulting Svelte update settle. */ +function settle() { + return vi.advanceTimersByTimeAsync(2000); +} + +function renderArmed() { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + render(GithubStarButton, { props: { nudge } }); + return nudge; +} + +const outsideElements: HTMLElement[] = []; + +/** Stands in for whatever the user was actually working on, e.g. the editor. */ +function renderOutsideElement() { + const element = document.createElement("button"); + document.body.append(element); + outsideElements.push(element); + return element; +} + +describe("GithubStarButton", () => { + beforeEach(() => { + localStorage.clear(); + analyticsEnabled.value = true; + featureFlags.adminServer.resetToDefault(); + vi.useFakeTimers(); + }); + + afterEach(() => { + featureFlags.adminServer.resetToDefault(); + outsideElements.splice(0).forEach((element) => element.remove()); + vi.useRealTimers(); + }); + + it("renders nothing at all on Rill Cloud", async () => { + featureFlags.adminServer.set(true); + const nudge = renderArmed(); + await settle(); + + // Neither the button nor the nudge: this is a Rill Developer feature. + expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).not.toHaveBeenCalled(); + }); + + it("always renders the footer link, pointing straight at the repo", () => { + render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + + const link = screen.getByText("Star us on GitHub").closest("a"); + expect(link).toHaveAttribute("href", GITHUB_STAR_URL); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("does not open unprompted when no payoff has happened", async () => { + render(GithubStarButton, { props: { nudge: new GithubStarNudge() } }); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); + + it("opens unprompted once a payoff has armed it", async () => { + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-shown"); + }); + + it("does not take the keyboard when the nudge opens", async () => { + const editor = renderOutsideElement(); + editor.focus(); + + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("lets focus leave the open nudge instead of trapping it", async () => { + const editor = renderOutsideElement(); + renderArmed(); + await settle(); + + editor.focus(); + await settle(); + + // The nudge stays open; it just does not hold the keyboard hostage. + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("leaves focus where it is when the nudge closes", async () => { + renderArmed(); + await settle(); + + const editor = renderOutsideElement(); + editor.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("keeps the button but withholds the nudge when telemetry is disabled", async () => { + analyticsEnabled.value = false; + const nudge = renderArmed(); + await settle(); + + expect(screen.getByText("Star us on GitHub")).toBeInTheDocument(); + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.dismissCount).toBe(0); + }); + + it("counts dismissing the nudge with Escape as a soft dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + // Still armed, but muted: the ask is deferred rather than abandoned. + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.dismissCount).toBe(1); + expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-dismissed"); + }); + + it("retires the nudge when the footer link is clicked, without opening the popover", async () => { + // Armed, and clicked inside the grace period: the pending auto-open must be + // cancelled rather than firing at someone already on their way to the repo. + const nudge = renderArmed(); + screen.getByText("Star us on GitHub").closest("a")!.click(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); + expect(fireGithubStarEvent).not.toHaveBeenCalledWith( + "github-star-dismissed", + ); + }); + + it("records an opt-out as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Don't show again").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-opted-out"); + }); + + it("records the star click as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Star on GitHub").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.dismissCount).toBe(0); + expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); + }); + + it("does not re-open unprompted after being dismissed", async () => { + renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); +}); diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte new file mode 100644 index 000000000000..2c84263e33fe --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -0,0 +1,171 @@ + + +{#if !$adminServer} + + +
+ +
+ +
+ {m.github_star_footer_label()} +
+
+ + + + e.preventDefault()} + onCloseAutoFocus={(e: Event) => e.preventDefault()} + > +
+
+

+ {m.github_star_title()} +

+

+ {m.github_star_message()} +

+
+
+ + +
+
+
+
+{/if} diff --git a/web-common/src/features/github-star/github-star.svelte.spec.ts b/web-common/src/features/github-star/github-star.svelte.spec.ts new file mode 100644 index 000000000000..167917fdc4c9 --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.spec.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GithubStarNudge } from "./github-star.svelte"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const START = new Date("2026-08-04T12:00:00Z").getTime(); + +/** Sets the clock to `days` after the start of the test. */ +function atDay(days: number) { + vi.setSystemTime(START + days * DAY_MS); +} + +describe("GithubStarNudge", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(START); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stays hidden until a payoff arms it", () => { + const nudge = new GithubStarNudge(); + expect(nudge.visible).toBe(false); + + nudge.armPayoff(); + expect(nudge.visible).toBe(true); + }); + + it("persists the armed state across reloads", () => { + new GithubStarNudge().armPayoff(); + expect(new GithubStarNudge().visible).toBe(true); + }); + + it("treats a star click as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordStar(); + expect(nudge.visible).toBe(false); + + // Neither a later payoff nor the passage of time may resurrect it. + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + atDay(365); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("treats an explicit opt-out as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordOptOut(); + + atDay(365); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("escalates soft dismissals to 30 days, then 90 days, then permanently", () => { + const first = new GithubStarNudge(); + first.armPayoff(); + + first.recordSoftDismiss(); + expect(first.visible).toBe(false); + atDay(29); + expect(new GithubStarNudge().visible).toBe(false); + atDay(31); + expect(new GithubStarNudge().visible).toBe(true); + + const second = new GithubStarNudge(); + second.recordSoftDismiss(); + atDay(31 + 89); + expect(new GithubStarNudge().visible).toBe(false); + atDay(31 + 91); + expect(new GithubStarNudge().visible).toBe(true); + + const third = new GithubStarNudge(); + third.recordSoftDismiss(); + expect(third.state.status).toBe("done"); + atDay(3650); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("caps an install at three asks", () => { + new GithubStarNudge().armPayoff(); + + let asks = 0; + for (let day = 0; day < 400; day++) { + atDay(day); + const nudge = new GithubStarNudge(); + if (nudge.visible) { + asks++; + nudge.recordSoftDismiss(); + } + } + + expect(asks).toBe(3); + }); + + it("ignores a soft dismissal while muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + expect(nudge.state.dismissCount).toBe(1); + + // A dismissal that could not have been seen must not advance the backoff. + nudge.recordSoftDismiss(); + expect(nudge.state.dismissCount).toBe(1); + }); + + it("ignores a soft dismissal that was never armed", () => { + const nudge = new GithubStarNudge(); + nudge.recordSoftDismiss(); + + expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.status).toBe("unarmed"); + }); + + it("does not re-arm on a second payoff once muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + }); + + it("treats corrupt stored state as unarmed", () => { + localStorage.setItem("rill:github-star", "{not json"); + expect(new GithubStarNudge().visible).toBe(false); + + localStorage.setItem("rill:github-star", JSON.stringify({ nonsense: 1 })); + expect(new GithubStarNudge().visible).toBe(false); + }); + + it("degrades to in-memory when localStorage throws", () => { + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + const getItem = vi + .spyOn(Storage.prototype, "getItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + + const nudge = new GithubStarNudge(); + expect(() => nudge.armPayoff()).not.toThrow(); + expect(nudge.visible).toBe(true); + + setItem.mockRestore(); + getItem.mockRestore(); + }); +}); diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts new file mode 100644 index 000000000000..90ebd91f4cbf --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -0,0 +1,129 @@ +export const GITHUB_STAR_URL = "https://github.com/rilldata/rill"; + +const STORAGE_KEY = "rill:github-star"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Mute applied after the Nth soft dismiss. + * Running off the end of the schedule is terminal: three ignores is a no. + */ +const MUTE_SCHEDULE_DAYS = [30, 90]; + +export type GithubStarStatus = "unarmed" | "armed" | "done"; + +export interface GithubStarState { + status: GithubStarStatus; + dismissCount: number; + /** While in the future, an armed nudge stays hidden. */ + mutedUntil?: number; +} + +const INITIAL_STATE: GithubStarState = { status: "unarmed", dismissCount: 0 }; + +/** + * Tracks whether to nudge the user to star Rill on GitHub. + * + * The nudge is armed by a payoff moment (a dashboard rendering) rather than by + * install or upgrade: a fresh install has no accumulated value to be happy about, + * and Rill ships patches every few days, so an upgrade trigger would nag the most + * engaged users hardest. + * + * Dismissal escalates rather than muting for a fixed period. An "X" click is + * ambiguous between "not now" and "no", and the client cannot tell which, so a + * flat mute would re-ask forever. Escalating to permanent caps an install at + * three asks. + */ +export class GithubStarNudge { + #state = $state(INITIAL_STATE); + + constructor() { + this.#state = readState(); + } + + get state() { + return this.#state; + } + + /** + * Whether the nudge should be shown right now. + * Recomputed whenever the state changes; mutes last 30+ days, so re-reading on + * each app load is ample granularity for expiry. + */ + get visible() { + return isVisible(this.#state); + } + + /** Called when the user reaches a payoff moment: a dashboard rendered. */ + armPayoff() { + if (this.#state.status !== "unarmed") return; + this.#commit({ ...this.#state, status: "armed" }); + } + + /** The user starred. Terminal. */ + recordStar() { + this.#commit({ ...this.#state, status: "done" }); + } + + /** The user clicked "Don't show again". Terminal. */ + recordOptOut() { + this.#commit({ ...this.#state, status: "done" }); + } + + /** + * Any other exit: X, click-outside, timeout, navigating away. These are + * indistinguishable from the client, and not counting silent ignores would let + * engaged users be re-asked every 30 days with no backoff. + */ + recordSoftDismiss() { + if (!isVisible(this.#state)) return; + + const dismissCount = this.#state.dismissCount + 1; + const muteDays = MUTE_SCHEDULE_DAYS[dismissCount - 1]; + if (muteDays === undefined) { + this.#commit({ ...this.#state, status: "done", dismissCount }); + return; + } + this.#commit({ + ...this.#state, + dismissCount, + mutedUntil: Date.now() + muteDays * DAY_MS, + }); + } + + #commit(next: GithubStarState) { + this.#state = next; + writeState(next); + } +} + +export function isVisible(state: GithubStarState, now = Date.now()) { + if (state.status !== "armed") return false; + return !state.mutedUntil || state.mutedUntil <= now; +} + +function readState(): GithubStarState { + try { + // Accessing localStorage can throw rather than return null: a sandboxed or + // storage-partitioned iframe raises a SecurityError, and it is absent on the server. + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return INITIAL_STATE; + const parsed = JSON.parse(raw) as GithubStarState; + if (!parsed || typeof parsed.status !== "string") return INITIAL_STATE; + return { ...INITIAL_STATE, ...parsed }; + } catch { + // Corrupt or unreadable state degrades to "unarmed" rather than throwing. + // The worst case is one extra ask, which the backoff then bounds. + return INITIAL_STATE; + } +} + +function writeState(state: GithubStarState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // no-op: nudge degrades to in-memory only + } +} + +export const githubStarNudge = new GithubStarNudge(); diff --git a/web-common/src/layout/navigation/Footer.svelte b/web-common/src/layout/navigation/Footer.svelte index 050d80f86eb7..68e942f3e668 100644 --- a/web-common/src/layout/navigation/Footer.svelte +++ b/web-common/src/layout/navigation/Footer.svelte @@ -10,6 +10,7 @@ import { fly } from "svelte/transition"; import { createLocalServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import RuntimeTrafficLights from "@rilldata/web-common/features/entity-management/RuntimeTrafficLights.svelte"; + import GithubStarButton from "@rilldata/web-common/features/github-star/GithubStarButton.svelte"; const metadataQuery = createLocalServiceGetMetadata(); @@ -51,6 +52,7 @@ {/each} +
Date: Wed, 5 Aug 2026 10:53:23 +0530 Subject: [PATCH 2/5] self review --- .../dashboards/workspace/Dashboard.svelte | 7 +-- .../github-star/GithubStarButton.spec.ts | 34 ----------- .../github-star/GithubStarButton.svelte | 26 +-------- .../github-star/github-star.svelte.ts | 56 ++++++++----------- .../src/metrics/BehaviourEventHandler.ts | 7 --- .../metrics/service/BehaviourEventFactory.ts | 18 ------ .../metrics/service/BehaviourEventTypes.ts | 6 -- 7 files changed, 27 insertions(+), 127 deletions(-) diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 5a8630bda66f..2e78e25f02a6 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -63,11 +63,8 @@ const timeControlsStore = useTimeControlStore(StateManagers); onMount(() => { - // A rendered dashboard is the payoff moment that arms the GitHub star nudge. - // The nudge is Rill Developer only, so Cloud and embeds must not even arm it: - // web-admin sets adminServer synchronously during root layout init, and embeds - // reset it to false, hence both checks. `adminServer` is read once here rather - // than tracked, since a dashboard never moves between Cloud and Developer. + // Github star nudge is Rill developer only. + // Nudge on dashboard render. if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff(); }); diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts index 63cfc404c907..f0ece4c85b3b 100644 --- a/web-common/src/features/github-star/GithubStarButton.spec.ts +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -1,20 +1,6 @@ import { render, screen } from "@testing-library/svelte"; -import { readable } from "svelte/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// The metadata query hits the local-only runtime service; stub it so the -// component can read `analyticsEnabled` without a server. -const analyticsEnabled = vi.hoisted(() => ({ value: true })); -vi.mock("@rilldata/web-common/runtime-client/local-service", () => ({ - createLocalServiceGetMetadata: () => - readable({ data: { analyticsEnabled: analyticsEnabled.value } }), -})); - -const fireGithubStarEvent = vi.hoisted(() => vi.fn()); -vi.mock("@rilldata/web-common/metrics/initMetrics", () => ({ - behaviourEvent: { fireGithubStarEvent }, -})); - import { featureFlags } from "@rilldata/web-common/features/feature-flags"; import GithubStarButton from "./GithubStarButton.svelte"; import { GITHUB_STAR_URL, GithubStarNudge } from "./github-star.svelte"; @@ -44,7 +30,6 @@ function renderOutsideElement() { describe("GithubStarButton", () => { beforeEach(() => { localStorage.clear(); - analyticsEnabled.value = true; featureFlags.adminServer.resetToDefault(); vi.useFakeTimers(); }); @@ -64,7 +49,6 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).not.toHaveBeenCalled(); }); it("always renders the footer link, pointing straight at the repo", () => { @@ -87,7 +71,6 @@ describe("GithubStarButton", () => { await settle(); expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-shown"); }); it("does not take the keyboard when the nudge opens", async () => { @@ -127,16 +110,6 @@ describe("GithubStarButton", () => { expect(document.activeElement).toBe(editor); }); - it("keeps the button but withholds the nudge when telemetry is disabled", async () => { - analyticsEnabled.value = false; - const nudge = renderArmed(); - await settle(); - - expect(screen.getByText("Star us on GitHub")).toBeInTheDocument(); - expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); - expect(nudge.state.dismissCount).toBe(0); - }); - it("counts dismissing the nudge with Escape as a soft dismissal", async () => { const nudge = renderArmed(); await settle(); @@ -148,7 +121,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("armed"); expect(nudge.state.dismissCount).toBe(1); expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-dismissed"); }); it("retires the nudge when the footer link is clicked, without opening the popover", async () => { @@ -161,10 +133,6 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); - expect(fireGithubStarEvent).not.toHaveBeenCalledWith( - "github-star-dismissed", - ); }); it("records an opt-out as terminal without spending a dismissal", async () => { @@ -176,7 +144,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-opted-out"); }); it("records the star click as terminal without spending a dismissal", async () => { @@ -188,7 +155,6 @@ describe("GithubStarButton", () => { expect(nudge.state.status).toBe("done"); expect(nudge.state.dismissCount).toBe(0); - expect(fireGithubStarEvent).toHaveBeenCalledWith("github-star-clicked"); }); it("does not re-open unprompted after being dismissed", async () => { diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte index 2c84263e33fe..0e570206ea06 100644 --- a/web-common/src/features/github-star/GithubStarButton.svelte +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -7,9 +7,6 @@ } from "@rilldata/web-common/components/popover"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; - import { behaviourEvent } from "@rilldata/web-common/metrics/initMetrics"; - import { BehaviourEventAction } from "@rilldata/web-common/metrics/service/BehaviourEventTypes"; - import { createLocalServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import { GITHUB_STAR_URL, githubStarNudge, @@ -28,8 +25,6 @@ // relying on a `showFooterLinks={false}` in an unrelated layout. const { adminServer } = featureFlags; - const metadataQuery = createLocalServiceGetMetadata(); - let open = $state(false); /** * Whether a nudge is outstanding, i.e. the popover opened by itself and the user @@ -42,25 +37,15 @@ /** The popover has no trigger, so it anchors to the footer link instead. */ let anchor = $state(null); - // A user who ran `rill telemetry disable` has signalled that they do not want to - // be marketed to. They keep the button; they do not get the unprompted nudge. - const analyticsEnabled = $derived( - $metadataQuery.data?.analyticsEnabled !== false, - ); - // Opening a popover on a timer has no reactive equivalent, so this is one of the // cases where an effect is the right tool. $effect(() => { - if ($adminServer || autoOpened || !nudge.visible || !analyticsEnabled) - return; + if ($adminServer || autoOpened || !nudge.visible) return; const timeout = setTimeout(() => { autoOpened = true; nudging = true; open = true; - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarShown, - ); }, AUTO_OPEN_DELAY_MS); return () => clearTimeout(timeout); @@ -73,27 +58,18 @@ // silent ignores would re-ask engaged users at every mute expiry. nudging = false; nudge.recordSoftDismiss(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarDismissed, - ); } // Shared by the footer link and the popover's primary action: both send the user // to the repo, so both retire the nudge. function star() { nudge.recordStar(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarClicked, - ); nudging = false; open = false; } function optOut() { nudge.recordOptOut(); - void behaviourEvent?.fireGithubStarEvent( - BehaviourEventAction.GithubStarOptedOut, - ); nudging = false; open = false; } diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts index 90ebd91f4cbf..29c0d67600a5 100644 --- a/web-common/src/features/github-star/github-star.svelte.ts +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -23,26 +23,18 @@ const INITIAL_STATE: GithubStarState = { status: "unarmed", dismissCount: 0 }; /** * Tracks whether to nudge the user to star Rill on GitHub. - * - * The nudge is armed by a payoff moment (a dashboard rendering) rather than by - * install or upgrade: a fresh install has no accumulated value to be happy about, - * and Rill ships patches every few days, so an upgrade trigger would nag the most - * engaged users hardest. - * - * Dismissal escalates rather than muting for a fixed period. An "X" click is - * ambiguous between "not now" and "no", and the client cannot tell which, so a - * flat mute would re-ask forever. Escalating to permanent caps an install at - * three asks. + * The nudge is armed by a dashboard render. + * Dismissal escalates rather than muting for a fixed period. After three soft dismissals, the nudge is retired. */ export class GithubStarNudge { - #state = $state(INITIAL_STATE); + private currentState = $state(INITIAL_STATE); - constructor() { - this.#state = readState(); + public constructor() { + this.currentState = readState(); } - get state() { - return this.#state; + public get state() { + return this.currentState; } /** @@ -50,24 +42,24 @@ export class GithubStarNudge { * Recomputed whenever the state changes; mutes last 30+ days, so re-reading on * each app load is ample granularity for expiry. */ - get visible() { - return isVisible(this.#state); + public get visible() { + return isVisible(this.currentState); } /** Called when the user reaches a payoff moment: a dashboard rendered. */ - armPayoff() { - if (this.#state.status !== "unarmed") return; - this.#commit({ ...this.#state, status: "armed" }); + public armPayoff() { + if (this.currentState.status !== "unarmed") return; + this.commit({ ...this.currentState, status: "armed" }); } /** The user starred. Terminal. */ - recordStar() { - this.#commit({ ...this.#state, status: "done" }); + public recordStar() { + this.commit({ ...this.currentState, status: "done" }); } /** The user clicked "Don't show again". Terminal. */ - recordOptOut() { - this.#commit({ ...this.#state, status: "done" }); + public recordOptOut() { + this.commit({ ...this.currentState, status: "done" }); } /** @@ -75,24 +67,24 @@ export class GithubStarNudge { * indistinguishable from the client, and not counting silent ignores would let * engaged users be re-asked every 30 days with no backoff. */ - recordSoftDismiss() { - if (!isVisible(this.#state)) return; + public recordSoftDismiss() { + if (!isVisible(this.currentState)) return; - const dismissCount = this.#state.dismissCount + 1; + const dismissCount = this.currentState.dismissCount + 1; const muteDays = MUTE_SCHEDULE_DAYS[dismissCount - 1]; if (muteDays === undefined) { - this.#commit({ ...this.#state, status: "done", dismissCount }); + this.commit({ ...this.currentState, status: "done", dismissCount }); return; } - this.#commit({ - ...this.#state, + this.commit({ + ...this.currentState, dismissCount, mutedUntil: Date.now() + muteDays * DAY_MS, }); } - #commit(next: GithubStarState) { - this.#state = next; + private commit(next: GithubStarState) { + this.currentState = next; writeState(next); } } diff --git a/web-common/src/metrics/BehaviourEventHandler.ts b/web-common/src/metrics/BehaviourEventHandler.ts index 1ed3f663fda2..923c2dee7f6b 100644 --- a/web-common/src/metrics/BehaviourEventHandler.ts +++ b/web-common/src/metrics/BehaviourEventHandler.ts @@ -99,13 +99,6 @@ export class BehaviourEventHandler { ]); } - public fireGithubStarEvent(action: BehaviourEventAction) { - return this.metricsService.dispatch("githubStarEvent", [ - this.commonUserMetrics, - action, - ]); - } - public fireGithubIntentEvent( action: BehaviourEventAction, githubFields?: GithubEventFields, diff --git a/web-common/src/metrics/service/BehaviourEventFactory.ts b/web-common/src/metrics/service/BehaviourEventFactory.ts index aa9e9bcb7633..9ac5dd60c521 100644 --- a/web-common/src/metrics/service/BehaviourEventFactory.ts +++ b/web-common/src/metrics/service/BehaviourEventFactory.ts @@ -126,24 +126,6 @@ export class BehaviourEventFactory extends MetricsEventFactory { return event; } - public githubStarEvent( - commonFields: CommonFields, - commonUserFields: CommonUserFields, - action: BehaviourEventAction, - ): BehaviourEvent { - const event = this.getBaseMetricsEvent( - "behavioral", - action, - commonFields, - commonUserFields, - ) as BehaviourEvent; - event.action = action; - event.medium = BehaviourEventMedium.Button; - event.space = MetricsEventSpace.LeftPanel; - event.screen_name = MetricsEventScreenName.Unknown; - return event; - } - public githubIntent( commonFields: CommonFields, commonUserFields: CommonUserFields, diff --git a/web-common/src/metrics/service/BehaviourEventTypes.ts b/web-common/src/metrics/service/BehaviourEventTypes.ts index 9aaf9d3038f1..62510341cf26 100644 --- a/web-common/src/metrics/service/BehaviourEventTypes.ts +++ b/web-common/src/metrics/service/BehaviourEventTypes.ts @@ -32,12 +32,6 @@ export enum BehaviourEventAction { GithubConnectFailure = "ghconnected-failure", GithubDisconnect = "ghconnected-disconnect", - // GitHub star nudge actions - GithubStarShown = "github-star-shown", - GithubStarClicked = "github-star-clicked", - GithubStarDismissed = "github-star-dismissed", - GithubStarOptedOut = "github-star-opted-out", - // Welcome/Connector actions AddDataIntent = "add-data-intent", WelcomePageViewed = "welcome-page-viewed", From f60929f4108820eb890c9d252f32ff6f11fc5431 Mon Sep 17 00:00:00 2001 From: Anshul Khandelwal <12948312+k-anshul@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:26:41 +0530 Subject: [PATCH 3/5] dismissal logic change --- .../github-star/GithubStarButton.spec.ts | 10 ++-- .../github-star/GithubStarButton.svelte | 50 +++++++++++++++---- .../github-star/github-star.svelte.spec.ts | 40 ++++++--------- .../github-star/github-star.svelte.ts | 31 +++--------- web-common/src/lib/i18n/messages/en.json | 2 +- web-common/src/lib/i18n/messages/es.json | 2 +- 6 files changed, 69 insertions(+), 66 deletions(-) diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts index f0ece4c85b3b..463707710a85 100644 --- a/web-common/src/features/github-star/GithubStarButton.spec.ts +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -48,7 +48,8 @@ describe("GithubStarButton", () => { // Neither the button nor the nudge: this is a Rill Developer feature. expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("always renders the footer link, pointing straight at the repo", () => { @@ -119,7 +120,6 @@ describe("GithubStarButton", () => { // Still armed, but muted: the ask is deferred rather than abandoned. expect(nudge.state.status).toBe("armed"); - expect(nudge.state.dismissCount).toBe(1); expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); }); @@ -132,7 +132,7 @@ describe("GithubStarButton", () => { expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("records an opt-out as terminal without spending a dismissal", async () => { @@ -143,7 +143,7 @@ describe("GithubStarButton", () => { await settle(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("records the star click as terminal without spending a dismissal", async () => { @@ -154,7 +154,7 @@ describe("GithubStarButton", () => { await settle(); expect(nudge.state.status).toBe("done"); - expect(nudge.state.dismissCount).toBe(0); + expect(nudge.state.mutedUntil).toBeUndefined(); }); it("does not re-open unprompted after being dismissed", async () => { diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte index 0e570206ea06..317693f9339a 100644 --- a/web-common/src/features/github-star/GithubStarButton.svelte +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -111,24 +111,34 @@ customAnchor={anchor} align="start" side="top" - class="w-[260px] p-4" + sideOffset={8} + class="github-star-popover w-[280px] overflow-hidden border-primary-200 bg-surface-overlay p-0 shadow-xl" role="status" trapFocus={false} onOpenAutoFocus={(e: Event) => e.preventDefault()} onCloseAutoFocus={(e: Event) => e.preventDefault()} > -
-
-

- {m.github_star_title()} -

-

- {m.github_star_message()} -

+ +
+
+
+ +
+
+

+ {m.github_star_title()} +

+

+ {m.github_star_message()} +

+