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
86 changes: 53 additions & 33 deletions desktop/src/features/onboarding/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,58 +94,77 @@ 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<Channel[]>(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
// snapshot. Reinsert the just-ensured channels before announcing focus so
// the route can consume the pending private Welcome channel immediately.
queryClient.setQueryData<Channel[]>(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,
Expand Down
88 changes: 88 additions & 0 deletions desktop/src/features/onboarding/welcome.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import test from "node:test";

import {
consumePendingWelcomeChannel,
ensureOptionalWelcomeChannel,
ensureStarterChannels,
ensureWelcomeChannel,
findPrivateWelcomeChannel,
hasEnsuredWelcomeChannel,
isWelcomeExperienceChannel,
markWelcomeChannelEnsured,
rememberPendingWelcomeChannel,
resolveWelcomeFocusChannelId,
WELCOME_CHANNEL_DESCRIPTION,
WELCOME_CHANNEL_NAME,
} from "./welcome.ts";
Expand Down Expand Up @@ -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);
});
45 changes: 45 additions & 0 deletions desktop/src/features/onboarding/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OptionalWelcomeChannelResult> {
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<StarterChannelsResult> {
Expand Down
Loading