Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/appflowy_flutter/lib/startup/deps_resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'package:appflowy/workspace/application/settings/appearance/mobile_appear
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart';
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
Expand Down Expand Up @@ -137,6 +138,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
getIt.registerLazySingleton<SubscriptionSuccessListenable>(
() => SubscriptionSuccessListenable(),
);
getIt.registerLazySingleton<CreateNoteService>(() => CreateNoteService());
}

void _resolveHomeDeps(GetIt getIt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.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/new_note_deeplink_handler.dart';
import 'package:appflowy/startup/tasks/deeplink/open_app_deeplink_handler.dart';
import 'package:appflowy/startup/tasks/deeplink/payment_deeplink_handler.dart';
import 'package:appflowy/user/application/auth/auth_error.dart';
Expand All @@ -30,6 +31,7 @@ class AppFlowyCloudDeepLink {
..register(PaymentDeepLinkHandler())
..register(InvitationDeepLinkHandler())
..register(ExpireLoginDeepLinkHandler())
..register(NewNoteDeepLinkHandler())
..register(OpenAppDeepLinkHandler());

_deepLinkSubscription = _AppLinkWrapper.instance.listen(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:async';

import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/deeplink/deeplink_handler.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/services.dart';

/// Handles deep links of the form:
///
/// ```
/// appflowy-flutter://new
/// ?workspace_id=<uuid> (optional – switches workspace first)
/// &parent_view_id=<uuid> (optional – target space / folder)
/// &name=<title> (optional – defaults to "New Note")
/// &content=<url-encoded-markdown> (optional)
/// &clipboard (optional – read content from clipboard)
/// ```
///
/// Either `content` or `clipboard` can supply the initial Markdown body.
/// When both are present, `clipboard` takes precedence.
class NewNoteDeepLinkHandler extends DeepLinkHandler<void> {
/// [service] is injected for testability; defaults to the [getIt] singleton.
NewNoteDeepLinkHandler({CreateNoteService? service})
: _service = service ?? getIt<CreateNoteService>();

final CreateNoteService _service;

static const _host = 'new';
static const _workspaceIdKey = 'workspace_id';
static const _parentViewIdKey = 'parent_view_id';
static const _nameKey = 'name';
static const _contentKey = 'content';
static const _clipboardKey = 'clipboard';

@override
bool canHandle(Uri uri) => uri.host == _host;

@override
Future<FlowyResult<void, FlowyError>> handle({
required Uri uri,
required DeepLinkStateHandler onStateChange,
}) async {
final name = uri.queryParameters[_nameKey]?.trim();
final effectiveName =
(name == null || name.isEmpty) ? 'New Note' : name;

String? content;

// `clipboard` flag takes precedence over an explicit `content` value.
if (uri.queryParameters.containsKey(_clipboardKey)) {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
content = clipboardData?.text;
if (content == null || content.isEmpty) {
Log.warn('NewNoteDeepLink: clipboard was empty');
}
} else {
content = uri.queryParameters[_contentKey];
}
Comment thread
alexrosepizant marked this conversation as resolved.

_service.request(
CreateNoteParams(
workspaceId: uri.queryParameters[_workspaceIdKey],
parentViewId: uri.queryParameters[_parentViewIdKey],
name: effectiveName,
content: content,
),
);

Log.info(
'NewNoteDeepLink: queued note creation '
'"$effectiveName" (workspace=${uri.queryParameters[_workspaceIdKey]}, '
'parent=${uri.queryParameters[_parentViewIdKey]})',
);

return FlowyResult.success(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_migration.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/note_creation_notifier.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:collection/collection.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:appflowy_editor/appflowy_editor.dart';
Expand Down Expand Up @@ -311,14 +315,29 @@ class _SidebarState extends State<_Sidebar> {
// mute the update button during the current application lifecycle.
final _muteUpdateButton = ValueNotifier(false);

/// Subscription to workspace bloc state changes used for the event-driven
/// workspace-switch step before note creation.
StreamSubscription<UserWorkspaceState>? _workspaceSubscription;

/// Guards against dispatching the same workspace-switch event multiple times
/// while the switch is still in progress.
String? _pendingWorkspaceSwitchId;

@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollChanged);
getIt<CreateNoteService>().addListener(_onCreateNoteRequested);
_workspaceSubscription = context
.read<UserWorkspaceBloc>()
.stream
.listen(_onWorkspaceStateChanged);
}

@override
void dispose() {
_workspaceSubscription?.cancel();
getIt<CreateNoteService>().removeListener(_onCreateNoteRequested);
_scrollDebounce?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
Expand All @@ -327,6 +346,112 @@ class _SidebarState extends State<_Sidebar> {
super.dispose();
}

// ── Deep-link: create new note ──────────────────────────────────────────

/// Called when [CreateNoteService] notifies a new pending request.
void _onCreateNoteRequested() {
if (!mounted) return;
final params = getIt<CreateNoteService>().pending;
if (params == null) return;
_maybeCreateNote(params);
}

/// Called on every [UserWorkspaceBloc] state change so a pending
/// workspace-switch can be completed without any polling timer.
void _onWorkspaceStateChanged(UserWorkspaceState _) {
if (!mounted) return;
final params = getIt<CreateNoteService>().pending;
if (params == null) return;
_maybeCreateNote(params);
}

/// Checks whether the correct workspace is active; if not, dispatches a
/// workspace switch exactly once and returns – [_onWorkspaceStateChanged]
/// will re-invoke this method when the switch completes.
void _maybeCreateNote(CreateNoteParams params) {
final currentWorkspaceId =
context.read<UserWorkspaceBloc>().state.currentWorkspace?.workspaceId;

if (params.workspaceId != null &&
params.workspaceId != currentWorkspaceId) {
if (_pendingWorkspaceSwitchId != params.workspaceId) {
_pendingWorkspaceSwitchId = params.workspaceId;
_switchToWorkspace(params.workspaceId!);
}
return;
}

_pendingWorkspaceSwitchId = null;
_createNoteFromParams(params);
}

void _switchToWorkspace(String workspaceId) {
final workspaceBloc = context.read<UserWorkspaceBloc>();
final target = workspaceBloc.state.workspaces.firstWhereOrNull(
(w) => w.workspaceId == workspaceId,
);
if (target == null) {
workspaceBloc.add(
UserWorkspaceEvent.fetchWorkspaces(initialWorkspaceId: workspaceId),
);
} else {
workspaceBloc.add(
UserWorkspaceEvent.openWorkspace(
workspaceId: workspaceId,
workspaceType: target.workspaceType,
),
);
}
}

Future<void> _createNoteFromParams(CreateNoteParams params) async {
// Consume immediately to prevent duplicate creation if the bloc fires
// another state change before the async call completes.
getIt<CreateNoteService>().consume();

final parentViewId = params.parentViewId?.isNotEmpty == true
? params.parentViewId!
: context.read<SpaceBloc>().state.currentSpace?.id ?? '';

if (parentViewId.isEmpty) {
Log.error('NewNoteDeepLink: no parentViewId available, aborting');
return;
}

// Convert Markdown → DocumentDataPB bytes. A malformed payload must not
// block note creation; fall back to an empty document instead.
List<int>? initialDataBytes;
final content = params.content;
if (content != null && content.isNotEmpty) {
try {
final document = customMarkdownToDocument(content);
initialDataBytes =
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
} catch (e) {
Log.warn(
'NewNoteDeepLink: Markdown conversion failed, '
'creating empty note – $e',
);
}
}

final result = await ViewBackendService.createView(
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
name: params.name,
openAfterCreate: true,
initialDataBytes: initialDataBytes,
);

result.fold(
(view) {
Log.info('NewNoteDeepLink: created "${view.name}" (${view.id})');
if (mounted) context.read<TabsBloc>().openPlugin(view);
},
(error) => Log.error('NewNoteDeepLink: failed to create note – $error'),
);
}

@override
Widget build(BuildContext context) {
const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 8);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'package:flutter/foundation.dart';

/// Parameters for a note-creation deep-link request.
class CreateNoteParams {
CreateNoteParams({
this.workspaceId,
this.parentViewId,
required this.name,
this.content,
});

/// Target workspace UUID. If null, the currently open workspace is used.
final String? workspaceId;

/// Parent view (space / folder) UUID. If null, falls back to the current
/// default space.
final String? parentViewId;

/// Title of the new document.
final String name;

/// Markdown content to pre-fill the document. May be null for an empty note.
final String? content;
}

/// Service registered in [getIt] that carries a pending note-creation request
/// from the deep-link layer to the sidebar layer.
///
/// Using a [ChangeNotifier]-based singleton registered via [getIt] avoids
/// leaking a hidden global mutable across layers and makes the dependency
/// explicit and injectable for tests.
class CreateNoteService extends ChangeNotifier {
CreateNoteParams? _pending;

/// The pending note-creation request, or null when idle.
CreateNoteParams? get pending => _pending;

/// Enqueue a new note-creation request and notify listeners.
void request(CreateNoteParams params) {
_pending = params;
notifyListeners();
}

/// Mark the pending request as handled (consume it).
void consume() {
_pending = null;
}
}
Loading
Loading