From 1c88d48f1625400411c7ddbf5d784bca4a658bd5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 2 Nov 2026 09:47:08 -0500 Subject: [PATCH] fix(mobile): skip a starter whose id an archived channel holds The relay refuses a kind:9007 whose `h` is an archived channel with `invalid: channel is archived`, before its duplicate check. Desktop and mobile create a starter at the same fixed id, so an archived starter there left invite setup in permanent recovery. Treat that refusal as an archived starter: join a live namesake if the reload shows one, else skip it. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 14 +++++++++++++- .../invites/invite_join_provider_test.dart | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 2e5d8a7111..dc3fe9202b 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -157,7 +157,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { _ensureScopeCurrent(); } catch (error) { final nameTaken = _isChannelNameTakenError(error); - if (!nameTaken && !_isDuplicateChannelError(error)) rethrow; + final archivedAtId = _isArchivedChannelError(error); + if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) { + rethrow; + } _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); @@ -168,6 +171,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { .firstOrNull ?? (nameTaken ? _findListedNamesake(channels, starter.slug) : null); if (holder == null) { + // The relay refuses a create at an archived channel's id before + // its duplicate check, and keeps that id, so the starter can never + // be created there. + if (archivedAtId) continue; // The directory omits archived channels, yet the relay still // keeps the id (desktop uses the same one), so a duplicate with // no match may be an archived starter. Read that id directly. @@ -228,6 +235,11 @@ bool _isUsableStarter(Channel channel) => bool _isDuplicateChannelError(Object error) => error.toString().contains('duplicate: channel already exists'); +/// The relay refuses any channel event whose `h` is an archived channel, +/// a create at that id included: `invalid: channel is archived`. +bool _isArchivedChannelError(Object error) => + error.toString().contains('invalid: channel is archived'); + /// The relay refuses creating an open channel whose name another listed /// channel already holds, ignoring case: /// `invalid: an open channel named "general" already exists`. diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 94008e0230..c8e6ed4722 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -496,6 +496,20 @@ void main() { expect(archivedAtId.loads(), 2); expect(focusAfterArchived, welcomeId); + // The relay refuses a create at an archived channel's id with + // `channel is archived`; that starter is skipped without a lookup. + final refusedAsArchived = starterSetup( + channelsOnLoad: (_) => const [], + refusalError: 'invalid: channel is archived', + ); + final focusAfterRefusal = await refusedAsArchived.recovery + .ensureStarterChannels(); + expect(refusedAsArchived.created, ['general', 'welcome-everyone']); + expect(refusedAsArchived.lookups, isEmpty); + expect(refusedAsArchived.joined, isEmpty); + expect(refusedAsArchived.loads(), 2); + expect(focusAfterRefusal, welcomeId); + // The scope fence also holds across the direct lookup, checked on the // last starter so the next starter's own fence cannot mask it. var lookedUp = false;