mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
### 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>
186 lines
6.2 KiB
Dart
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));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|