mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
Clarify mobile community onboarding and saved identity selection (#8729)
## Summary Make community onboarding easier to scan: shorten hosted hostname fallbacks, clarify Add Community instructions, and replace raw saved-key buttons with identity cards. Use white pages, grey cards, ghost secondary actions, standard page transitions, and a “Getting ready” screen during joining. Native profile setup keeps a horizontal modal transition. Identity cards load signed name/avatar metadata from saved communities without switching accounts; unavailable profiles use a neutral fallback. Cards show only the avatar, a 16px medium-weight name, and muted npub; community lists are omitted. Retiring profile setup closes its avatar editor too. ### Related issue Follow-up to #8687 and #8599; no duplicate PR found. ### Testing - Formatting and analysis passed. Full suite with hosted-auth defines: 4,019 passed, 5 skipped; includes profile-fetch and cancellation regressions. - Prior iOS device preview and native profile tests passed; hosted CI native Swift checks passed on the preceding head. Latest Dart fixes are not yet reinstalled on the phone. - Simulator walkthrough and owner-tested iPhone preview use sample identities and simulated admission. Sample-data previews; the identity snapshot shows the unavailable-profile fallback rendered by Flutter. Media is attached here only. | Community picker | Add Community | Saved identity | | --- | --- | --- | |  |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -3,7 +3,7 @@ import UIKit
|
||||
import CoreText
|
||||
|
||||
/// Profile draft presentation; persistence and the existing avatar editor stay in Dart.
|
||||
final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate, UIViewControllerTransitioningDelegate {
|
||||
private let channel: FlutterMethodChannel
|
||||
private var navigation: UINavigationController?
|
||||
private var pending: FlutterResult?
|
||||
@@ -81,6 +81,7 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
nav.modalPresentationStyle = .overFullScreen
|
||||
nav.transitioningDelegate = self
|
||||
nav.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
|
||||
self.pending = result; self.session = session; self.navigation = nav
|
||||
controller.loadViewIfNeeded()
|
||||
@@ -257,6 +258,50 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
result?(action)
|
||||
}
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { complete("skip") }
|
||||
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController,
|
||||
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
ProfileSetupPageTransition(presenting: true)
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
ProfileSetupPageTransition(presenting: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches the horizontal forward/backward motion of the Flutter onboarding pages.
|
||||
private final class ProfileSetupPageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
let presenting: Bool
|
||||
init(presenting: Bool) { self.presenting = presenting }
|
||||
|
||||
func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval { 0.25 }
|
||||
|
||||
func animateTransition(using context: UIViewControllerContextTransitioning) {
|
||||
let key: UITransitionContextViewControllerKey = presenting ? .to : .from
|
||||
guard let controller = context.viewController(forKey: key) else {
|
||||
context.completeTransition(false)
|
||||
return
|
||||
}
|
||||
guard let view = context.view(forKey: presenting ? .to : .from) ?? controller.view else {
|
||||
context.completeTransition(false)
|
||||
return
|
||||
}
|
||||
let distance = context.containerView.bounds.width
|
||||
if presenting {
|
||||
view.frame = context.finalFrame(for: controller)
|
||||
context.containerView.addSubview(view)
|
||||
view.transform = CGAffineTransform(translationX: distance, y: 0)
|
||||
}
|
||||
UIView.animate(withDuration: transitionDuration(using: context), delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState]) {
|
||||
view.transform = self.presenting ? .identity : CGAffineTransform(translationX: distance, y: 0)
|
||||
} completion: { _ in
|
||||
let completed = !context.transitionWasCancelled
|
||||
if !self.presenting && completed { view.removeFromSuperview() }
|
||||
view.transform = .identity
|
||||
context.completeTransition(completed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeProfileSetupController: UIViewController, UITextFieldDelegate {
|
||||
|
||||
@@ -88,6 +88,7 @@ final class ProfileSetupRecoveryTests: XCTestCase {
|
||||
let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
||||
.first { $0.accessibilityLabel == "Add profile photo" })
|
||||
XCTAssertEqual(nav.modalPresentationStyle, .overFullScreen)
|
||||
XCTAssertTrue(nav.transitioningDelegate === coordinator)
|
||||
XCTAssertNil(profile.navigationItem.rightBarButtonItem)
|
||||
let name = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UITextField }.first)
|
||||
let submit = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton }
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/hosted/hosted_flow.dart';
|
||||
import '../../shared/hosted/hosted_environment.dart';
|
||||
import '../../shared/theme/grid.dart';
|
||||
import '../../shared/community/community_grid.dart';
|
||||
import '../../shared/community/community_grid_option.dart';
|
||||
@@ -63,7 +64,10 @@ class HostedCommunitySelection extends HookConsumerWidget {
|
||||
for (final community in flow.communities)
|
||||
CommunityGridOption(
|
||||
id: community.relayUrl,
|
||||
name: community.name,
|
||||
name: HostedEnvironment.communityDisplayName(
|
||||
community.name,
|
||||
community.relayUrl,
|
||||
),
|
||||
relayUrl: community.relayUrl,
|
||||
onSelect: (_) async {
|
||||
flow.chooseLandingCommunity(community.relayUrl);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/widgets/onboarding_route.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -31,26 +32,20 @@ class HostedConnectPage extends HookConsumerWidget {
|
||||
final stepsNavigator = GlobalKey<NavigatorState>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!active || !context.mounted) return;
|
||||
route = PageRouteBuilder<void>(
|
||||
route = onboardingRoute<void>(
|
||||
context,
|
||||
settings: const RouteSettings(name: 'hosted-setup'),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => PopScope(
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) stepsNavigator.currentState?.maybePop();
|
||||
},
|
||||
child: Navigator(
|
||||
key: stepsNavigator,
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
builder: (_) => _HostedSteps(
|
||||
flow: flow,
|
||||
onAddCommunity: onAddCommunity,
|
||||
onClose: () => latest.value.onClose(),
|
||||
profileBuilder: (context) =>
|
||||
latest.value.profileBuilder(context),
|
||||
),
|
||||
),
|
||||
child: _HostedSteps(
|
||||
navigatorKey: stepsNavigator,
|
||||
flow: flow,
|
||||
onAddCommunity: onAddCommunity,
|
||||
onClose: () => latest.value.onClose(),
|
||||
profileBuilder: (context) => latest.value.profileBuilder(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -80,11 +75,13 @@ class HostedConnectPage extends HookConsumerWidget {
|
||||
|
||||
class _HostedSteps extends HookWidget {
|
||||
const _HostedSteps({
|
||||
required this.navigatorKey,
|
||||
required this.flow,
|
||||
this.onAddCommunity,
|
||||
required this.onClose,
|
||||
required this.profileBuilder,
|
||||
});
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
final HostedFlow flow;
|
||||
final VoidCallback? onAddCommunity;
|
||||
final VoidCallback onClose;
|
||||
@@ -93,63 +90,53 @@ class _HostedSteps extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
useListenable(flow);
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final presentedStage = flow.stage;
|
||||
final presentation = Object();
|
||||
final activePresentation = useRef<Object?>(null)..value = presentation;
|
||||
// Keep an opaque surface behind the native profile presentation and its
|
||||
// dismissal. Completion retires the route only after the flow settles.
|
||||
if (flow.stage == HostedFlowStage.profile) {
|
||||
return ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: profileBuilder(context),
|
||||
);
|
||||
// Native profile owns its entry animation; keep its underlying page stable.
|
||||
final pageStage = useRef(flow.stage);
|
||||
if (flow.stage != HostedFlowStage.profile) pageStage.value = flow.stage;
|
||||
void goBack() {
|
||||
if (activePresentation.value != presentation ||
|
||||
flow.stage != presentedStage) {
|
||||
return;
|
||||
}
|
||||
// Nested PopScopes can observe the same rejected system pop. Retire
|
||||
// this presentation before dispatching its one back/close action.
|
||||
activePresentation.value = null;
|
||||
if (flow.canChangeCommunities) {
|
||||
flow.backToCommunityChoices();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
return ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: AnimatedSwitcher(
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 250),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
ExcludeFocus(
|
||||
child: ExcludeSemantics(child: IgnorePointer(child: child)),
|
||||
),
|
||||
?current,
|
||||
],
|
||||
|
||||
return Navigator(
|
||||
key: navigatorKey,
|
||||
onDidRemovePage: (_) {},
|
||||
pages: [
|
||||
OnboardingPage<void>(
|
||||
key: ValueKey(pageStage.value),
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) goBack();
|
||||
},
|
||||
child: flow.stage == HostedFlowStage.profile
|
||||
? ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: profileBuilder(context),
|
||||
)
|
||||
: HostedConnectSheet(
|
||||
flow: flow,
|
||||
onAddCommunity: onAddCommunity,
|
||||
presentedStage: presentedStage,
|
||||
fullScreen: true,
|
||||
onBack: goBack,
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (child, animation) => SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
child: HostedConnectSheet(
|
||||
key: ValueKey(flow.stage),
|
||||
flow: flow,
|
||||
onAddCommunity: onAddCommunity,
|
||||
presentedStage: flow.stage,
|
||||
fullScreen: true,
|
||||
onBack: () {
|
||||
// Outgoing pages remain mounted until their transition finishes.
|
||||
if (activePresentation.value != presentation ||
|
||||
flow.stage != presentedStage) {
|
||||
return;
|
||||
}
|
||||
if (flow.canChangeCommunities) {
|
||||
flow.backToCommunityChoices();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,12 +224,12 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
child: const Text('Keep saved identity and skip'),
|
||||
),
|
||||
if (flow.canSkipFailedCommunity)
|
||||
OutlinedButton(
|
||||
TextButton(
|
||||
onPressed: flow.skipFailedCommunity,
|
||||
child: const Text('Skip this community'),
|
||||
),
|
||||
if (flow.canChangeCommunities)
|
||||
OutlinedButton(
|
||||
TextButton(
|
||||
onPressed: flow.backToCommunityChoices,
|
||||
child: const Text('Back to choices'),
|
||||
),
|
||||
|
||||
89
mobile/lib/features/onboarding/saved_identity_card.dart
Normal file
89
mobile/lib/features/onboarding/saved_identity_card.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/hosted/device_identity_source.dart';
|
||||
import '../../shared/hosted/saved_identity_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/theme/buzz_icons.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
|
||||
/// An explicit saved-key choice using available cached profile metadata.
|
||||
class SavedIdentityCard extends ConsumerWidget {
|
||||
const SavedIdentityCard({
|
||||
super.key,
|
||||
required this.identity,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final DeviceIdentityChoice identity;
|
||||
final VoidCallback? onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cachedProfile = ref.watch(
|
||||
userCacheProvider.select((profiles) => profiles[identity.pubkey]),
|
||||
);
|
||||
final profile =
|
||||
cachedProfile ??
|
||||
ref.watch(savedIdentityProfileProvider(identity.pubkey)).asData?.value;
|
||||
final profileName = profile?.displayName;
|
||||
final name = profileName != null && profileName.trim().isNotEmpty
|
||||
? profileName
|
||||
: 'Saved identity';
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: onSelect != null,
|
||||
label: 'Use $name, ${shortPubkey(identity.pubkey)}',
|
||||
onTap: onSelect,
|
||||
excludeSemantics: true,
|
||||
child: OutlinedButton(
|
||||
onPressed: onSelect,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
),
|
||||
foregroundColor: context.colors.onSurface,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarImage(
|
||||
imageUrl: profile?.avatarUrl,
|
||||
radius: 24,
|
||||
fallback: Center(
|
||||
child: Text(
|
||||
name.isEmpty ? '?' : name.characters.first.toUpperCase(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: context.textTheme.titleSmall?.copyWith(height: 1.5),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
shortPubkey(identity.pubkey),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
const Icon(BuzzIcons.chevronRight, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import '../../shared/hosted/self_hosted_entry.dart';
|
||||
import '../../shared/identity_transfer/nsec_import_sheet.dart';
|
||||
import '../../shared/identity_transfer/native_community_entry.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../../shared/hosted/hosted_environment.dart';
|
||||
import 'saved_identity_card.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/onboarding_step_page.dart';
|
||||
import '../../shared/widgets/onboarding_loading_page.dart';
|
||||
import '../../shared/widgets/buzz_titled_sheet_layout.dart';
|
||||
|
||||
/// Local preparation seam; the production service validates and retains keys.
|
||||
@@ -54,7 +56,10 @@ Future<bool> showSelfHostedEntry(
|
||||
if (!context.mounted) return false;
|
||||
if (choices != null) {
|
||||
final result = await NativeCommunityEntry.show(
|
||||
name: name.isEmpty ? Uri.parse(relay).host : name,
|
||||
name: HostedEnvironment.communityDisplayName(
|
||||
name.isEmpty ? Uri.parse(relay).host : name,
|
||||
relay,
|
||||
),
|
||||
dark: dark,
|
||||
primaryArgb: primary,
|
||||
choices: choices,
|
||||
@@ -187,8 +192,8 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
)
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: context.colors.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
@@ -199,6 +204,74 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final saved = choices.data?.choices ?? const <DeviceIdentityChoice>[];
|
||||
final identityTitle = saved.isEmpty
|
||||
? 'Choose an identity'
|
||||
: saved.length == 1
|
||||
? 'You already have a saved identity'
|
||||
: 'You already have saved identities';
|
||||
final destination = HostedEnvironment.communityDisplayName(
|
||||
name.isEmpty ? Uri.parse(relay).host : name,
|
||||
relay,
|
||||
);
|
||||
final identitySubtitle =
|
||||
'Join $destination. ${saved.length == 1 ? 'Use this identity or import a Nostr key.' : 'Choose an identity or import a Nostr key.'}';
|
||||
final identityCards = <Widget>[
|
||||
for (final identity in saved)
|
||||
SavedIdentityCard(
|
||||
identity: identity,
|
||||
onSelect: busy.value ? null : () => select(pubkey: identity.pubkey),
|
||||
),
|
||||
];
|
||||
final joinActions = <Widget>[
|
||||
if (choices.connectionState == ConnectionState.done) ...[
|
||||
button(
|
||||
onPressed: busy.value
|
||||
? null
|
||||
: () async {
|
||||
if (fullScreen) {
|
||||
final imported = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (importContext) => NsecImportSheet(
|
||||
fullScreen: true,
|
||||
onBack: () {
|
||||
generation.value++;
|
||||
busy.value = false;
|
||||
error.value = null;
|
||||
Navigator.of(importContext).pop(false);
|
||||
},
|
||||
onImport: (nsec) => connect(nsec: nsec, close: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (imported == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} else {
|
||||
importFocusReady.value = false;
|
||||
importing.value = true;
|
||||
}
|
||||
},
|
||||
child: const Text('Import a Nostr key'),
|
||||
),
|
||||
if (saved.isEmpty && choices.data?.complete == true)
|
||||
button(
|
||||
primary: true,
|
||||
onPressed: busy.value ? null : () => select(create: true),
|
||||
child: const Text('Create an identity and join'),
|
||||
),
|
||||
if (saved.isEmpty && choices.data?.complete == true)
|
||||
const Text(
|
||||
'Export a backup in Settings after joining. This device will hold your only copy until you back it up.',
|
||||
),
|
||||
],
|
||||
if (busy.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Text('Joining…'),
|
||||
),
|
||||
if (error.value != null)
|
||||
Text(error.value!, style: TextStyle(color: context.colors.error)),
|
||||
];
|
||||
final joinContent = Padding(
|
||||
padding: fullScreen ? EdgeInsets.zero : const EdgeInsets.all(Grid.gutter),
|
||||
child: SingleChildScrollView(
|
||||
@@ -206,103 +279,51 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.theme.brightness == Brightness.dark
|
||||
? const Color(0xFF233039)
|
||||
: Colors.white.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name.isEmpty ? Uri.parse(relay).host : name,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
if (choices.hasError || choices.data?.complete == false)
|
||||
const Text(
|
||||
'Some saved identities could not be read. You can use an available identity or import a key.',
|
||||
),
|
||||
for (final identity in saved)
|
||||
button(
|
||||
onPressed: busy.value
|
||||
? null
|
||||
: () => select(pubkey: identity.pubkey),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(identity.label),
|
||||
Text(fullNpub(identity.pubkey) ?? identity.pubkey),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (choices.connectionState == ConnectionState.done) ...[
|
||||
button(
|
||||
onPressed: busy.value
|
||||
? null
|
||||
: () async {
|
||||
if (fullScreen) {
|
||||
final imported = await Navigator.of(context)
|
||||
.push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (importContext) => NsecImportSheet(
|
||||
fullScreen: true,
|
||||
onBack: () {
|
||||
generation.value++;
|
||||
busy.value = false;
|
||||
error.value = null;
|
||||
Navigator.of(importContext).pop(false);
|
||||
},
|
||||
onImport: (nsec) =>
|
||||
connect(nsec: nsec, close: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (imported == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} else {
|
||||
importFocusReady.value = false;
|
||||
importing.value = true;
|
||||
}
|
||||
},
|
||||
child: const Text('Import a Nostr key'),
|
||||
),
|
||||
if (saved.isEmpty && choices.data?.complete == true)
|
||||
button(
|
||||
primary: true,
|
||||
onPressed: busy.value ? null : () => select(create: true),
|
||||
child: const Text('Create an identity and join'),
|
||||
),
|
||||
if (saved.isEmpty && choices.data?.complete == true)
|
||||
const Text(
|
||||
'Export a backup in Settings after joining. This device will hold your only copy until you back it up.',
|
||||
),
|
||||
if (!fullScreen) ...[
|
||||
Text(identitySubtitle, style: context.textTheme.bodyMedium),
|
||||
const SizedBox(height: Grid.sm),
|
||||
],
|
||||
// The full-screen page already provides 16 px below its subtitle.
|
||||
if (fullScreen && identityCards.isNotEmpty)
|
||||
const SizedBox(height: Grid.xxs),
|
||||
for (var i = 0; i < identityCards.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: Grid.xxs),
|
||||
identityCards[i],
|
||||
],
|
||||
if (!fullScreen) ...[
|
||||
if (identityCards.isNotEmpty) const SizedBox(height: Grid.xxs),
|
||||
for (var i = 0; i < joinActions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: Grid.xxs),
|
||||
joinActions[i],
|
||||
],
|
||||
],
|
||||
if (busy.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Text('Joining…'),
|
||||
),
|
||||
if (error.value != null)
|
||||
Text(error.value!, style: TextStyle(color: context.colors.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (fullScreen && busy.value) {
|
||||
return OnboardingLoadingPage(
|
||||
title: 'Getting ready',
|
||||
onCancel: () {
|
||||
generation.value++;
|
||||
busy.value = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (fullScreen) {
|
||||
return OnboardingStepPage(
|
||||
title: 'Join community',
|
||||
subtitle: 'Select an identity to use in this community.',
|
||||
title: identityTitle,
|
||||
subtitle: identitySubtitle,
|
||||
onBack: () {
|
||||
generation.value++;
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
content: joinContent,
|
||||
actions: joinActions,
|
||||
);
|
||||
}
|
||||
return AnimatedSize(
|
||||
@@ -315,7 +336,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget {
|
||||
if (context.mounted && importing.value) importFocusReady.value = true;
|
||||
},
|
||||
child: BuzzTitledSheetLayout(
|
||||
title: importing.value ? 'Import identity' : 'Join community',
|
||||
title: importing.value ? 'Import identity' : 'Choose an identity',
|
||||
onClose: importing.value
|
||||
? () {
|
||||
generation.value++;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'community_link_focus.dart';
|
||||
import '../../shared/widgets/ios_community_link_input.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/widgets/onboarding_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
@@ -80,27 +81,20 @@ class AddCommunityPage extends StatelessWidget {
|
||||
errorMessage: errorMessage,
|
||||
onInputChanged: onInputChanged,
|
||||
);
|
||||
return AnimatedSwitcher(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 250),
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (final child in previous)
|
||||
ExcludeFocus(
|
||||
child: ExcludeSemantics(child: IgnorePointer(child: child)),
|
||||
),
|
||||
?current,
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) onBack();
|
||||
},
|
||||
child: Navigator(
|
||||
onDidRemovePage: (_) {},
|
||||
pages: [
|
||||
OnboardingPage<void>(
|
||||
key: ValueKey(step == 'pair' ? 'pair' : 'connection'),
|
||||
child: page,
|
||||
),
|
||||
],
|
||||
),
|
||||
transitionBuilder: (child, animation) => SlideTransition(
|
||||
position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
child: page,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class _AddCommunityConnection extends HookWidget {
|
||||
key: const Key('add-community-link-action'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Color.lerp(
|
||||
context.colors.surface,
|
||||
Colors.transparent,
|
||||
context.colors.primary,
|
||||
progress,
|
||||
),
|
||||
@@ -172,9 +172,7 @@ class _AddCommunityConnection extends HookWidget {
|
||||
context.colors.onPrimary,
|
||||
progress,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: context.colors.outline.withValues(alpha: 1 - progress),
|
||||
),
|
||||
side: BorderSide.none,
|
||||
),
|
||||
onPressed: busy || (enteringLink && !canSubmit)
|
||||
? null
|
||||
@@ -196,7 +194,7 @@ class _AddCommunityConnection extends HookWidget {
|
||||
),
|
||||
),
|
||||
if (pairingVisible.value && !enteringLink)
|
||||
OutlinedButton(
|
||||
TextButton(
|
||||
onPressed: busy ? null : onPairing,
|
||||
child: const Text('Use a pairing code'),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,8 @@ import '../../shared/theme/theme.dart';
|
||||
// ignore: unused_import
|
||||
import 'profile_avatar_sheet_app.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/onboarding_step_page.dart';
|
||||
import '../../shared/widgets/onboarding_route.dart';
|
||||
import 'profile_avatar_draft.dart';
|
||||
import '../../shared/widgets/playing_avatar_image.dart';
|
||||
import 'profile_setup_avatar_editor.dart';
|
||||
@@ -27,7 +29,8 @@ class ProfileSetupPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
useListenable(flow);
|
||||
final theme = Theme.of(context);
|
||||
final appearance = <String, Object>{
|
||||
'dark': theme.brightness == Brightness.dark,
|
||||
'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(),
|
||||
@@ -88,25 +91,24 @@ class ProfileSetupPage extends HookConsumerWidget {
|
||||
|
||||
Future<void> fallback() async {
|
||||
if (!current()) return;
|
||||
await showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
title: 'Set up your profile',
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
showCloseButton: false,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) {
|
||||
profileRoute = ModalRoute.of(sheetContext);
|
||||
return _ProfileSetupForm(
|
||||
avatar: avatar,
|
||||
onName: (value) => name = value,
|
||||
editAvatar: editAvatar,
|
||||
save: save,
|
||||
notNow: flow.finishProfile,
|
||||
);
|
||||
},
|
||||
await Navigator.of(context, rootNavigator: true).push<void>(
|
||||
onboardingRoute<void>(
|
||||
context,
|
||||
builder: (sheetContext) {
|
||||
profileRoute = ModalRoute.of(sheetContext);
|
||||
return OnboardingStepPage(
|
||||
title: 'Set up your profile',
|
||||
onBack: null,
|
||||
content: _ProfileSetupForm(
|
||||
avatar: avatar,
|
||||
onName: (value) => name = value,
|
||||
editAvatar: editAvatar,
|
||||
save: save,
|
||||
notNow: flow.finishProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!current()) return;
|
||||
profileRoute = null;
|
||||
@@ -215,7 +217,7 @@ class ProfileSetupPage extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [flow]);
|
||||
}, [flow, flow.stage == HostedFlowStage.profile]);
|
||||
useEffect(() {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
||||
@@ -94,7 +94,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
: flow.noCommunitiesToAdd
|
||||
? 'No communities available to add. Scan a QR code or use an invite link.'
|
||||
: connected
|
||||
? 'Connected to Builderlab.'
|
||||
? 'Scan a QR code or enter a community link.'
|
||||
: null;
|
||||
final handlingBack = useRef(false);
|
||||
void back() {
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'account_identity_store.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../relay/relay_provider.dart';
|
||||
|
||||
/// Public identity choices; never contains private signing material.
|
||||
class DeviceIdentityChoice {
|
||||
@@ -26,6 +28,40 @@ class DeviceIdentitySource {
|
||||
DeviceIdentityChoice(entry.key, entry.value.label),
|
||||
];
|
||||
|
||||
/// Read profile metadata only from communities already saved for this key.
|
||||
/// A failed/offline community does not prevent trying another saved source.
|
||||
Future<UserProfile?> loadProfile(
|
||||
String pubkey,
|
||||
Future<UserProfile?> Function(RelayConfig config) fetch,
|
||||
) async {
|
||||
final seen = <String>{};
|
||||
for (final key in ['buzz_communities', 'buzz_workspaces']) {
|
||||
final raw = await _storage.read(key: key);
|
||||
if (raw == null) continue;
|
||||
for (final record in jsonDecode(raw) as List) {
|
||||
try {
|
||||
final value = record as Map<String, dynamic>;
|
||||
final nsec = value['nsec'] as String?;
|
||||
final relay = value['relayUrl'] as String?;
|
||||
if (nsec == null ||
|
||||
relay == null ||
|
||||
AccountIdentityStore.publicKeyForNsec(nsec) != pubkey ||
|
||||
(value['pubkey'] != null && value['pubkey'] != pubkey)) {
|
||||
continue;
|
||||
}
|
||||
final config = RelayConfig(baseUrl: relay, nsec: nsec);
|
||||
if (!seen.add(config.baseUrl)) continue;
|
||||
if (seen.length > 3) return null;
|
||||
final profile = await fetch(config);
|
||||
if (profile != null) return profile;
|
||||
} catch (_) {
|
||||
// Metadata is optional; never alter keys or block identity selection.
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Reread and validate the chosen key immediately before create-only adoption.
|
||||
Future<AccountIdentitySnapshot> adopt(
|
||||
String pubkey,
|
||||
|
||||
@@ -10,6 +10,23 @@ abstract final class HostedEnvironment {
|
||||
'https://app.builderlabstaging.xyz',
|
||||
};
|
||||
|
||||
/// Shortens hostname fallbacks for display without changing relay identity.
|
||||
static String communityDisplayName(String name, String relayUrl) {
|
||||
final relay = Uri.tryParse(relayUrl);
|
||||
if (relay == null ||
|
||||
name != relay.host ||
|
||||
accountOriginFor(relay) == null) {
|
||||
return name;
|
||||
}
|
||||
for (final domain in _communityAccounts.keys) {
|
||||
final suffix = '.$domain';
|
||||
if (name.endsWith(suffix)) {
|
||||
return name.substring(0, name.length - suffix.length);
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Creation and admission use the same trusted build-specific host mapping.
|
||||
static String get communitySuffix => _communityAccounts.entries
|
||||
.singleWhere((entry) => Uri.parse(entry.value) == origin)
|
||||
|
||||
@@ -59,6 +59,22 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
HostedEnvironment.requiresAuth(Uri.parse(config.baseUrl).host) &&
|
||||
HostedEnvironment.supportsRelay(Uri.parse(config.baseUrl));
|
||||
|
||||
/// Headers for optional reads, without starting account work or changing
|
||||
/// recovery state. Null means protected metadata must wait for active auth.
|
||||
Map<String, String>? cachedHeaders(RelayConfig config) {
|
||||
if (_disposed) return null;
|
||||
if (!protects(config)) return const {};
|
||||
final cached = _cached;
|
||||
if (cached == null ||
|
||||
cached.relay != Uri.parse(config.wsUrl).replace(path: '').toString() ||
|
||||
cached.pubkey != pubkeyFromNsec(config.nsec) ||
|
||||
!identical(cached.account, account.account) ||
|
||||
!cached.expiry.isAfter(_now())) {
|
||||
return null;
|
||||
}
|
||||
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
|
||||
}
|
||||
|
||||
Future<Map<String, String>> headers(RelayConfig config) async {
|
||||
if (!protects(config)) return const {};
|
||||
final relay = Uri.parse(config.wsUrl).replace(path: '').toString();
|
||||
|
||||
176
mobile/lib/shared/hosted/saved_identity_profile.dart
Normal file
176
mobile/lib/shared/hosted/saved_identity_profile.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../crypto/event_signing.dart';
|
||||
import '../profile/profile_event_parser.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../features/age_gate/age_signal_provider.dart';
|
||||
import '../relay/relay_query_response.dart';
|
||||
import '../relay/relay_session.dart';
|
||||
import 'device_identity_source.dart';
|
||||
import 'hosted_relay_auth.dart';
|
||||
|
||||
final savedIdentityProfileClientProvider = Provider<http.Client Function()>(
|
||||
(ref) => http.Client.new,
|
||||
);
|
||||
|
||||
final _profileQueueProvider = Provider((ref) => _ProfileQueue());
|
||||
|
||||
/// Fetches the saved identity's own profile without changing the active session.
|
||||
final savedIdentityProfileProvider = FutureProvider.autoDispose
|
||||
.family<UserProfile?, String>((ref, pubkey) async {
|
||||
if (ref.watch(ageSignalProvider) == AgeSignalState.restricted) {
|
||||
return null;
|
||||
}
|
||||
final auth = ref.read(hostedRelayAuthProvider);
|
||||
final createTransport = ref.read(savedIdentityProfileClientProvider);
|
||||
final slot = ref.read(_profileQueueProvider).request();
|
||||
http.Client? transport;
|
||||
StreamIterator<List<int>>? chunks;
|
||||
var active = true;
|
||||
ref.onDispose(() {
|
||||
active = false;
|
||||
slot.cancelQueued();
|
||||
if (chunks != null) unawaited(chunks!.cancel());
|
||||
transport?.close();
|
||||
});
|
||||
if (!await slot.ready.future) return null;
|
||||
try {
|
||||
if (!active) return null;
|
||||
return await DeviceIdentitySource().loadProfile(pubkey, (config) async {
|
||||
if (!active) return null;
|
||||
// Passive cards must never queue account restore/identity/assertion
|
||||
// work ahead of reconnect or admission, even while still visible.
|
||||
final headers = auth.cachedHeaders(config);
|
||||
if (headers == null) return null;
|
||||
final uri = Uri.parse(config.baseUrl).resolve('/query');
|
||||
final body = utf8.encode(
|
||||
jsonEncode([
|
||||
{
|
||||
'kinds': [0],
|
||||
'authors': [pubkey],
|
||||
'limit': 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
final attemptTransport = createTransport();
|
||||
transport = attemptTransport;
|
||||
var requesting = true;
|
||||
chunks = null;
|
||||
final client = attemptTransport;
|
||||
final request = http.Request('POST', uri)
|
||||
..followRedirects = false
|
||||
..bodyBytes = body
|
||||
..headers.addAll({
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: uri.toString(),
|
||||
bodyBytes: body,
|
||||
nsec: config.nsec,
|
||||
pubkey: pubkey,
|
||||
),
|
||||
});
|
||||
try {
|
||||
final response = await (() async {
|
||||
final response = await client.send(request);
|
||||
if (!active || !requesting) return null;
|
||||
chunks = StreamIterator(response.stream);
|
||||
// One kind-0 event should fit comfortably within 256 KiB.
|
||||
const maxBytes = 256 * 1024;
|
||||
if (response.statusCode != 200 ||
|
||||
(response.contentLength ?? 0) > maxBytes) {
|
||||
return null;
|
||||
}
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
while (await chunks!.moveNext()) {
|
||||
if (!active || !requesting) return null;
|
||||
final chunk = chunks!.current;
|
||||
if (chunk.length > maxBytes - bytes.length) return null;
|
||||
bytes.add(chunk);
|
||||
}
|
||||
if (!active || !requesting) return null;
|
||||
return (bytes.takeBytes(), response.headers['content-type']);
|
||||
})().timeout(const Duration(seconds: 5));
|
||||
if (!active || response == null) return null;
|
||||
// Retain the queue slot until bounded decoding actually finishes.
|
||||
return await _decodeProfile(response.$1, response.$2, pubkey);
|
||||
} finally {
|
||||
// Retire this attempt before another relay can be tried.
|
||||
requesting = false;
|
||||
if (chunks != null) unawaited(chunks!.cancel());
|
||||
client.close();
|
||||
transport = null;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
});
|
||||
|
||||
Future<UserProfile?> _decodeProfile(
|
||||
Uint8List bytes,
|
||||
String? contentType,
|
||||
String pubkey,
|
||||
) => Isolate.run(() {
|
||||
// The transport normally inflates HTTP gzip before the byte cap. Reject
|
||||
// raw gzip here so the shared parser cannot expand a second, uncapped body.
|
||||
if (bytes.length > 2 && bytes[0] == 0x1f && bytes[1] == 0x8b) return null;
|
||||
final events = parseRelayQueryEvents(bytes, contentType);
|
||||
for (final event in events) {
|
||||
if (event.kind != 0 || event.pubkey != pubkey) continue;
|
||||
verifiedNostrEventFromJson(jsonEncode(event.toJson()));
|
||||
return parseProfileEvent(event).profile;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Shared across identities in one provider container. Cancelled queued cards
|
||||
// never reach secure storage, HTTP, or decoding.
|
||||
class _ProfileQueue {
|
||||
final _waiting = Queue<_ProfileSlot>();
|
||||
int _running = 0;
|
||||
|
||||
_ProfileSlot request() {
|
||||
final slot = _ProfileSlot(this);
|
||||
_waiting.add(slot);
|
||||
_drain();
|
||||
return slot;
|
||||
}
|
||||
|
||||
void _drain() {
|
||||
while (_running < 3 && _waiting.isNotEmpty) {
|
||||
final slot = _waiting.removeFirst();
|
||||
_running++;
|
||||
slot.ready.complete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileSlot {
|
||||
_ProfileSlot(this.queue);
|
||||
final _ProfileQueue queue;
|
||||
final ready = Completer<bool>();
|
||||
bool _released = false;
|
||||
|
||||
void cancelQueued() {
|
||||
if (ready.isCompleted) return;
|
||||
queue._waiting.remove(this);
|
||||
ready.complete(false);
|
||||
_released = true;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_released) return;
|
||||
_released = true;
|
||||
queue._running--;
|
||||
queue._drain();
|
||||
}
|
||||
}
|
||||
@@ -154,8 +154,8 @@ class NsecImportSheet extends HookWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -59,6 +60,9 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
this.requestTimeout = const Duration(seconds: 30),
|
||||
});
|
||||
|
||||
/// Maximum encoded image body retained before decoding (20 MiB).
|
||||
static const maxResponseBytes = 20 * 1024 * 1024;
|
||||
|
||||
static const _defaultCooldown = Duration(seconds: 30);
|
||||
static const _maxRetryAfter = Duration(minutes: 5);
|
||||
static final Map<String, DateTime> _cooldownUntil = {};
|
||||
@@ -105,15 +109,32 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
try {
|
||||
final abort = Completer<void>();
|
||||
final deadline = Timer(requestTimeout, abort.complete);
|
||||
StreamIterator<List<int>>? body;
|
||||
try {
|
||||
final request = http.AbortableRequest(
|
||||
'GET',
|
||||
uri,
|
||||
abortTrigger: abort.future,
|
||||
)..headers.addAll(auth.headersFor(url));
|
||||
response = await http.Response.fromStream(await client.send(request));
|
||||
final streamed = await client.send(request);
|
||||
final iterator = body = StreamIterator(streamed.stream);
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
while (await iterator.moveNext()) {
|
||||
final chunk = iterator.current;
|
||||
if (chunk.length > maxResponseBytes - bytes.length) {
|
||||
throw const FormatException('Image response too large');
|
||||
}
|
||||
bytes.add(chunk);
|
||||
}
|
||||
response = http.Response.bytes(
|
||||
bytes.takeBytes(),
|
||||
streamed.statusCode,
|
||||
headers: streamed.headers,
|
||||
);
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
if (!abort.isCompleted) abort.complete();
|
||||
unawaited(body?.cancel());
|
||||
}
|
||||
} catch (_) {
|
||||
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
||||
|
||||
@@ -13,7 +13,7 @@ class OnboardingLoadingPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
final theme = Theme.of(context);
|
||||
final safe = MediaQuery.paddingOf(context);
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
|
||||
56
mobile/lib/shared/widgets/onboarding_route.dart
Normal file
56
mobile/lib/shared/widgets/onboarding_route.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A standard onboarding route that skips motion when the OS requests it.
|
||||
Route<T> onboardingRoute<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
RouteSettings? settings,
|
||||
}) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
return PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (context, _, _) => Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MaterialPageRoute<T>(settings: settings, builder: builder);
|
||||
}
|
||||
|
||||
/// Declarative counterpart of [onboardingRoute] for nested onboarding steps.
|
||||
class OnboardingPage<T> extends MaterialPage<T> {
|
||||
const OnboardingPage({super.key, required super.child});
|
||||
|
||||
@override
|
||||
Route<T> createRoute(BuildContext context) {
|
||||
if (!MediaQuery.disableAnimationsOf(context)) {
|
||||
return super.createRoute(context);
|
||||
}
|
||||
return _InstantOnboardingRoute<T>(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstantOnboardingRoute<T> extends PageRouteBuilder<T> {
|
||||
_InstantOnboardingRoute(OnboardingPage<T> page)
|
||||
: super(
|
||||
settings: page,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) => Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: (settings as OnboardingPage<T>).child,
|
||||
);
|
||||
}
|
||||
@@ -27,28 +27,35 @@ class OnboardingStepPage extends StatelessWidget {
|
||||
final VoidCallback? onConnectionOptions;
|
||||
final double foregroundProgress;
|
||||
final double keyboardBottomSpacing;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onBack;
|
||||
final Widget content;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = utilitySurfaceThemeData(Theme.of(context));
|
||||
final theme = Theme.of(context);
|
||||
final ios = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final buttonStyle = FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
);
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
filledButtonTheme: FilledButtonThemeData(style: buttonStyle),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(style: buttonStyle),
|
||||
textButtonTheme: TextButtonThemeData(style: buttonStyle),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) onBack();
|
||||
if (!didPop) onBack?.call();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
@@ -89,14 +96,16 @@ class OnboardingStepPage extends StatelessWidget {
|
||||
).compactHeight,
|
||||
child: IosNavigationBar(
|
||||
title: '',
|
||||
leading: IosNavigationAction(
|
||||
label: 'Back',
|
||||
symbol: 'chevron.backward',
|
||||
onPressed: onBack,
|
||||
),
|
||||
leading: onBack == null
|
||||
? null
|
||||
: IosNavigationAction(
|
||||
label: 'Back',
|
||||
symbol: 'chevron.backward',
|
||||
onPressed: onBack,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
else if (onBack != null)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Padding(
|
||||
|
||||
@@ -165,7 +165,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('community-switcher-add')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
expect(find.byTooltip('Back').hitTestable(), findsNothing);
|
||||
expect(find.byType(BeeLoadingMark), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pump();
|
||||
@@ -230,11 +230,16 @@ void main() {
|
||||
fixture.beforeAdmission = (_) => resumedAdmission.future;
|
||||
await tester.tap(find.byType(CommunityGridOption).last);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
await tester.pump(
|
||||
ModalRoute.of(
|
||||
tester.element(find.text('Joining your community')),
|
||||
)!.transitionDuration,
|
||||
);
|
||||
expect(find.text('Home content'), findsNothing);
|
||||
expect(find.text('Joining your community'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
expect(find.byTooltip('Back').hitTestable(), findsNothing);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(BeeLoadingMark)).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Joining your community')).dy),
|
||||
|
||||
@@ -316,7 +316,7 @@ void main() {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CommunityGridOption),
|
||||
matching: find.text('two.communities.buzz.xyz'),
|
||||
matching: find.text('two'),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:buzz/features/onboarding/hosted_community_selection.dart';
|
||||
import 'package:buzz/shared/community/community_grid_option.dart';
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/hosted_communities_fixture.dart';
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('picker shortens hosted fallback labels and preserves titles', (
|
||||
tester,
|
||||
) async {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
final fixture = HostedCommunitiesFixture();
|
||||
await fixture.initialize(widget: true);
|
||||
addTearDown(fixture.dispose);
|
||||
|
||||
for (final (host, name, expected) in [
|
||||
('mobile.communities.buzz.xyz', 'mobile.communities.buzz.xyz', 'mobile'),
|
||||
(
|
||||
'mobile.communities.public.builderlabstaging.xyz',
|
||||
'mobile.communities.public.builderlabstaging.xyz',
|
||||
'mobile',
|
||||
),
|
||||
('mobile.communities.buzz.xyz', 'Mobile Team', 'Mobile Team'),
|
||||
('chat.example.com', 'chat.example.com', 'chat.example.com'),
|
||||
(
|
||||
'mobile.communities.buzz.xyz.example.com',
|
||||
'mobile.communities.buzz.xyz.example.com',
|
||||
'mobile.communities.buzz.xyz.example.com',
|
||||
),
|
||||
('communities.buzz.xyz', 'communities.buzz.xyz', 'communities.buzz.xyz'),
|
||||
]) {
|
||||
final relay = 'wss://$host';
|
||||
fixture.flow.communities = [
|
||||
AddCommunityDeepLink(relayUrl: relay, name: name),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: HostedCommunitySelection(flow: fixture.flow, fullScreen: true),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(expected), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(expected), findsOneWidget);
|
||||
final tile = tester.widget<CommunityGridOption>(
|
||||
find.byType(CommunityGridOption),
|
||||
);
|
||||
expect(tile.relayUrl, relay);
|
||||
expect(fixture.flow.communities.single.name, name);
|
||||
if (name != expected) expect(find.text(name), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
}, semanticsEnabled: true);
|
||||
}
|
||||
@@ -549,7 +549,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
try {
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
expect(find.byTooltip('Back').hitTestable(), findsNothing);
|
||||
// External teardown still fences pending enrollment.
|
||||
await f.flow.leave();
|
||||
await tester.pump();
|
||||
@@ -611,7 +611,7 @@ void main() {
|
||||
of: find.byType(TextField).hitTestable(),
|
||||
matching: find.byType(BottomSheet),
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
expect(f.sent('/claim'), hasLength(1));
|
||||
expect(f.recoveryScopes, hasLength(3));
|
||||
@@ -911,10 +911,10 @@ void main() {
|
||||
await f.tap(tester, 'Continue');
|
||||
// A refused key must not pin the URL against an explicit allowed choice.
|
||||
f.refusedSelfHostedPubkey = f.keys.public;
|
||||
await f.tap(tester, fullNpub(f.keys.public)!);
|
||||
await f.tap(tester, shortPubkey(f.keys.public));
|
||||
expect(await f.storage.loadAll(), isEmpty);
|
||||
expect(find.textContaining('Could not join.'), findsOneWidget);
|
||||
await f.tap(tester, fullNpub(f.secondKeys.public)!);
|
||||
await f.tap(tester, shortPubkey(f.secondKeys.public));
|
||||
expect((await f.storage.loadAll()).single.nsec, f.secondKeys.nsec);
|
||||
expect(
|
||||
(await AccountIdentityStore().forPairing(f.keys.public).read())!.pubkey,
|
||||
|
||||
@@ -116,7 +116,9 @@ void main() {
|
||||
);
|
||||
await tester.tap(find.text('Connect').hitTestable());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
// Finish the standard page push before checking the loading surface.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
if (HostedEnvironment.authDomains.isNotEmpty) {
|
||||
@@ -154,7 +156,7 @@ void main() {
|
||||
find.byType(CommunityGridOption).hitTestable().last,
|
||||
)
|
||||
.name,
|
||||
'two.communities.buzz.xyz',
|
||||
'two',
|
||||
);
|
||||
f.beforeAdmission = null;
|
||||
await tester.tap(find.byType(CommunityGridOption).first);
|
||||
@@ -290,7 +292,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SelfHostedEntrySheet), findsOneWidget);
|
||||
expect(find.byType(NsecImportSheet), findsNothing);
|
||||
expect(find.text('Join community'), findsOneWidget);
|
||||
expect(find.text('You already have a saved identity'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byType(SelfHostedEntrySheet)).height,
|
||||
joinHeight,
|
||||
@@ -315,7 +317,7 @@ void main() {
|
||||
// Completion races the outgoing route animation.
|
||||
pendingProbe.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Join community'), findsOneWidget);
|
||||
expect(find.text('You already have a saved identity'), findsOneWidget);
|
||||
expect(await container.read(communityStorageProvider).loadAll(), isEmpty);
|
||||
pendingProbe = null;
|
||||
await tester.tap(find.text('Import a Nostr key').hitTestable());
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:buzz/features/onboarding/hosted_connect_page.dart';
|
||||
import 'package:buzz/features/pairing/add_community_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../helpers/hosted_communities_fixture.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('reduced motion swaps Add and hosted steps instantly', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
void expectRouteScopes(Finder content, int count) {
|
||||
var scopes = 0;
|
||||
SemanticsNode? node = tester.getSemantics(content);
|
||||
while (node != null) {
|
||||
if (node.flagsCollection.scopesRoute) scopes++;
|
||||
node = node.parent;
|
||||
}
|
||||
expect(scopes, count);
|
||||
}
|
||||
|
||||
final pair = ValueNotifier(false);
|
||||
addTearDown(pair.dispose);
|
||||
Widget app(Widget home, {List<NavigatorObserver> observers = const []}) =>
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
navigatorObservers: observers,
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(disableAnimations: true),
|
||||
child: child!,
|
||||
),
|
||||
home: home,
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: pair,
|
||||
builder: (_, pairing, _) => AddCommunityPage(
|
||||
existingUser: pairing,
|
||||
onBack: () => pair.value = false,
|
||||
onCommunityLink: () {},
|
||||
onPairing: () => pair.value = true,
|
||||
onScan: () {},
|
||||
onOpenCommunity: (_) {},
|
||||
onSubmitPairing: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
pair.value = true;
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
final pairRoute = ModalRoute.of(
|
||||
tester.element(find.byType(TextField).first),
|
||||
)!;
|
||||
expect(pairRoute.animation!.isCompleted, isTrue);
|
||||
expect(pairRoute.transitionDuration, Duration.zero);
|
||||
expect(find.text('Add a community'), findsNothing);
|
||||
expectRouteScopes(find.byType(TextField).first, 2);
|
||||
pair.value = false;
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Add a community'), findsOneWidget);
|
||||
expect(find.byKey(const Key('community-pairing-input')), findsNothing);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
final f = HostedCommunitiesFixture();
|
||||
await f.initialize(widget: true);
|
||||
addTearDown(f.dispose);
|
||||
f.rows = [f.row('one'), f.row('two')];
|
||||
final observer = _Routes();
|
||||
var closes = 0;
|
||||
await tester.pumpWidget(
|
||||
app(
|
||||
HostedConnectPage(
|
||||
flow: f.flow,
|
||||
onClose: () => closes++,
|
||||
profileBuilder: (_) => const Text('Profile content'),
|
||||
),
|
||||
observers: [observer],
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
final outer =
|
||||
observer.routes.singleWhere((r) => r.settings.name == 'hosted-setup')
|
||||
as TransitionRoute;
|
||||
expect(outer.transitionDuration, Duration.zero);
|
||||
expect(outer.animation!.isCompleted, isTrue);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(closes, 1);
|
||||
await f.flow.signInAccount();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
final title = find.text('Select a community');
|
||||
expect(title, findsOneWidget);
|
||||
// The imperative hosted route and its declarative step each own a scope.
|
||||
expectRouteScopes(title, 2);
|
||||
final inner = ModalRoute.of(tester.element(title))!;
|
||||
expect(inner.transitionDuration, Duration.zero);
|
||||
expect(inner.animation!.isCompleted, isTrue);
|
||||
// Profile uses the same page key; updated child content must still rebuild.
|
||||
final profile = f.flow.requestProfile((_) async {});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Profile content'), findsOneWidget);
|
||||
f.flow.finishProfile();
|
||||
await profile;
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
semantics.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _Routes extends NavigatorObserver {
|
||||
final routes = <Route<dynamic>>[];
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
|
||||
routes.add(route);
|
||||
}
|
||||
156
mobile/test/features/onboarding/saved_identity_card_test.dart
Normal file
156
mobile/test/features/onboarding/saved_identity_card_test.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart';
|
||||
import 'package:buzz/features/onboarding/saved_identity_card.dart';
|
||||
import 'package:buzz/shared/hosted/self_hosted_entry.dart';
|
||||
import 'package:buzz/shared/hosted/device_identity_source.dart';
|
||||
import 'package:buzz/shared/hosted/saved_identity_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/utils/string_utils.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('loads name and avatar when the active profile cache is empty', (
|
||||
tester,
|
||||
) async {
|
||||
const pubkey =
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
final profile = Completer<UserProfile?>();
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
savedIdentityProfileProvider(
|
||||
pubkey,
|
||||
).overrideWith((ref) => profile.future),
|
||||
],
|
||||
child: SavedIdentityCard(
|
||||
identity: const DeviceIdentityChoice(pubkey, 'Home'),
|
||||
onSelect: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Saved identity'), findsOneWidget);
|
||||
expect(find.text('Used in Home'), findsNothing);
|
||||
profile.complete(
|
||||
const UserProfile(
|
||||
pubkey: pubkey,
|
||||
displayName: 'Kenny',
|
||||
avatarUrl: 'https://home.example/avatar.png',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Kenny'), findsOneWidget);
|
||||
expect(find.text('Saved identity'), findsNothing);
|
||||
expect(
|
||||
tester.widget<AvatarImage>(find.byType(AvatarImage)).imageUrl,
|
||||
'https://home.example/avatar.png',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('identity card below subtitle joins with the selected key', (
|
||||
tester,
|
||||
) async {
|
||||
final keys = nostr.Keys('1'.padLeft(64, '0'));
|
||||
const source = 'mobile.communities.public.builderlabstaging.xyz';
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
{'name': source, 'pubkey': keys.public, 'nsec': keys.nsec},
|
||||
]),
|
||||
});
|
||||
String? attemptedKey;
|
||||
final admission = Completer<void>();
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
selfHostedEntryProvider.overrideWithValue(
|
||||
SelfHostedEntry(
|
||||
probe: (community) async {
|
||||
attemptedKey = community.pubkey;
|
||||
await admission.future;
|
||||
throw StateError('Offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const SelfHostedEntrySheet(
|
||||
relay: 'wss://honeycombstudios.example',
|
||||
name: 'honeycombstudios.example',
|
||||
fullScreen: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining('Join honeycombstudios.example.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('You already have a saved identity'), findsOneWidget);
|
||||
expect(find.text('Used in mobile'), findsNothing);
|
||||
expect(find.text(shortPubkey(keys.public)), findsOneWidget);
|
||||
expect(find.text(fullNpub(keys.public)!), findsNothing);
|
||||
expect(
|
||||
find.bySemanticsLabel('Use Saved identity, ${shortPubkey(keys.public)}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(find.text('View identity details'), findsNothing);
|
||||
expect(
|
||||
tester.getBottomRight(find.text('Import a Nostr key')).dy,
|
||||
greaterThan(750),
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(find.byType(SavedIdentityCard)).dy -
|
||||
tester
|
||||
.getBottomLeft(
|
||||
find.textContaining('Use this identity or import a Nostr key.'),
|
||||
)
|
||||
.dy,
|
||||
24,
|
||||
);
|
||||
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(SelfHostedEntrySheet)),
|
||||
);
|
||||
container
|
||||
.read(userCacheProvider.notifier)
|
||||
.put(
|
||||
UserProfile(
|
||||
pubkey: keys.public,
|
||||
displayName: 'Alex',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Alex'), findsOneWidget);
|
||||
expect(find.text('Used in mobile'), findsNothing);
|
||||
expect(
|
||||
tester.widget<AvatarImage>(find.byType(AvatarImage)).imageUrl,
|
||||
'https://example.com/avatar.png',
|
||||
);
|
||||
await tester.tap(find.text('Alex'));
|
||||
await tester.pump();
|
||||
expect(find.text('Getting ready'), findsOneWidget);
|
||||
expect(find.text('You already have a saved identity'), findsNothing);
|
||||
admission.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(attemptedKey, keys.public);
|
||||
expect(
|
||||
find.textContaining('Your saved keys have been kept.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Import a Nostr key'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Import identity'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart';
|
||||
import 'package:buzz/features/onboarding/saved_identity_card.dart';
|
||||
import 'package:buzz/shared/hosted/saved_identity_profile.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'hosted chooser never queues passive account work',
|
||||
(tester) async {
|
||||
final keys = [
|
||||
for (var i = 1; i <= 4; i++)
|
||||
nostr.Keys(i.toRadixString(16).padLeft(64, '0')),
|
||||
];
|
||||
final relay = 'wss://saved.${HostedEnvironment.communitySuffix}';
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
for (final key in keys)
|
||||
{
|
||||
'name': 'Saved',
|
||||
'relayUrl': relay,
|
||||
'pubkey': key.public,
|
||||
'nsec': key.nsec,
|
||||
},
|
||||
]),
|
||||
});
|
||||
final release = Completer<void>();
|
||||
final started = Completer<void>();
|
||||
final paths = <String>[];
|
||||
final expiry = DateTime.now()
|
||||
.add(const Duration(hours: 1))
|
||||
.toIso8601String();
|
||||
final account = BuilderlabAccountClient(
|
||||
httpClient: MockClient((request) async {
|
||||
final path = request.url.path;
|
||||
paths.add(path);
|
||||
if (path.endsWith('/known-communities/list')) {
|
||||
started.complete();
|
||||
await release.future;
|
||||
}
|
||||
final value = path.endsWith('/login/exchange')
|
||||
? {'session_credential': 'test-session', 'expires_at': expiry}
|
||||
: path.endsWith('/auth/me')
|
||||
? {'subject': 'test-subject', 'expires_at': expiry}
|
||||
: path.endsWith('/nostr-identities/current')
|
||||
? {
|
||||
'identity': {'pubkey_hex': keys.first.public},
|
||||
}
|
||||
: path.endsWith('/identity/assertions')
|
||||
? {
|
||||
'assertion': 'test-assertion',
|
||||
'nostr_pubkey': keys.first.public,
|
||||
'expires_at':
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120,
|
||||
}
|
||||
: <String, dynamic>{};
|
||||
return http.Response(jsonEncode(value), 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(account.dispose);
|
||||
await account.exchange(code: 'code', verifier: 'v' * 43);
|
||||
paths.clear();
|
||||
final busy = account.post('/v1/buzz/known-communities/list', {});
|
||||
await started.future;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
builderlabAccountProvider.overrideWithValue(account),
|
||||
savedIdentityProfileClientProvider.overrideWithValue(
|
||||
() => MockClient((_) async {
|
||||
fail('Uncached hosted cards must not send relay requests');
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(
|
||||
home: SelfHostedEntrySheet(
|
||||
relay: 'wss://join.example',
|
||||
name: 'Join',
|
||||
fullScreen: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SavedIdentityCard), findsNWidgets(4));
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final active = account.post('/v1/buzz/known-communities/add', {});
|
||||
release.complete();
|
||||
await busy;
|
||||
await active;
|
||||
await tester.pumpAndSettle();
|
||||
expect(paths.map((path) => path.split('/').last), ['list', 'add']);
|
||||
|
||||
// Active authentication may populate the cache; passive reads use it only
|
||||
// for the same relay, key, account session, and unexpired lifetime.
|
||||
var now = DateTime.now();
|
||||
final auth = HostedRelayAuth(account, now: () => now);
|
||||
addTearDown(auth.dispose);
|
||||
final config = RelayConfig(baseUrl: relay, nsec: keys.first.nsec);
|
||||
expect(auth.cachedHeaders(config), isNull);
|
||||
final headers = await auth.headers(config);
|
||||
final count = paths.length;
|
||||
expect(auth.cachedHeaders(config), headers);
|
||||
expect(
|
||||
auth.cachedHeaders(RelayConfig(baseUrl: relay, nsec: keys.last.nsec)),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
auth.cachedHeaders(
|
||||
RelayConfig(
|
||||
baseUrl: 'wss://other.${HostedEnvironment.communitySuffix}',
|
||||
nsec: keys.first.nsec,
|
||||
),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
now = now.add(const Duration(minutes: 5));
|
||||
expect(auth.cachedHeaders(config), isNull);
|
||||
expect(paths.length, count);
|
||||
},
|
||||
skip:
|
||||
!const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH') ||
|
||||
!HostedEnvironment.enabled,
|
||||
);
|
||||
}
|
||||
111
mobile/test/features/onboarding/saved_identity_queue_test.dart
Normal file
111
mobile/test/features/onboarding/saved_identity_queue_test.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart';
|
||||
import 'package:buzz/features/onboarding/saved_identity_card.dart';
|
||||
import 'package:buzz/shared/hosted/saved_identity_profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
testWidgets('many saved identities share a cancellable lookup queue', (
|
||||
tester,
|
||||
) async {
|
||||
final keys = [
|
||||
for (var i = 1; i <= 12; i++)
|
||||
nostr.Keys(i.toRadixString(16).padLeft(64, '0')),
|
||||
];
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
for (final key in keys)
|
||||
{
|
||||
'name': 'Home',
|
||||
'relayUrl': 'wss://home.example',
|
||||
'pubkey': key.public,
|
||||
'nsec': key.nsec,
|
||||
},
|
||||
]),
|
||||
});
|
||||
final clients = <_PendingProfileClient>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
savedIdentityProfileClientProvider.overrideWithValue(() {
|
||||
final client = _PendingProfileClient();
|
||||
clients.add(client);
|
||||
return client;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
Widget chooser() => UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
home: const SelfHostedEntrySheet(
|
||||
relay: 'wss://join.example',
|
||||
name: 'Join',
|
||||
fullScreen: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(chooser());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SavedIdentityCard), findsNWidgets(12));
|
||||
expect(clients, hasLength(3));
|
||||
// Completing one lookup admits exactly one queued identity.
|
||||
clients.first.body.add(List.filled(256 * 1024 + 1, 32));
|
||||
await tester.pumpAndSettle();
|
||||
expect(clients, hasLength(4));
|
||||
expect(clients.first.closed, isTrue);
|
||||
// Leave while other requests and queued cards still exist.
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(container: container, child: const SizedBox()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(clients, hasLength(4));
|
||||
expect(
|
||||
clients.every((client) => client.closed && client.cancelled),
|
||||
isTrue,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
expect(clients, hasLength(4));
|
||||
// Reopening uses fresh work; cancelled slots cannot starve the chooser.
|
||||
await tester.pumpWidget(chooser());
|
||||
await tester.pumpAndSettle();
|
||||
expect(clients, hasLength(7));
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(container: container, child: const SizedBox()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
clients.every((client) => client.closed && client.cancelled),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _PendingProfileClient extends http.BaseClient {
|
||||
_PendingProfileClient() {
|
||||
body = StreamController<List<int>>(
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
late final StreamController<List<int>> body;
|
||||
bool closed = false;
|
||||
bool cancelled = false;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async =>
|
||||
http.StreamedResponse(body.stream, 200);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
closed = true;
|
||||
unawaited(body.close());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/features/profile/profile_setup_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -15,18 +17,39 @@ void main() {
|
||||
await fixture.initialize(widget: true);
|
||||
addTearDown(fixture.dispose);
|
||||
var attempts = 0;
|
||||
final release = Completer<void>();
|
||||
final pending = fixture.flow.requestProfile((_) async {
|
||||
attempts++;
|
||||
await release.future;
|
||||
throw StateError('Relay unavailable');
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(child: ProfileSetupPage(flow: fixture.flow)),
|
||||
WidgetHelpers.testable(
|
||||
disableAnimations: true,
|
||||
child: ProfileSetupPage(flow: fixture.flow),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
final route = ModalRoute.of(tester.element(find.byType(TextField)))!;
|
||||
expect(route.transitionDuration, Duration.zero);
|
||||
expect(route.reverseTransitionDuration, Duration.zero);
|
||||
expect(route.animation!.isCompleted, isTrue);
|
||||
expect(find.text('Not now'), findsNothing);
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(fixture.flow.stage, HostedFlowStage.profile);
|
||||
await tester.enterText(find.byType(TextField), 'Kenny');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump();
|
||||
expect(find.text('Saving…'), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(fixture.flow.stage, HostedFlowStage.profile);
|
||||
expect(find.text('Saving…'), findsOneWidget);
|
||||
release.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(attempts, 1);
|
||||
expect(find.text('Not now'), findsOneWidget);
|
||||
|
||||
255
mobile/test/shared/hosted/saved_identity_profile_test.dart
Normal file
255
mobile/test/shared/hosted/saved_identity_profile_test.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:buzz/shared/crypto/event_signing.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:buzz/shared/hosted/saved_identity_profile.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final secret = '1'.padLeft(64, '0');
|
||||
final keys = nostr.Keys(secret);
|
||||
final other = nostr.Keys('2'.padLeft(64, '0'));
|
||||
|
||||
test(
|
||||
'inactive profile lookup avoids account work and preserves reconnect recovery',
|
||||
() async {
|
||||
final account = _SignedOutAccount();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
builderlabAccountProvider.overrideWithValue(account),
|
||||
savedIdentityProfileClientProvider.overrideWithValue(
|
||||
() => MockClient((_) async {
|
||||
fail('Signed-out hosted lookup must not reach the relay');
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
addTearDown(account.dispose);
|
||||
final active = RelayConfig(
|
||||
baseUrl: 'wss://active.${HostedEnvironment.communitySuffix}',
|
||||
nsec: keys.nsec,
|
||||
);
|
||||
final inactive = 'wss://inactive.${HostedEnvironment.communitySuffix}';
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
{
|
||||
'name': 'Inactive',
|
||||
'relayUrl': inactive,
|
||||
'pubkey': other.public,
|
||||
'nsec': other.nsec,
|
||||
},
|
||||
]),
|
||||
});
|
||||
final auth = container.read(hostedRelayAuthProvider);
|
||||
expect(auth.protects(active), isTrue);
|
||||
await expectLater(
|
||||
auth.headers(active),
|
||||
throwsA(AccountFailure.signedOut),
|
||||
);
|
||||
final previous = (auth.failedRelay, auth.failedPubkey, auth.failure);
|
||||
var notifications = 0;
|
||||
auth.addListener(() {
|
||||
notifications++;
|
||||
});
|
||||
expect(
|
||||
await container.read(savedIdentityProfileProvider(other.public).future),
|
||||
isNull,
|
||||
);
|
||||
expect(account.restores, 1);
|
||||
expect((auth.failedRelay, auth.failedPubkey, auth.failure), previous);
|
||||
expect(notifications, 0);
|
||||
},
|
||||
skip:
|
||||
!const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH') ||
|
||||
!HostedEnvironment.enabled,
|
||||
);
|
||||
|
||||
for (final failure in ['oversized', 'stalled body', 'stalled headers']) {
|
||||
testWidgets('closes $failure before trying another saved relay', (
|
||||
tester,
|
||||
) async {
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
for (final host in ['broken.example', 'next.example'])
|
||||
{
|
||||
'name': host,
|
||||
'relayUrl': 'wss://$host',
|
||||
'pubkey': keys.public,
|
||||
'nsec': keys.nsec,
|
||||
},
|
||||
]),
|
||||
});
|
||||
var cancelled = false;
|
||||
bool? closedBeforeFallback;
|
||||
bool? cancelledBeforeFallback;
|
||||
final body = StreamController<List<int>>(
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
final clients = <_ProfileClient>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
savedIdentityProfileClientProvider.overrideWithValue(() {
|
||||
final client = _ProfileClient((request) async {
|
||||
if (request.url.host == 'next.example') {
|
||||
closedBeforeFallback = clients.first.closed;
|
||||
cancelledBeforeFallback = cancelled;
|
||||
return http.StreamedResponse(
|
||||
Stream.value(utf8.encode('[]')),
|
||||
503,
|
||||
);
|
||||
}
|
||||
if (failure == 'stalled headers') {
|
||||
return Completer<http.StreamedResponse>().future;
|
||||
}
|
||||
if (failure == 'oversized') {
|
||||
body.add(List.filled(256 * 1024, 32));
|
||||
body.add([32]);
|
||||
}
|
||||
return http.StreamedResponse(body.stream, 200);
|
||||
});
|
||||
clients.add(client);
|
||||
return client;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final subscription = container.listen(
|
||||
savedIdentityProfileProvider(keys.public),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
var finished = false;
|
||||
container.read(savedIdentityProfileProvider(keys.public).future).then((
|
||||
value,
|
||||
) {
|
||||
expectSync(value, isNull);
|
||||
finished = true;
|
||||
});
|
||||
await tester.pump();
|
||||
if (failure == 'oversized') expectSync(finished, isTrue);
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
expectSync(finished, isTrue);
|
||||
expectSync(clients, hasLength(2));
|
||||
expectSync(closedBeforeFallback, isTrue);
|
||||
if (failure != 'stalled headers') {
|
||||
expectSync(cancelledBeforeFallback, isTrue);
|
||||
}
|
||||
expectSync(clients.every((client) => client.closed), isTrue);
|
||||
unawaited(body.close());
|
||||
});
|
||||
}
|
||||
|
||||
for (final variant in ['valid', 'invalid signature', 'raw gzip']) {
|
||||
final invalid = variant == 'invalid signature';
|
||||
test('loads only the saved key profile: $variant', () async {
|
||||
final records = jsonEncode([
|
||||
{
|
||||
'name': 'Other',
|
||||
'relayUrl': 'wss://other.example',
|
||||
'pubkey': other.public,
|
||||
'nsec': other.nsec,
|
||||
},
|
||||
{
|
||||
'name': 'Unavailable',
|
||||
'relayUrl': 'wss://offline.example',
|
||||
'pubkey': keys.public,
|
||||
'nsec': keys.nsec,
|
||||
},
|
||||
{
|
||||
'name': 'Home',
|
||||
'relayUrl': 'wss://home.example',
|
||||
'pubkey': keys.public,
|
||||
'nsec': keys.nsec,
|
||||
},
|
||||
]);
|
||||
FlutterSecureStorage.setMockInitialValues({'buzz_communities': records});
|
||||
final event =
|
||||
jsonDecode(
|
||||
signNostrEvent(
|
||||
kind: 0,
|
||||
secretKey: secret,
|
||||
content: jsonEncode({
|
||||
'name': 'Real name',
|
||||
'picture': 'https://home.example/avatar.png',
|
||||
}),
|
||||
).toJson(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
if (invalid) event['sig'] = '0' * 128;
|
||||
final requests = <String>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
savedIdentityProfileClientProvider.overrideWithValue(
|
||||
() => MockClient((request) async {
|
||||
requests.add(request.url.host);
|
||||
expect(request.followRedirects, isFalse);
|
||||
expect(request.headers['Authorization'], startsWith('Nostr '));
|
||||
expect(jsonDecode(request.body)[0]['authors'], [keys.public]);
|
||||
if (request.url.host == 'offline.example') {
|
||||
return http.Response('', 503);
|
||||
}
|
||||
final json = jsonEncode([event]);
|
||||
if (variant == 'raw gzip') {
|
||||
return http.Response.bytes(gzip.encode(utf8.encode(json)), 200);
|
||||
}
|
||||
return http.Response(json, 200);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final profile = await container.read(
|
||||
savedIdentityProfileProvider(keys.public).future,
|
||||
);
|
||||
expect(requests, ['offline.example', 'home.example']);
|
||||
if (invalid || variant == 'raw gzip') {
|
||||
expect(profile, isNull);
|
||||
} else {
|
||||
expect(profile?.displayName, 'Real name');
|
||||
expect(profile?.avatarUrl, 'https://home.example/avatar.png');
|
||||
}
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: 'buzz_communities'),
|
||||
records,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileClient extends http.BaseClient {
|
||||
_ProfileClient(this.respond);
|
||||
final Future<http.StreamedResponse> Function(http.BaseRequest) respond;
|
||||
bool closed = false;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) =>
|
||||
respond(request);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SignedOutAccount extends BuilderlabAccountClient {
|
||||
int restores = 0;
|
||||
@override
|
||||
Future<BuilderlabAccount?> restore() async {
|
||||
restores++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
@@ -111,6 +112,60 @@ void main() {
|
||||
});
|
||||
|
||||
group('MediaImageProvider fetching', () {
|
||||
test(
|
||||
'oversized image streams abort before decode and preserve the client',
|
||||
() async {
|
||||
for (final declared in [true, false]) {
|
||||
var cancelled = false;
|
||||
var aborted = false;
|
||||
var delivered = false;
|
||||
final controller = StreamController<List<int>>(
|
||||
onCancel: () => cancelled = true,
|
||||
);
|
||||
final client = _StreamingClient((request) async {
|
||||
if (request.url.path.endsWith('valid.png')) {
|
||||
return http.StreamedResponse(Stream.value(_pngBytes), 200);
|
||||
}
|
||||
(request as http.AbortableRequest).abortTrigger!.then((_) {
|
||||
aborted = true;
|
||||
});
|
||||
// Never finish: the loader must stop as soon as the cap is crossed.
|
||||
controller.add(Uint8List(MediaImageProvider.maxResponseBytes));
|
||||
controller.add([0]);
|
||||
return http.StreamedResponse(
|
||||
controller.stream,
|
||||
200,
|
||||
contentLength: declared
|
||||
? MediaImageProvider.maxResponseBytes + 1
|
||||
: null,
|
||||
);
|
||||
});
|
||||
final provider = MediaImageProvider(
|
||||
url: 'https://avatar.example/$declared.png',
|
||||
auth: _auth(),
|
||||
client: client,
|
||||
onBytesLoaded: (_) => delivered = true,
|
||||
);
|
||||
final error = await _waitError(
|
||||
provider.resolve(ImageConfiguration.empty),
|
||||
).timeout(const Duration(seconds: 2));
|
||||
expect(error, isA<FormatException>());
|
||||
expect(cancelled, isTrue);
|
||||
expect(aborted, isTrue);
|
||||
expect(delivered, isFalse);
|
||||
await controller.close();
|
||||
await _wait(
|
||||
MediaImageProvider(
|
||||
url: 'https://avatar.example/valid.png',
|
||||
auth: _auth(),
|
||||
client: client,
|
||||
).resolve(ImageConfiguration.empty),
|
||||
);
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('resolves one fetch for repeated resolves of the same key', () async {
|
||||
var fetches = 0;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
@@ -242,3 +297,11 @@ Future<Object?> _waitError(ImageStream stream) async {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
class _StreamingClient extends http.BaseClient {
|
||||
_StreamingClient(this.respond);
|
||||
final Future<http.StreamedResponse> Function(http.BaseRequest) respond;
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) =>
|
||||
respond(request);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user