Files
buzz/mobile/lib/features/channels/deep_link_dispatcher.dart
Tom Brow 6eff84d127 fix(mobile): join starter channels after accepting invite (#5915)
### What changed?

After a new mobile invite claim succeeds, Buzz now best-effort ensures
membership in the same public starter channels as desktop:

- `#general`
- `#welcome-everyone`

Missing starters use desktop's deterministic per-relay IDs and exact
public channel configuration, so concurrent mobile and desktop setup
converges safely. Setup failures do not invalidate or retry an already
successful invite claim, and failure for one starter does not block the
other.

The success sheet offers **Continue to #welcome-everyone** when that
channel is available. Mobile does not create the private `Welcome`
channel because it cannot provision the desktop Welcome agents that make
that channel useful.

This PR is stacked on #6145 because it deliberately reuses that PR's
open-channel directory and join behavior. Once #6145 merges, this PR can
be retargeted to `main` without changing its BUZZ-12 diff.

Fixes
[BUZZ-12](https://linear.app/squareup/issue/BUZZ-12/bug-community-appears-empty-after-using-invite-link-on-mobile).

### How is it tested?

- Desktop/mobile deterministic starter-ID parity coverage.
- Existing-channel join and missing-channel creation coverage.
- Duplicate-create convergence and per-channel failure isolation
coverage.
- Invite success remains successful when starter setup fails.
- Widget coverage for continuing directly into `#welcome-everyone`.
- Focused invite/deep-link tests: 23 passed.
- `just mobile-check`: passed.
- `just mobile-test`: 1,483 passed.
- Pre-push repository checks: passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 12:59:21 -07:00

186 lines
6.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../invites/invite_join_provider.dart';
import '../invites/invite_join_sheet.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channels_provider.dart';
/// Routes pending `buzz://message` deep links into the channel view.
///
/// Wraps the authenticated home subtree. Whenever a parsed link is parked in
/// [pendingDeepLinkProvider] and the channel list is available, this pushes
/// the target [ChannelDetailPage] on the enclosing [Navigator]. Links are
/// held (not dropped) while channels are still loading, so cold-start links
/// dispatch as soon as the first channel fetch completes.
typedef DeepLinkDestinationBuilder =
Widget Function(Channel channel, BuzzDeepLink link);
class DeepLinkDispatcher extends ConsumerStatefulWidget {
final Widget child;
final DeepLinkDestinationBuilder? destinationBuilder;
final bool dispatchMessageLinks;
const DeepLinkDispatcher({
super.key,
required this.child,
this.destinationBuilder,
this.dispatchMessageLinks = true,
});
@override
ConsumerState<DeepLinkDispatcher> createState() => _DeepLinkDispatcherState();
}
class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
bool _preparingInvite = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
@override
Widget build(BuildContext context) {
// Re-evaluate dispatch when either a new link arrives or channels load.
ref.listen<BuzzDeepLink?>(pendingDeepLinkProvider, (_, link) {
_maybeDispatch(link);
});
if (widget.dispatchMessageLinks) {
ref.listen<AsyncValue<List<Channel>>>(channelsProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
return widget.child;
}
void _maybeDispatch(BuzzDeepLink? link) {
if (link == null || _preparingInvite) return;
if (link is InviteDeepLink) {
_maybeDispatchInvite(link);
return;
}
if ((link is! MessageDeepLink && link is! ChannelDeepLink) ||
!widget.dispatchMessageLinks) {
return;
}
final channelId = switch (link) {
MessageDeepLink(:final channelId) => channelId,
ChannelDeepLink(:final channelId) => channelId,
_ => throw StateError('unsupported navigable deep link: $link'),
};
final channels = ref.read(channelsProvider).asData?.value;
// Channels not loaded yet — keep the link parked; the channelsProvider
// listener re-attempts once data arrives.
if (channels == null) return;
final channel = channels
.where((c) => c.id == channelId)
.cast<Channel?>()
.firstOrNull;
if (channel == null) {
ref.read(pendingDeepLinkProvider.notifier).consume();
debugPrint(
'deep-link: channel $channelId not found in workspace; dropping link',
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Channel not found in this workspace')),
);
return;
}
if (!context.mounted) return;
_pushChannel(channel, link);
ref.read(pendingDeepLinkProvider.notifier).consume();
}
void _pushChannel(Channel channel, BuzzDeepLink link) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
widget.destinationBuilder?.call(channel, link) ??
ChannelDetailPage(
channel: channel,
initialMessageId: link is MessageDeepLink ? link.messageId : null,
initialThreadRootId: link is MessageDeepLink
? link.threadRootId
: null,
),
),
);
}
void _maybeDispatchInvite(InviteDeepLink link) {
if (_preparingInvite) return;
_preparingInvite = true;
final navigatorContext = context;
final messenger = ScaffoldMessenger.maybeOf(context);
Future.microtask(() async {
var consumed = false;
try {
await ref.read(inviteJoinProvider.notifier).prepare(link);
ref.read(pendingDeepLinkProvider.notifier).consume();
consumed = true;
if (!navigatorContext.mounted) return;
final inviteState = ref.read(inviteJoinProvider);
final status = inviteState.status;
if (status == InviteJoinStatus.confirming ||
inviteState.isStarterSetupRecovery) {
final sheet = showInviteJoinSheet(navigatorContext);
if (status == InviteJoinStatus.claiming &&
inviteState.isStarterSetupRecovery) {
unawaited(
ref.read(inviteJoinProvider.notifier).startStarterSetupRecovery(),
);
}
final shouldFocusStarter = await sheet;
final focusChannelId = ref.read(inviteJoinProvider).focusChannelId;
if (shouldFocusStarter == true &&
focusChannelId != null &&
ref.read(pendingDeepLinkProvider) == null &&
navigatorContext.mounted) {
final channels = ref.read(channelsProvider).asData?.value;
final channel = channels
?.where((candidate) => candidate.id == focusChannelId)
.firstOrNull;
if (channel != null) {
_pushChannel(channel, ChannelDeepLink(channelId: focusChannelId));
}
}
} else if (status == InviteJoinStatus.switchedExisting) {
messenger?.showSnackBar(
const SnackBar(content: Text('Switched to this community')),
);
}
} catch (error) {
debugPrint('deep-link: failed to prepare invite: $error');
if (navigatorContext.mounted) {
messenger?.showSnackBar(
const SnackBar(
content: Text(
'Could not open this invite. Re-open the invite link to try again.',
),
),
);
}
} finally {
_preparingInvite = false;
if (mounted && consumed) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
}
}
});
}
}