From ea478d5023bee46156a691729b79a3fc15aee5e2 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 22 Apr 2025 12:04:48 +0800 Subject: [PATCH 1/8] refactor: deeplink handler --- .../startup/tasks/appflowy_cloud_task.dart | 131 ++++++------------ .../tasks/deeplink/deeplink_handler.dart | 87 ++++++++++++ .../deeplink/login_deeplink_handler.dart | 49 +++++++ .../deeplink/payment_deeplink_handler.dart | 24 ++++ 4 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/payment_deeplink_handler.dart diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 362b27a85a913..106f2f1299e07 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -5,16 +5,13 @@ import 'package:app_links/app_links.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy/user/application/user_auth_listener.dart'; -import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; @@ -25,6 +22,10 @@ const appflowyDeepLinkSchema = 'appflowy-flutter'; class AppFlowyCloudDeepLink { AppFlowyCloudDeepLink() { + _deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance + ..register(LoginDeepLinkHandler()) + ..register(PaymentDeepLinkHandler()); + _deepLinkSubscription = _AppLinkWrapper.instance.listen( (Uri? uri) async { Log.info('onDeepLink: ${uri.toString()}'); @@ -51,6 +52,7 @@ class AppFlowyCloudDeepLink { } late final StreamSubscription _deepLinkSubscription; + late final DeepLinkHandlerRegistry _deepLinkHandlerRegistry; Future dispose() async { Log.debug('AppFlowyCloudDeepLink: $hashCode dispose'); @@ -102,84 +104,57 @@ class AppFlowyCloudDeepLink { return; } - if (_isPaymentSuccessUri(uri)) { - Log.debug("Payment success deep link: ${uri.toString()}"); - final plan = uri.queryParameters['plan']; - return getIt().onPaymentSuccess(plan); - } - - return _isAuthCallbackDeepLink(uri).fold( - (_) async { - final deviceId = await getDeviceId(); - final payload = OauthSignInPB( - authenticator: AuthTypePB.Server, - map: { - AuthServiceMapKeys.signInURL: uri.toString(), - AuthServiceMapKeys.deviceId: deviceId, - }, - ); - _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.loading); - final result = await UserEventOauthSignIn(payload).send(); - - _stateNotifier?.value = DeepLinkResult( - state: DeepLinkState.finish, - result: result, - ); - // If there is no completer, runAppFlowy() will be called. - if (_completer == null) { - await result.fold( - (_) async { - await runAppFlowy(); - }, - (err) { - Log.error(err); - final context = AppGlobals.rootNavKey.currentState?.context; - if (context != null) { - showToastNotification( - message: err.msg, - ); - } - }, - ); - } else { - _completer?.complete(result); - completer = null; + await _deepLinkHandlerRegistry.processDeepLink( + uri: uri, + onStateChange: (handler, state) { + // only handle the login deep link + if (handler is LoginDeepLinkHandler) { + _stateNotifier?.value = DeepLinkResult(state: state); } }, - (err) { - Log.error('onDeepLinkError: Unexpected deep link: $err'); + onResult: (handler, result) async { + if (handler is LoginDeepLinkHandler && + result is FlowyResult) { + // If there is no completer, runAppFlowy() will be called. + if (_completer == null) { + await result.fold( + (_) async { + await runAppFlowy(); + }, + (err) { + Log.error(err); + final context = AppGlobals.rootNavKey.currentState?.context; + if (context != null) { + showToastNotification( + message: err.msg, + ); + } + }, + ); + } else { + _completer?.complete(result); + completer = null; + } + } + }, + onError: (error) { + Log.error('onDeepLinkError: Unexpected deep link: $error'); if (_completer == null) { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { - showSnackBarMessage( - context, - err.msg, + showToastNotification( + message: error.msg, + type: ToastificationType.error, ); } } else { - _completer?.complete(FlowyResult.failure(err)); + _completer?.complete(FlowyResult.failure(error)); completer = null; } }, ); } - FlowyResult _isAuthCallbackDeepLink(Uri uri) { - if (uri.fragment.contains('access_token')) { - return FlowyResult.success(null); - } - - return FlowyResult.failure( - FlowyError.create() - ..code = ErrorCode.MissingAuthField - ..msg = uri.path, - ); - } - - bool _isPaymentSuccessUri(Uri uri) { - return uri.host == 'payment-success'; - } - Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { final params = {}; @@ -263,22 +238,6 @@ class InitAppFlowyCloudTask extends LaunchTask { } } -class DeepLinkResult { - DeepLinkResult({ - required this.state, - this.result, - }); - - final DeepLinkState state; - final FlowyResult? result; -} - -enum DeepLinkState { - none, - loading, - finish, -} - // wrapper for AppLinks to support multiple listeners class _AppLinkWrapper { _AppLinkWrapper._() { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart new file mode 100644 index 0000000000000..96820458828e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef DeepLinkResultHandler = void Function( + DeepLinkHandler handler, + FlowyResult result, +); + +typedef DeepLinkStateHandler = void Function( + DeepLinkHandler handler, + DeepLinkState state, +); + +typedef DeepLinkErrorHandler = void Function( + FlowyError error, +); + +abstract class DeepLinkHandler { + /// Checks if this handler should handle the given URI + bool canHandle(Uri uri); + + /// Handles the deep link URI + + Future> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }); +} + +class DeepLinkHandlerRegistry { + DeepLinkHandlerRegistry._(); + static final instance = DeepLinkHandlerRegistry._(); + + final List _handlers = []; + + /// Register a new DeepLink handler + void register(DeepLinkHandler handler) { + _handlers.add(handler); + } + + Future processDeepLink({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + required DeepLinkResultHandler onResult, + required DeepLinkErrorHandler onError, + }) async { + Log.info('Processing DeepLink: ${uri.toString()}'); + + for (final handler in _handlers) { + if (handler.canHandle(uri)) { + Log.info('Handler ${handler.runtimeType} will handle the DeepLink'); + + final result = await handler.handle( + uri: uri, + onStateChange: onStateChange, + ); + + onResult(handler, result); + } + } + + Log.error('No handler found for DeepLink: ${uri.toString()}'); + + onError( + FlowyError(msg: 'No handler found for DeepLink: ${uri.toString()}'), + ); + } +} + +class DeepLinkResult { + DeepLinkResult({ + required this.state, + this.result, + }); + final DeepLinkState state; + final FlowyResult? result; +} + +enum DeepLinkState { + none, + loading, + finish, + error, +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart new file mode 100644 index 0000000000000..22fbe44157edc --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/device_id.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class LoginDeepLinkHandler extends DeepLinkHandler { + @override + bool canHandle(Uri uri) { + final isLoginCallback = uri.host == 'login-callback'; + if (!isLoginCallback) { + return false; + } + + final containsAccessToken = uri.fragment.contains('access_token'); + if (!containsAccessToken) { + return false; + } + + return true; + } + + @override + Future> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }) async { + final deviceId = await getDeviceId(); + final payload = OauthSignInPB( + authenticator: AuthTypePB.Server, + map: { + AuthServiceMapKeys.signInURL: uri.toString(), + AuthServiceMapKeys.deviceId: deviceId, + }, + ); + + onStateChange(this, DeepLinkState.loading); + + final result = await UserEventOauthSignIn(payload).send(); + + onStateChange(this, DeepLinkState.finish); + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/payment_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/payment_deeplink_handler.dart new file mode 100644 index 0000000000000..d5703a2d7a01b --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/payment_deeplink_handler.dart @@ -0,0 +1,24 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class PaymentDeepLinkHandler extends DeepLinkHandler { + @override + bool canHandle(Uri uri) { + return uri.host == 'payment-success'; + } + + @override + Future> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }) async { + Log.debug("Payment success deep link: ${uri.toString()}"); + final plan = uri.queryParameters['plan']; + getIt().onPaymentSuccess(plan); + return FlowyResult.success(null); + } +} From e6e25a73eb11d391d6bff3512a3eab2e87ace6a7 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Wed, 23 Apr 2025 23:04:07 +0800 Subject: [PATCH 2/8] feat: add invitation deeplink handler --- .../startup/tasks/appflowy_cloud_task.dart | 4 +- .../tasks/deeplink/deeplink_handler.dart | 15 +- .../deeplink/invitation_deeplink_hanlder.dart | 48 ++++++ .../workspace/_sidebar_workspace_menu.dart | 2 +- .../sidebar/workspace/sidebar_workspace.dart | 154 +++++++++++++----- frontend/appflowy_flutter/macos/Podfile.lock | 46 +++--- 6 files changed, 197 insertions(+), 72 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index 448c269477d52..a2f96383c8c0f 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -6,6 +6,7 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_hanlder.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; @@ -24,7 +25,8 @@ class AppFlowyCloudDeepLink { AppFlowyCloudDeepLink() { _deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance ..register(LoginDeepLinkHandler()) - ..register(PaymentDeepLinkHandler()); + ..register(PaymentDeepLinkHandler()) + ..register(InvitationDeepLinkHandler()); _deepLinkSubscription = _AppLinkWrapper.instance.listen( (Uri? uri) async { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart index 96820458828e2..67c10fbaa12e0 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/deeplink_handler.dart @@ -49,6 +49,8 @@ class DeepLinkHandlerRegistry { }) async { Log.info('Processing DeepLink: ${uri.toString()}'); + bool handled = false; + for (final handler in _handlers) { if (handler.canHandle(uri)) { Log.info('Handler ${handler.runtimeType} will handle the DeepLink'); @@ -59,14 +61,19 @@ class DeepLinkHandlerRegistry { ); onResult(handler, result); + + handled = true; + break; } } - Log.error('No handler found for DeepLink: ${uri.toString()}'); + if (!handled) { + Log.error('No handler found for DeepLink: ${uri.toString()}'); - onError( - FlowyError(msg: 'No handler found for DeepLink: ${uri.toString()}'), - ); + onError( + FlowyError(msg: 'No handler found for DeepLink: ${uri.toString()}'), + ); + } } } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart new file mode 100644 index 0000000000000..bf3616f04ce26 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +// appflowy-flutter://invitation-callback?workspace_id=b2d11122-1fc8-474d-9ef1-ec12fea7ffe8 + +class InvitationDeepLinkHandler extends DeepLinkHandler { + static const invitationCallbackHost = 'invitation-callback'; + static const invitationCallbackWorkspaceId = 'workspace_id'; + + @override + bool canHandle(Uri uri) { + final isInvitationCallback = uri.host == invitationCallbackHost; + if (!isInvitationCallback) { + return false; + } + + final containsWorkspaceId = + uri.queryParameters.containsKey(invitationCallbackWorkspaceId); + if (!containsWorkspaceId) { + return false; + } + + return true; + } + + @override + Future> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }) async { + final workspaceId = uri.queryParameters[invitationCallbackWorkspaceId]; + if (workspaceId == null) { + return FlowyResult.failure( + FlowyError( + msg: 'Workspace ID is required', + ), + ); + } + + openWorkspaceIdNotifier.value = workspaceId; + + return FlowyResult.success(null); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 4ff5ccbf6788d..aa2326ac8cdab 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -24,7 +24,7 @@ import '_sidebar_import_notion.dart'; const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); @visibleForTesting -const importNotionButtonKey = ValueKey('importNotinoButton'); +const importNotionButtonKey = ValueKey('importNotionButton'); class WorkspacesMenu extends StatefulWidget { const WorkspacesMenu({ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 50ea9d83c728f..8362990a6be48 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -10,11 +10,15 @@ import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +// Workaround for open workspace from invitation deep link +ValueNotifier openWorkspaceIdNotifier = ValueNotifier(null); + class SidebarWorkspace extends StatefulWidget { const SidebarWorkspace({super.key, required this.userProfile}); @@ -28,10 +32,20 @@ class _SidebarWorkspaceState extends State { Loading? loadingIndicator; final ValueNotifier onHover = ValueNotifier(false); + int maxRetryCount = 3; + int retryCount = 0; + + @override + void initState() { + super.initState(); + + openWorkspaceIdNotifier.addListener(_openWorkspaceFromInvitation); + } @override void dispose() { onHover.dispose(); + openWorkspaceIdNotifier.removeListener(_openWorkspaceFromInvitation); super.dispose(); } @@ -147,6 +161,7 @@ class _SidebarWorkspaceState extends State { (s) => LocaleKeys.workspace_openSuccess.tr(), (e) => '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}', ); + break; case UserWorkspaceActionType.updateIcon: message = result.fold( @@ -160,8 +175,9 @@ class _SidebarWorkspaceState extends State { (e) => '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}', ); break; - case UserWorkspaceActionType.none: + case UserWorkspaceActionType.fetchWorkspaces: + case UserWorkspaceActionType.none: case UserWorkspaceActionType.leave: message = null; break; @@ -177,6 +193,59 @@ class _SidebarWorkspaceState extends State { ); } } + + void _openWorkspaceFromInvitation() { + final workspaceId = openWorkspaceIdNotifier.value; + if (workspaceId == null) { + Log.info('No workspace id to open'); + return; + } + + final state = context.read().state; + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace?.workspaceId == workspaceId) { + Log.info('Already in the workspace'); + return; + } + + final openWorkspace = state.workspaces.firstWhereOrNull( + (workspace) => workspace.workspaceId == workspaceId, + ); + + if (openWorkspace == null) { + Log.error('Workspace not found, try to fetch workspaces'); + + context + .read() + .add(const UserWorkspaceEvent.fetchWorkspaces()); + + Future.delayed( + Duration(milliseconds: 250 + retryCount * 250), + () { + if (retryCount >= maxRetryCount) { + openWorkspaceIdNotifier.value = null; + retryCount = 0; + Log.error('Failed to open workspace from invitation'); + return; + } + + retryCount++; + _openWorkspaceFromInvitation(); + }, + ); + + return; + } + + context.read().add( + UserWorkspaceEvent.openWorkspace( + workspaceId, + openWorkspace.workspaceAuthType, + ), + ); + + openWorkspaceIdNotifier.value = null; + } } class SidebarSwitchWorkspaceButton extends StatefulWidget { @@ -202,48 +271,47 @@ class _SidebarSwitchWorkspaceButtonState @override Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - offset: const Offset(0, 5), - constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600), - margin: EdgeInsets.zero, - animationDuration: Durations.short3, - beginScaleFactor: 1.0, - beginOpacity: 0.8, - controller: _popoverController, - triggerActions: PopoverTriggerFlags.none, - onOpen: () { - context - .read() - .add(const UserWorkspaceEvent.fetchWorkspaces()); - }, - onClose: () { - Log.info('close workspace menu'); - }, - popupBuilder: (_) { - return BlocProvider.value( - value: context.read(), - child: BlocBuilder( - builder: (context, state) { - final currentWorkspace = state.currentWorkspace; - final workspaces = state.workspaces; - if (currentWorkspace == null) { - return const SizedBox.shrink(); - } - Log.info('open workspace menu'); - return WorkspacesMenu( - userProfile: widget.userProfile, - currentWorkspace: currentWorkspace, - workspaces: workspaces, - ); - }, - ), - ); - }, - child: _SideBarSwitchWorkspaceButtonChild( - currentWorkspace: widget.currentWorkspace, - popoverController: _popoverController, - isHover: widget.isHover, + return BlocProvider( + create: (context) => context.read(), + child: AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 5), + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600), + margin: EdgeInsets.zero, + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + controller: _popoverController, + triggerActions: PopoverTriggerFlags.none, + onOpen: () { + context + .read() + .add(const UserWorkspaceEvent.fetchWorkspaces()); + }, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + final workspaces = state.workspaces; + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + return WorkspacesMenu( + userProfile: widget.userProfile, + currentWorkspace: currentWorkspace, + workspaces: workspaces, + ); + }, + ), + ); + }, + child: _SideBarSwitchWorkspaceButtonChild( + currentWorkspace: widget.currentWorkspace, + popoverController: _popoverController, + isHover: widget.isHover, + ), ), ); } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index 9c949dd02f9df..b846196707226 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -144,34 +144,34 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 - auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 - file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d - flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 + appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 - hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c - irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 - local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda - screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 - sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 - share_plus: 1fa619de8392a4398bfaf176d441853922614e89 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 - webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4 - window_manager: 990c8e348c4da2a93b81da638245d40554ec9436 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c + window_manager: e8d0b1431ab6c454f2b5c9ae26004bbfa43469aa PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 From 4c55a558661aaccca47ea3c25a0a812c7c3e0238 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Wed, 23 Apr 2025 23:14:18 +0800 Subject: [PATCH 3/8] test: add deeplink tests --- .../sidebar/workspace/sidebar_workspace.dart | 2 + .../unit_test/deeplink/deeplink_test.dart | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 8362990a6be48..a0674bc351591 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -194,6 +194,8 @@ class _SidebarWorkspaceState extends State { } } + // This function is a workaround, when we support open the workspace from invitation deep link, + // we should refactor the code here void _openWorkspaceFromInvitation() { final workspaceId = openWorkspaceIdNotifier.value; if (workspaceId == null) { diff --git a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart new file mode 100644 index 0000000000000..06dd054b5390e --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_hanlder.dart'; +import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('deep link handler: ', () { + final deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance + ..register(LoginDeepLinkHandler()) + ..register(PaymentDeepLinkHandler()) + ..register(InvitationDeepLinkHandler()); + + test('invitation deep link handler', () { + final uri = + Uri.parse('appflowy-flutter://invitation-callback?workspace_id=123'); + deepLinkHandlerRegistry.processDeepLink( + uri: uri, + onStateChange: (handler, state) { + expect(handler, isA()); + }, + onResult: (handler, result) { + expect(handler, isA()); + expect(result.isSuccess, true); + }, + onError: (error) { + expect(error, isNull); + }, + ); + }); + + test('login deep link handler', () { + final uri = + Uri.parse('appflowy-flutter://login-callback#access_token=123'); + expect(LoginDeepLinkHandler().canHandle(uri), true); + }); + + test('payment deep link handler', () { + final uri = Uri.parse('appflowy-flutter://payment-success'); + expect(PaymentDeepLinkHandler().canHandle(uri), true); + }); + + test('unknown deep link handler', () { + final uri = + Uri.parse('appflowy-flutter://unknown-callback?workspace_id=123'); + deepLinkHandlerRegistry.processDeepLink( + uri: uri, + onStateChange: (handler, state) {}, + onResult: (handler, result) {}, + onError: (error) { + expect(error, isNotNull); + }, + ); + }); + }); +} From c8e28111ace43850d6c15b6f489d2c29b907fe81 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Thu, 24 Apr 2025 10:27:09 +0800 Subject: [PATCH 4/8] feat: add user_id in the invitation callback --- .../deeplink/invitation_deeplink_hanlder.dart | 28 ++++++++++++++---- .../deeplink/login_deeplink_handler.dart | 5 ---- .../application/user/user_workspace_bloc.dart | 7 ++++- .../sidebar/workspace/sidebar_workspace.dart | 29 ++++++++++++++----- .../sidebar/workspace/workspace_notifier.dart | 12 ++++++++ 5 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart index bf3616f04ce26..f9d14856146be 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart @@ -1,16 +1,16 @@ import 'dart:async'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; -// appflowy-flutter://invitation-callback?workspace_id=b2d11122-1fc8-474d-9ef1-ec12fea7ffe8 - +// invitation callback deeplink example: +// appflowy-flutter://invitation-callback?workspace_id=b2d11122-1fc8-474d-9ef1-ec12fea7ffe8&user_id=275966408418922496 class InvitationDeepLinkHandler extends DeepLinkHandler { static const invitationCallbackHost = 'invitation-callback'; static const invitationCallbackWorkspaceId = 'workspace_id'; - + static const invitationCallbackUserId = 'user_id'; @override bool canHandle(Uri uri) { final isInvitationCallback = uri.host == invitationCallbackHost; @@ -24,6 +24,12 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { return false; } + final containsUserId = + uri.queryParameters.containsKey(invitationCallbackUserId); + if (!containsUserId) { + return false; + } + return true; } @@ -33,6 +39,7 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { required DeepLinkStateHandler onStateChange, }) async { final workspaceId = uri.queryParameters[invitationCallbackWorkspaceId]; + final userId = uri.queryParameters[invitationCallbackUserId]; if (workspaceId == null) { return FlowyResult.failure( FlowyError( @@ -41,7 +48,18 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { ); } - openWorkspaceIdNotifier.value = workspaceId; + if (userId == null) { + return FlowyResult.failure( + FlowyError( + msg: 'User ID is required', + ), + ); + } + + openWorkspaceNotifier.value = WorkspaceNotifyValue( + workspaceId: workspaceId, + userId: userId, + ); return FlowyResult.success(null); } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart index 22fbe44157edc..5ed8073e2a5f1 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/login_deeplink_handler.dart @@ -11,11 +11,6 @@ import 'package:appflowy_result/appflowy_result.dart'; class LoginDeepLinkHandler extends DeepLinkHandler { @override bool canHandle(Uri uri) { - final isLoginCallback = uri.host == 'login-callback'; - if (!isLoginCallback) { - return false; - } - final containsAccessToken = uri.fragment.contains('access_token'); if (!containsAccessToken) { return false; diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index d14f258462e40..5ecd1aa26d4c2 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -442,12 +443,16 @@ class UserWorkspaceBloc extends Bloc { try { final currentWorkspace = await UserBackendService.getCurrentWorkspace().getOrThrow(); + final currentWorkspaceId = + openWorkspaceNotifier.value?.workspaceId ?? currentWorkspace.id; + // clear the open workspace notifier value + openWorkspaceNotifier.value = null; final workspaces = await _userService.getWorkspaces().getOrThrow(); if (workspaces.isEmpty) { workspaces.add(convertWorkspacePBToUserWorkspace(currentWorkspace)); } final currentWorkspaceInList = workspaces - .firstWhereOrNull((e) => e.workspaceId == currentWorkspace.id) ?? + .firstWhereOrNull((e) => e.workspaceId == currentWorkspaceId) ?? workspaces.firstOrNull; return ( currentWorkspaceInList, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index a0674bc351591..72d071878ad48 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; @@ -16,9 +17,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -// Workaround for open workspace from invitation deep link -ValueNotifier openWorkspaceIdNotifier = ValueNotifier(null); - class SidebarWorkspace extends StatefulWidget { const SidebarWorkspace({super.key, required this.userProfile}); @@ -39,13 +37,13 @@ class _SidebarWorkspaceState extends State { void initState() { super.initState(); - openWorkspaceIdNotifier.addListener(_openWorkspaceFromInvitation); + openWorkspaceNotifier.addListener(_openWorkspaceFromInvitation); } @override void dispose() { onHover.dispose(); - openWorkspaceIdNotifier.removeListener(_openWorkspaceFromInvitation); + openWorkspaceNotifier.removeListener(_openWorkspaceFromInvitation); super.dispose(); } @@ -197,12 +195,20 @@ class _SidebarWorkspaceState extends State { // This function is a workaround, when we support open the workspace from invitation deep link, // we should refactor the code here void _openWorkspaceFromInvitation() { - final workspaceId = openWorkspaceIdNotifier.value; + final value = openWorkspaceNotifier.value; + final workspaceId = value?.workspaceId; + final userId = value?.userId; + if (workspaceId == null) { Log.info('No workspace id to open'); return; } + if (userId == null) { + Log.info('Open workspace from invitation with no user id'); + return; + } + final state = context.read().state; final currentWorkspace = state.currentWorkspace; if (currentWorkspace?.workspaceId == workspaceId) { @@ -210,6 +216,13 @@ class _SidebarWorkspaceState extends State { return; } + if (userId != widget.userProfile.id.toString()) { + Log.info( + 'Current user id: ${widget.userProfile.id} is not the same as the user id in the invitation: $userId', + ); + return; + } + final openWorkspace = state.workspaces.firstWhereOrNull( (workspace) => workspace.workspaceId == workspaceId, ); @@ -225,7 +238,7 @@ class _SidebarWorkspaceState extends State { Duration(milliseconds: 250 + retryCount * 250), () { if (retryCount >= maxRetryCount) { - openWorkspaceIdNotifier.value = null; + openWorkspaceNotifier.value = null; retryCount = 0; Log.error('Failed to open workspace from invitation'); return; @@ -246,7 +259,7 @@ class _SidebarWorkspaceState extends State { ), ); - openWorkspaceIdNotifier.value = null; + openWorkspaceNotifier.value = null; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart new file mode 100644 index 0000000000000..ce07718fb0e04 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart @@ -0,0 +1,12 @@ +// Workaround for open workspace from invitation deep link +import 'package:flutter/material.dart'; + +ValueNotifier openWorkspaceNotifier = + ValueNotifier(null); + +class WorkspaceNotifyValue { + WorkspaceNotifyValue({this.workspaceId, this.userId}); + + final String? workspaceId; + final String? userId; +} From da57f798920e0625e25601efb00235722e857b8b Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Thu, 24 Apr 2025 10:57:48 +0800 Subject: [PATCH 5/8] feat: add expire login deeplink handler --- .../startup/tasks/appflowy_cloud_task.dart | 18 +++++++++-- .../expire_login_deeplink_handler.dart | 31 +++++++++++++++++++ ....dart => invitation_deeplink_handler.dart} | 1 + .../unit_test/deeplink/deeplink_test.dart | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/startup/tasks/deeplink/expire_login_deeplink_handler.dart rename frontend/appflowy_flutter/lib/startup/tasks/deeplink/{invitation_deeplink_hanlder.dart => invitation_deeplink_handler.dart} (99%) diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index a2f96383c8c0f..892b9df2ff1d9 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -6,7 +6,8 @@ import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; -import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_hanlder.dart'; +import 'package:appflowy/startup/tasks/deeplink/expire_login_deeplink_handler.dart'; +import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; @@ -26,7 +27,8 @@ class AppFlowyCloudDeepLink { _deepLinkHandlerRegistry = DeepLinkHandlerRegistry.instance ..register(LoginDeepLinkHandler()) ..register(PaymentDeepLinkHandler()) - ..register(InvitationDeepLinkHandler()); + ..register(InvitationDeepLinkHandler()) + ..register(ExpireLoginDeepLinkHandler()); _deepLinkSubscription = _AppLinkWrapper.instance.listen( (Uri? uri) async { @@ -137,6 +139,18 @@ class AppFlowyCloudDeepLink { _completer?.complete(result); completer = null; } + } else if (handler is ExpireLoginDeepLinkHandler) { + result.onFailure( + (error) { + final context = AppGlobals.rootNavKey.currentState?.context; + if (context != null) { + showToastNotification( + message: error.msg, + type: ToastificationType.error, + ); + } + }, + ); } }, onError: (error) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/expire_login_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/expire_login_deeplink_handler.dart new file mode 100644 index 0000000000000..d7513cdc015ac --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/expire_login_deeplink_handler.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +/// Expire login deeplink example: +/// appflowy-flutter:%23error=access_denied&error_code=403&error_description=Email+link+is+invalid+or+has+expired +class ExpireLoginDeepLinkHandler extends DeepLinkHandler { + @override + bool canHandle(Uri uri) { + final isExpireLogin = uri.toString().contains('error=access_denied'); + if (!isExpireLogin) { + return false; + } + + return true; + } + + @override + Future> handle({ + required Uri uri, + required DeepLinkStateHandler onStateChange, + }) async { + return FlowyResult.failure( + FlowyError( + msg: 'Magic link is invalid or has expired', + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart similarity index 99% rename from frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart rename to frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart index f9d14856146be..8ddfe77b30abc 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_hanlder.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart @@ -11,6 +11,7 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { static const invitationCallbackHost = 'invitation-callback'; static const invitationCallbackWorkspaceId = 'workspace_id'; static const invitationCallbackUserId = 'user_id'; + @override bool canHandle(Uri uri) { final isInvitationCallback = uri.host == invitationCallbackHost; diff --git a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart index 06dd054b5390e..c1172431cdba5 100644 --- a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart @@ -1,5 +1,5 @@ import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart'; -import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_hanlder.dart'; +import 'package:appflowy/startup/tasks/deeplink/invitation_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/login_deeplink_handler.dart'; import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart'; import 'package:flutter_test/flutter_test.dart'; From 953c3fd736c3297228ef335eceb34208263b7263 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Thu, 24 Apr 2025 11:06:32 +0800 Subject: [PATCH 6/8] feat: add loading indicator in continue with password page --- .../lib/user/application/sign_in_bloc.dart | 8 ++ .../continue_with_password_page.dart | 93 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 6aabf91a03ede..2e28db48ad028 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -141,6 +141,14 @@ class SignInBloc extends Bloc { required String email, required String password, }) async { + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); final result = await authService.signInWithEmailPassword( email: email, password: password, diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart index 1e2ed6e100e90..45989516c4199 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -32,6 +32,8 @@ class _ContinueWithPasswordPageState extends State { final passwordController = TextEditingController(); final inputPasswordKey = GlobalKey(); + bool isSubmitting = false; + @override void dispose() { passwordController.dispose(); @@ -60,6 +62,12 @@ class _ContinueWithPasswordPageState extends State { } else { inputPasswordKey.currentState?.clearError(); } + + if (isSubmitting != state.isSubmitting) { + setState(() { + isSubmitting = state.isSubmitting; + }); + } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -123,6 +131,16 @@ class _ContinueWithPasswordPageState extends State { List _buildPasswordSection() { final theme = AppFlowyTheme.of(context); final iconSize = 20.0; + final textStyle = AFButtonSize.l.buildTextStyle(context); + final textHeight = textStyle.height; + final textFontSize = textStyle.fontSize; + + // the indicator height is the height of the text style. + double indicatorHeight = 20; + if (textHeight != null && textFontSize != null) { + indicatorHeight = textHeight * textFontSize; + } + return [ // Password input AFTextField( @@ -144,38 +162,67 @@ class _ContinueWithPasswordPageState extends State { onSubmitted: widget.onEnterPassword, ), // todo: ask designer to provide the spacing - VSpace(8), + // VSpace(8), // Forgot password button - Align( - alignment: Alignment.centerLeft, - child: AFGhostTextButton( - text: LocaleKeys.signIn_forgotPassword.tr(), - size: AFButtonSize.s, - padding: EdgeInsets.zero, - onTap: widget.onForgotPassword, - textColor: (context, isHovering, disabled) { - final theme = AppFlowyTheme.of(context); - if (isHovering) { - return theme.fillColorScheme.themeThickHover; - } - return theme.textColorScheme.theme; - }, - ), - ), + // Align( + // alignment: Alignment.centerLeft, + // child: AFGhostTextButton( + // text: LocaleKeys.signIn_forgotPassword.tr(), + // size: AFButtonSize.s, + // padding: EdgeInsets.zero, + // onTap: widget.onForgotPassword, + // textColor: (context, isHovering, disabled) { + // final theme = AppFlowyTheme.of(context); + // if (isHovering) { + // return theme.fillColorScheme.themeThickHover; + // } + // return theme.textColorScheme.theme; + // }, + // ), + // ), VSpace(20), // Continue button - AFFilledTextButton.primary( - text: LocaleKeys.web_continue.tr(), - onTap: () => widget.onEnterPassword(passwordController.text), - size: AFButtonSize.l, - alignment: Alignment.center, - ), + isSubmitting + ? _buildIndicator(indicatorHeight: indicatorHeight) + : _buildContinueButton(textStyle: textStyle), VSpace(20), ]; } + Widget _buildContinueButton({ + required TextStyle textStyle, + }) { + return AFFilledTextButton.primary( + text: LocaleKeys.web_continue.tr(), + textStyle: textStyle.copyWith( + color: AppFlowyTheme.of(context).textColorScheme.onFill, + ), + onTap: () => widget.onEnterPassword(passwordController.text), + size: AFButtonSize.l, + alignment: Alignment.center, + ); + } + + Widget _buildIndicator({ + required double indicatorHeight, + }) { + return AFFilledButton.disabled( + size: AFButtonSize.l, + builder: (context, isHovering, disabled) { + return Align( + child: SizedBox.square( + dimension: indicatorHeight, + child: CircularProgressIndicator( + strokeWidth: 3.0, + ), + ), + ); + }, + ); + } + List _buildBackToLogin() { return [ AFGhostTextButton( From 7e549ad60546b85b5088ac3a1a7b8bce128e5800 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Thu, 24 Apr 2025 12:04:00 +0800 Subject: [PATCH 7/8] feat: replace user_id with email --- .../deeplink/invitation_deeplink_handler.dart | 16 ++++++------- .../application/user/user_workspace_bloc.dart | 23 +++++++++++-------- .../sidebar/workspace/sidebar_workspace.dart | 18 ++++++++------- .../sidebar/workspace/workspace_notifier.dart | 7 ++++-- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart index 8ddfe77b30abc..264cc81b8cbdf 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/deeplink/invitation_deeplink_handler.dart @@ -10,7 +10,7 @@ import 'package:appflowy_result/appflowy_result.dart'; class InvitationDeepLinkHandler extends DeepLinkHandler { static const invitationCallbackHost = 'invitation-callback'; static const invitationCallbackWorkspaceId = 'workspace_id'; - static const invitationCallbackUserId = 'user_id'; + static const invitationCallbackEmail = 'email'; @override bool canHandle(Uri uri) { @@ -25,9 +25,9 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { return false; } - final containsUserId = - uri.queryParameters.containsKey(invitationCallbackUserId); - if (!containsUserId) { + final containsEmail = + uri.queryParameters.containsKey(invitationCallbackEmail); + if (!containsEmail) { return false; } @@ -40,7 +40,7 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { required DeepLinkStateHandler onStateChange, }) async { final workspaceId = uri.queryParameters[invitationCallbackWorkspaceId]; - final userId = uri.queryParameters[invitationCallbackUserId]; + final email = uri.queryParameters[invitationCallbackEmail]; if (workspaceId == null) { return FlowyResult.failure( FlowyError( @@ -49,17 +49,17 @@ class InvitationDeepLinkHandler extends DeepLinkHandler { ); } - if (userId == null) { + if (email == null) { return FlowyResult.failure( FlowyError( - msg: 'User ID is required', + msg: 'Email is required', ), ); } openWorkspaceNotifier.value = WorkspaceNotifyValue( workspaceId: workspaceId, - userId: userId, + email: email, ); return FlowyResult.success(null); diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index 5ecd1aa26d4c2..567d4a86e2010 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -2,7 +2,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; -import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -21,6 +20,7 @@ part 'user_workspace_bloc.freezed.dart'; class UserWorkspaceBloc extends Bloc { UserWorkspaceBloc({ required this.userProfile, + this.initialWorkspaceId, }) : _userService = UserBackendService(userId: userProfile.id), _listener = UserListener(userProfile: userProfile), super(UserWorkspaceState.initial()) { @@ -41,7 +41,9 @@ class UserWorkspaceBloc extends Bloc { }, ); - final result = await _fetchWorkspaces(); + final result = await _fetchWorkspaces( + initialWorkspaceId: initialWorkspaceId, + ); final currentWorkspace = result.$1; final workspaces = result.$2; final isCollabWorkspaceOn = @@ -68,8 +70,10 @@ class UserWorkspaceBloc extends Bloc { ), ); }, - fetchWorkspaces: () async { - final result = await _fetchWorkspaces(); + fetchWorkspaces: (initialWorkspaceId) async { + final result = await _fetchWorkspaces( + initialWorkspaceId: initialWorkspaceId, + ); final currentWorkspace = result.$1; final workspaces = result.$2; @@ -433,20 +437,18 @@ class UserWorkspaceBloc extends Bloc { final UserProfilePB userProfile; final UserBackendService _userService; final UserListener _listener; + final String? initialWorkspaceId; Future< ( UserWorkspacePB? currentWorkspace, List workspaces, bool shouldOpenWorkspace, - )> _fetchWorkspaces() async { + )> _fetchWorkspaces({String? initialWorkspaceId}) async { try { final currentWorkspace = await UserBackendService.getCurrentWorkspace().getOrThrow(); - final currentWorkspaceId = - openWorkspaceNotifier.value?.workspaceId ?? currentWorkspace.id; - // clear the open workspace notifier value - openWorkspaceNotifier.value = null; + final currentWorkspaceId = initialWorkspaceId ?? currentWorkspace.id; final workspaces = await _userService.getWorkspaces().getOrThrow(); if (workspaces.isEmpty) { workspaces.add(convertWorkspacePBToUserWorkspace(currentWorkspace)); @@ -479,7 +481,8 @@ class UserWorkspaceBloc extends Bloc { @freezed class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.initial() = Initial; - const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; + const factory UserWorkspaceEvent.fetchWorkspaces({String? initialWorkspaceId}) = + FetchWorkspaces; const factory UserWorkspaceEvent.createWorkspace( String name, AuthTypePB authType, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 72d071878ad48..02a0aa46655f1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -197,15 +197,15 @@ class _SidebarWorkspaceState extends State { void _openWorkspaceFromInvitation() { final value = openWorkspaceNotifier.value; final workspaceId = value?.workspaceId; - final userId = value?.userId; + final email = value?.email; if (workspaceId == null) { Log.info('No workspace id to open'); return; } - if (userId == null) { - Log.info('Open workspace from invitation with no user id'); + if (email == null) { + Log.info('Open workspace from invitation with no email'); return; } @@ -216,9 +216,9 @@ class _SidebarWorkspaceState extends State { return; } - if (userId != widget.userProfile.id.toString()) { + if (email != widget.userProfile.email) { Log.info( - 'Current user id: ${widget.userProfile.id} is not the same as the user id in the invitation: $userId', + 'Current user email: ${widget.userProfile.email} is not the same as the email in the invitation: $email', ); return; } @@ -230,9 +230,11 @@ class _SidebarWorkspaceState extends State { if (openWorkspace == null) { Log.error('Workspace not found, try to fetch workspaces'); - context - .read() - .add(const UserWorkspaceEvent.fetchWorkspaces()); + context.read().add( + UserWorkspaceEvent.fetchWorkspaces( + initialWorkspaceId: workspaceId, + ), + ); Future.delayed( Duration(milliseconds: 250 + retryCount * 250), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart index ce07718fb0e04..535d1469befdc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/workspace_notifier.dart @@ -5,8 +5,11 @@ ValueNotifier openWorkspaceNotifier = ValueNotifier(null); class WorkspaceNotifyValue { - WorkspaceNotifyValue({this.workspaceId, this.userId}); + WorkspaceNotifyValue({ + this.workspaceId, + this.email, + }); final String? workspaceId; - final String? userId; + final String? email; } From 4f2de1c86b564ed45fa490d66084abd9b5156dc6 Mon Sep 17 00:00:00 2001 From: LucasXu0 Date: Thu, 24 Apr 2025 12:39:17 +0800 Subject: [PATCH 8/8] fix: invitation deeplink test --- .../test/unit_test/deeplink/deeplink_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart index c1172431cdba5..75a6fb060c0c9 100644 --- a/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/deeplink/deeplink_test.dart @@ -12,8 +12,9 @@ void main() { ..register(InvitationDeepLinkHandler()); test('invitation deep link handler', () { - final uri = - Uri.parse('appflowy-flutter://invitation-callback?workspace_id=123'); + final uri = Uri.parse( + 'appflowy-flutter://invitation-callback?email=lucas@appflowy.com&workspace_id=123', + ); deepLinkHandlerRegistry.processDeepLink( uri: uri, onStateChange: (handler, state) {