From 7028c861a9f08027ee3857702772eed587928d8a Mon Sep 17 00:00:00 2001 From: mattbalza Date: Wed, 5 Aug 2026 14:25:07 +0200 Subject: [PATCH] fix(onboarding): don't fail first run when the relay forbids member channel creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay running `channel_create_policy=owner-only` rejects the personal private "Welcome" channel for every non-owner member with restricted: only workspace owners may create durable channels or forums `initializeStarterChannels` already treated the public starter channels as best-effort, but `ensureWelcomeChannel` threw straight through, so first run returned `{ok: false}` and `CommunityOnboardingFlow.finalize()` re-threw it — painting "Couldn't set up starter channels" on a brand-new install even though the member was fully provisioned and every shared channel was reachable. Treat the private Welcome room as optional: - `ensureOptionalWelcomeChannel` resolves to `{channel: null, unavailableReason}` instead of rejecting. - Skip the seeding, pending-channel and ready-event work when there is no channel, and mark the welcome channel as settled so the mount effect stops re-attempting the rejected create every ~46s. - `resolveWelcomeFocusChannelId` lands first run on `welcome-everyone`, then `general`, when there is no private Welcome channel. Genuine starter-channel failures still surface as before. Signed-off-by: mattbalza --- desktop/src/features/onboarding/hooks.ts | 86 +++++++++++------- .../src/features/onboarding/welcome.test.mjs | 88 +++++++++++++++++++ desktop/src/features/onboarding/welcome.ts | 45 ++++++++++ 3 files changed, 186 insertions(+), 33 deletions(-) diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index 7495af48ebb..a6c6af67796 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -8,12 +8,13 @@ import { } from "@/features/agents/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; import { + ensureOptionalWelcomeChannel, ensureStarterChannels, - ensureWelcomeChannel, hasEnsuredWelcomeChannel, markWelcomeChannelEnsured, notifyWelcomeChannelReady, rememberPendingWelcomeChannel, + resolveWelcomeFocusChannelId, } from "@/features/onboarding/welcome"; import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding"; import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas"; @@ -93,41 +94,55 @@ export async function initializeStarterChannels( console.warn("Failed to initialize public starter channels.", error); } - const welcomeChannel = await ensureWelcomeChannel( - { - createChannel, - deleteChannel, - getChannelMembers, - getChannels, - updateChannel, - }, - { - replaceExisting: forceFreshOnboarding, - }, - ); + const { channel: welcomeChannel, unavailableReason } = + await ensureOptionalWelcomeChannel( + { + createChannel, + deleteChannel, + getChannelMembers, + getChannels, + updateChannel, + }, + { + replaceExisting: forceFreshOnboarding, + }, + ); + + if (!welcomeChannel) { + // Owner-only relays reject member-created channels, so this community + // just has no private Welcome room. Record it as settled, otherwise the + // retry effect re-attempts the rejected create on every mount. + console.warn( + "Continuing without a private Welcome channel.", + unavailableReason, + ); + markWelcomeChannelEnsured(pubkey, communityScope); + } const starterChannelList = starterChannels?.channels ?? []; + const ensuredChannelList = welcomeChannel + ? [...starterChannelList, welcomeChannel] + : starterChannelList; queryClient.setQueryData(channelsQueryKey, (channels = []) => { const ensuredIds = new Set( - starterChannelList.map((channel) => channel.id), + ensuredChannelList.map((channel) => channel.id), + ); + const ensuredById = new Map( + ensuredChannelList.map((channel) => [channel.id, channel]), ); - ensuredIds.add(welcomeChannel.id); return [ - ...starterChannelList, - ...(starterChannelList.some( - (channel) => channel.id === welcomeChannel.id, - ) - ? [] - : [welcomeChannel]), + ...ensuredById.values(), ...channels.filter((channel) => !ensuredIds.has(channel.id)), ]; }); - void seedWelcomeExperience( - queryClient, - welcomeChannel.id, - pubkey, - communityScope, - ); + if (welcomeChannel) { + void seedWelcomeExperience( + queryClient, + welcomeChannel.id, + pubkey, + communityScope, + ); + } await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (focus) { // Refreshing can briefly replace the optimistic cache with an older relay @@ -135,16 +150,21 @@ export async function initializeStarterChannels( // the route can consume the pending private Welcome channel immediately. queryClient.setQueryData(channelsQueryKey, (channels = []) => { const byId = new Map( - [...channels, ...starterChannelList, welcomeChannel].map( - (channel) => [channel.id, channel], - ), + [...channels, ...ensuredChannelList].map((channel) => [ + channel.id, + channel, + ]), ); return [...byId.values()]; }); - rememberPendingWelcomeChannel(welcomeChannel.id); - notifyWelcomeChannelReady(welcomeChannel.id); + if (welcomeChannel) { + rememberPendingWelcomeChannel(welcomeChannel.id); + notifyWelcomeChannelReady(welcomeChannel.id); + } } - const focusChannelId = focus ? welcomeChannel.id : undefined; + const focusChannelId = focus + ? resolveWelcomeFocusChannelId(welcomeChannel, starterChannels) + : undefined; if (starterChannelsError) { return { ok: false, diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs index e0a57a4586b..3a4321ce6f3 100644 --- a/desktop/src/features/onboarding/welcome.test.mjs +++ b/desktop/src/features/onboarding/welcome.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { consumePendingWelcomeChannel, + ensureOptionalWelcomeChannel, ensureStarterChannels, ensureWelcomeChannel, findPrivateWelcomeChannel, @@ -10,6 +11,7 @@ import { isWelcomeExperienceChannel, markWelcomeChannelEnsured, rememberPendingWelcomeChannel, + resolveWelcomeFocusChannelId, WELCOME_CHANNEL_DESCRIPTION, WELCOME_CHANNEL_NAME, } from "./welcome.ts"; @@ -360,3 +362,89 @@ test("isWelcomeExperienceChannel matches legacy Welcome and starter welcome-ever ); assert.equal(isWelcomeExperienceChannel(null), false); }); + +const OWNER_ONLY_RELAY_ERROR = + "restricted: only workspace owners may create durable channels or forums"; + +test("ensureOptionalWelcomeChannel returns the channel when the relay allows it", async () => { + const created = makeChannel({ id: "welcome-created" }); + const result = await ensureOptionalWelcomeChannel({ + getChannels: async () => [], + createChannel: async () => created, + }); + + assert.equal(result.channel, created); + assert.equal(result.unavailableReason, undefined); +}); + +test("ensureOptionalWelcomeChannel reports unavailable on an owner-only relay", async () => { + // Regression: relays running channel_create_policy=owner-only reject the + // personal Welcome channel for every member, which used to fail first run + // outright with "Couldn't set up starter channels". + const result = await ensureOptionalWelcomeChannel({ + getChannels: async () => [], + createChannel: async () => { + throw new Error(OWNER_ONLY_RELAY_ERROR); + }, + }); + + assert.equal(result.channel, null); + assert.equal(result.unavailableReason, OWNER_ONLY_RELAY_ERROR); +}); + +test("ensureOptionalWelcomeChannel reports unavailable for a non-Error rejection", async () => { + const result = await ensureOptionalWelcomeChannel({ + getChannels: async () => { + throw "relay offline"; + }, + createChannel: async () => makeChannel(), + }); + + assert.equal(result.channel, null); + assert.equal(typeof result.unavailableReason, "string"); + assert.ok(result.unavailableReason.length > 0); +}); + +test("resolveWelcomeFocusChannelId prefers the private Welcome channel", () => { + const personal = makeChannel({ id: "welcome-private" }); + const general = makeChannel({ id: "general-1", name: "general" }); + const welcomeEveryone = makeChannel({ + id: "welcome-everyone-1", + name: "welcome-everyone", + }); + + assert.equal( + resolveWelcomeFocusChannelId(personal, { + channels: [general, welcomeEveryone], + generalChannel: general, + welcomeChannel: welcomeEveryone, + }), + "welcome-private", + ); +}); + +test("resolveWelcomeFocusChannelId falls back to welcome-everyone, then general", () => { + const general = makeChannel({ id: "general-1", name: "general" }); + const welcomeEveryone = makeChannel({ + id: "welcome-everyone-1", + name: "welcome-everyone", + }); + + assert.equal( + resolveWelcomeFocusChannelId(null, { + channels: [general, welcomeEveryone], + generalChannel: general, + welcomeChannel: welcomeEveryone, + }), + "welcome-everyone-1", + ); + assert.equal( + resolveWelcomeFocusChannelId(null, { + channels: [general], + generalChannel: general, + welcomeChannel: null, + }), + "general-1", + ); + assert.equal(resolveWelcomeFocusChannelId(null, null), undefined); +}); diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts index 359c8341b23..9e8317a6e26 100644 --- a/desktop/src/features/onboarding/welcome.ts +++ b/desktop/src/features/onboarding/welcome.ts @@ -257,6 +257,51 @@ export async function ensureWelcomeChannel( return client.createChannel(welcomeChannelInput); } +export type OptionalWelcomeChannelResult = + | { channel: Channel; unavailableReason?: undefined } + | { channel: null; unavailableReason: string }; + +/** + * Resolve the personal Welcome channel without letting its absence fail first + * run. Relays configured with `channel_create_policy=owner-only` reject the + * create for every non-owner, so the private Welcome room is a nicety the + * community may simply not offer. + */ +export async function ensureOptionalWelcomeChannel( + client: WelcomeChannelClient, + options: WelcomeChannelOptions = {}, +): Promise { + try { + return { channel: await ensureWelcomeChannel(client, options) }; + } catch (error) { + return { + channel: null, + unavailableReason: + error instanceof Error + ? error.message + : "The private Welcome channel is unavailable.", + }; + } +} + +/** + * Where first run should land: the personal Welcome channel when the community + * allows one, otherwise the shared welcome-everyone room, otherwise general. + */ +export function resolveWelcomeFocusChannelId( + welcomeChannel: Channel | null, + starterChannels: { + welcomeChannel: Channel | null; + generalChannel: Channel | null; + } | null, +) { + return ( + welcomeChannel?.id ?? + starterChannels?.welcomeChannel?.id ?? + starterChannels?.generalChannel?.id + ); +} + export async function ensureStarterChannels( client: StarterChannelsClient, ): Promise {