fix(desktop): make starter channels best-effort for community joiners

Joining an established community failed onboarding with 'starter channels
created but metadata not yet available' whenever the community's starter
channels (general / welcome-everyone) no longer matched the pristine spec —
renamed, made private (their kind:39000 metadata is invisible to a
non-member joiner), archived, or converted to a forum. Every new member hit
the error deterministically on the Meet-your-starter-team screen.

Starter-channel setup is a real requirement only for the community founder
creating them; for a joiner, a customized starter set is normal community
evolution, not a failure. Downgrade both layers to best-effort:

- ensure_starter_channels (Rust) logs and proceeds instead of erroring when
  the full starter set is not observable; membership joins still happen for
  whatever starter channels do match.
- ensureStarterChannels (TS) returns nullable generalChannel/welcomeChannel
  instead of throwing when the ensured list lacks a full match. No callers
  consumed those fields outside tests.

The private Welcome channel flow is unchanged and was already independent
of this failure.

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-07-23 08:44:03 +02:00
parent acfbb1bb6a
commit b47880885f
3 changed files with 63 additions and 24 deletions
+14 -1
View File
@@ -674,8 +674,21 @@ pub async fn ensure_starter_channels(
existing_channels = get_channels(state.clone()).await?;
}
// Best-effort from here: a spec that still has no match is normal
// community evolution, not a failure. In an established community the
// starter channels may have been renamed, made private (their kind:39000
// is then invisible to a non-member joiner), archived, or converted to a
// forum — none of which should block a new member's onboarding.
// The freshly-created case needs no rescue either: the relay grants the
// creator owner membership at create time, so a slow kind:39000 only
// delays sidebar visibility. `ensure_starter_channel_memberships` skips
// unmatched specs, joining only the starter channels that are actually
// observable.
if !has_all_starter_channels(&existing_channels) {
return Err("starter channels created but metadata not yet available".to_string());
eprintln!(
"buzz-desktop: ensure_starter_channels: proceeding without full starter set \
(metadata missing or channels customized)"
);
}
ensure_starter_channel_memberships(&state, &creator_keys, &mut existing_channels).await?;
@@ -332,6 +332,31 @@ test("ensureStarterChannels resumes when one starter channel is missing", async
assert.equal(ensureCalls, 1);
});
test("ensureStarterChannels succeeds without a full starter set (customized community)", async () => {
// An established community renamed/privatized its starter channels: the
// ensure command returns whatever this member can see, and a partial (or
// empty) starter match must not throw — joiner onboarding is best-effort.
const renamedGeneral = makeChannel({
id: "general-channel",
name: "town-square",
visibility: "open",
});
let ensureCalls = 0;
const result = await ensureStarterChannels({
getChannels: async () => [renamedGeneral],
ensureStarterChannels: async () => {
ensureCalls += 1;
return [renamedGeneral];
},
});
assert.equal(result.generalChannel, null);
assert.equal(result.welcomeChannel, null);
assert.deepEqual(result.channels, [renamedGeneral]);
assert.equal(ensureCalls, 1);
});
test("isWelcomeExperienceChannel matches legacy Welcome and starter welcome-everyone", () => {
assert.equal(isWelcomeExperienceChannel(makeChannel()), true);
assert.equal(
+24 -23
View File
@@ -41,8 +41,13 @@ type StarterChannelsClient = {
export type StarterChannelsResult = {
channels: Channel[];
generalChannel: Channel;
welcomeChannel: Channel;
/**
* Null when the community's starter channel no longer matches the spec
* (renamed, private, archived, or forum). Starter channels are best-effort
* for joiners of established communities — a missing match is not an error.
*/
generalChannel: Channel | null;
welcomeChannel: Channel | null;
};
type WelcomeChannelOptions = {
@@ -86,21 +91,17 @@ function findStarterChannel(channels: Channel[], name: string) {
export function findStarterChannels(
channels: Channel[],
): Omit<StarterChannelsResult, "channels"> | null {
const generalChannel = findStarterChannel(
channels,
STARTER_GENERAL_CHANNEL_NAME,
);
const welcomeChannel = findStarterChannel(
channels,
STARTER_WELCOME_CHANNEL_NAME,
);
): Omit<StarterChannelsResult, "channels"> {
return {
generalChannel: findStarterChannel(channels, STARTER_GENERAL_CHANNEL_NAME),
welcomeChannel: findStarterChannel(channels, STARTER_WELCOME_CHANNEL_NAME),
};
}
if (!generalChannel || !welcomeChannel) {
return null;
}
return { generalChannel, welcomeChannel };
function hasAllStarterChannels(
starters: Omit<StarterChannelsResult, "channels">,
) {
return starters.generalChannel !== null && starters.welcomeChannel !== null;
}
function hasOnlyCurrentOrAllowedMembers(
@@ -262,22 +263,22 @@ export async function ensureStarterChannels(
): Promise<StarterChannelsResult> {
const existingChannels = await client.getChannels();
const existingStarters = findStarterChannels(existingChannels);
if (existingStarters) {
if (hasAllStarterChannels(existingStarters)) {
return {
channels: existingChannels,
...existingStarters,
};
}
// Best-effort: in an established community the starter channels may have
// been renamed, made private, archived, or converted — the ensure command
// creates whatever is genuinely missing and returns what this member can
// see. A partial (or empty) starter set is fine; it must not fail a
// joiner's onboarding.
const ensuredChannels = await client.ensureStarterChannels();
const ensuredStarters = findStarterChannels(ensuredChannels);
if (!ensuredStarters) {
throw new Error("Starter channels were not available after setup");
}
return {
channels: ensuredChannels,
...ensuredStarters,
...findStarterChannels(ensuredChannels),
};
}