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
2 changes: 2 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[workspace]

[package]
name = "sprout"
version = "0.1.0"
Expand Down
46 changes: 40 additions & 6 deletions desktop/src/features/onboarding/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type OnboardingGateStage = "blocking" | "onboarding" | "ready";

type UseFirstRunOnboardingGateOptions = {
currentPubkey: string | null;
hasExistingProfile: boolean;
identityIsFetching: boolean;
identityStatus: QueryStatus;
profileStatus: QueryStatus;
Expand Down Expand Up @@ -125,6 +126,7 @@ function resolveOnboardingGateStage({

export function useFirstRunOnboardingGate({
currentPubkey,
hasExistingProfile,
identityIsFetching,
identityStatus,
profileStatus,
Expand Down Expand Up @@ -166,14 +168,37 @@ export function useFirstRunOnboardingGate({
return;
}

// If the relay already has a real profile for this pubkey, the user has
// been onboarded elsewhere — skip the gate and persist the completion so
// future launches in this data dir don't re-check.
if (hasExistingProfile) {
if (typeof window !== "undefined" && currentPubkey) {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
}

setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasSettledCurrentPubkey: true,
isOpen: !activeGateState.hasCompletedCurrentPubkey,
})),
updateActiveGateState(current, currentPubkey, (activeGateState) => {
const alreadyOnboarded =
activeGateState.hasCompletedCurrentPubkey || hasExistingProfile;
return {
...activeGateState,
hasCompletedCurrentPubkey: alreadyOnboarded,
hasSettledCurrentPubkey: true,
isOpen: !alreadyOnboarded,
};
}),
);
}, [currentPubkey, hasSettledCurrentPubkey, identityStatus, profileStatus]);
}, [
currentPubkey,
hasExistingProfile,
hasSettledCurrentPubkey,
identityStatus,
profileStatus,
]);

const skipForNow = React.useCallback(() => {
setGateState((current) =>
Expand Down Expand Up @@ -213,6 +238,14 @@ export function useFirstRunOnboardingGate({
};
}

function hasRealDisplayName(displayName?: string | null): boolean {
if (!displayName) return false;
const trimmed = displayName.trim();
if (trimmed.length === 0) return false;
const lower = trimmed.toLowerCase();
return !lower.startsWith("npub1") && !lower.startsWith("nostr:npub1");
}

export function useAppOnboardingState() {
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
Expand All @@ -221,6 +254,7 @@ export function useAppOnboardingState() {
const profileQuery = useProfileQuery();
const onboardingGate = useFirstRunOnboardingGate({
currentPubkey,
hasExistingProfile: hasRealDisplayName(profileQuery.data?.displayName),
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityStatus: identityQuery.status,
profileStatus: profileQuery.status,
Expand Down
25 changes: 20 additions & 5 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};

type TestIdentity = {
privateKey: string;
Expand Down Expand Up @@ -139,17 +143,16 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({
test("first-run onboarding keeps the shell hidden through both pages and only marks Home seen after finish", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");

await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(page.getByTestId("onboarding-display-name")).toHaveValue(
"alice",
);
await expect(page.getByTestId("onboarding-display-name")).toHaveValue("");
await expectNoHomeSeenEntries(page);

await page.getByTestId("onboarding-display-name").fill("Alice");
await continueToSetupPage(page);
await expectShellHidden(page);
await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible();
Expand All @@ -161,6 +164,17 @@ test("first-run onboarding keeps the shell hidden through both pages and only ma
await expectHomeSeenCount(page, 2);
});

test("existing relay profile auto-skips onboarding without localStorage completion", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");

await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
});

test("finishing onboarding auto-joins the #general channel for a new member", async ({
page,
}) => {
Expand All @@ -179,7 +193,7 @@ test("finishing onboarding auto-joins the #general channel for a new member", as
test("page 2 falls back to Doctor guidance when ACP tools are not installed", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(
page,
{
Expand All @@ -189,6 +203,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as
);
await page.goto("/");

await page.getByTestId("onboarding-display-name").fill("Alice");
await continueToSetupPage(page);
await expect(page.getByTestId("onboarding-acp-empty")).toBeVisible();
await expect(
Expand Down
21 changes: 20 additions & 1 deletion scripts/instance-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# SPROUT_RELAY_PORT, SPROUT_RELAY_URL
# SPROUT_INSTANCE_SLUG, SPROUT_WORKTREE_LABEL, VITE_DEV_BRANCH (worktrees only)
# SPROUT_TAURI_CONFIG
# SPROUT_PRIVATE_KEY (worktrees only, when SPROUT_SHARE_IDENTITY=1)

WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)

Expand All @@ -24,13 +25,31 @@ unset VITE_DEV_BRANCH

# In worktrees, extract a label from the branch name and derive a unique app
# identity and icon so multiple local desktop instances can run side by side.
#
# Worktree detection: compare --git-dir to --git-common-dir. In the main
# working tree these are identical; in any worktree (whether under .worktrees/,
# .claude/worktrees/, or elsewhere on disk) they differ.
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
if [[ -n "$GIT_COMMON_DIR" && "$GIT_DIR" != "$GIT_COMMON_DIR" ]]; then
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
export SPROUT_WORKTREE_LABEL="${BRANCH_NAME##*/}"
export SPROUT_INSTANCE_SLUG=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')

# SPROUT_SHARE_IDENTITY=1: reuse the main dev checkout's Nostr key so
# worktrees skip onboarding and share the same identity. The per-worktree
# identifier is kept so concurrent instances don't collide on
# tauri-plugin-single-instance or the app data directory.
if [[ "${SPROUT_SHARE_IDENTITY:-0}" == "1" ]]; then
CANONICAL_KEY="$HOME/Library/Application Support/xyz.block.sprout.app.dev/identity.key"
if [[ -f "$CANONICAL_KEY" ]]; then
export SPROUT_PRIVATE_KEY="$(cat "$CANONICAL_KEY")"
else
echo "⚠ SPROUT_SHARE_IDENTITY=1 but no identity found at $CANONICAL_KEY — run Sprout from repo root first" >&2
fi
fi

ICON_DIR="$(pwd)/src-tauri/target/dev-icons"
mkdir -p "$ICON_DIR"
DEV_ICON="$ICON_DIR/icon.icns"
Expand Down
Loading