From 60acf8c889abeced14cc6da9d7827034c373cc77 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 28 Mar 2024 17:46:31 +0800 Subject: [PATCH] feat: collab cursor/selection (#4983) * feat: support collab selection * feat: collab cusro/selection * chore: add metadata field * feat: support displaying user name above cursor * fix: emit error * feat: support displaying collaborators * feat: sync collaborator * fix: collab doc issues * chore: update deps * feat: refactor device id * chore: enable share button * chore: update collab a816214 * fix: clippy lint * chore: use extension type instead class function * feat: add clear recent views button in debug mode * chore: support clear recent views * feat: support saving the last opened workspace * chore: update collab --- .../lib/core/config/kv_keys.dart | 5 + .../presentation/base/mobile_view_page.dart | 9 + .../presentation/home/mobile_home_page.dart | 3 + .../mobile_home_recent_views.dart | 69 +++++++- .../recent_folder/mobile_recent_view.dart | 4 +- .../setting/about/about_setting_group.dart | 2 +- .../self_host/self_host_bottom_sheet.dart | 24 --- .../setting/self_host_setting_group.dart | 8 + .../application/doc_awareness_metadata.dart | 22 +++ .../document/application/doc_bloc.dart | 154 ++++++++++++++---- ...t_adapter.dart => doc_collab_adapter.dart} | 89 +++++++++- .../application/doc_collaborators_bloc.dart | 121 ++++++++++++++ .../document/application}/doc_listener.dart | 27 ++- .../document/application/doc_service.dart | 37 +++++ .../document/application/doc_sync_bloc.dart | 15 +- .../application/doc_sync_state_listener.dart} | 0 .../document_data_pb_extension.dart | 9 +- .../lib/plugins/document/document.dart | 20 ++- .../collaborator_avater_stack.dart | 83 ++++++++++ .../presentation/document_collaborators.dart | 66 ++++++++ .../keyboard_height_observer.dart | 2 +- .../lib/shared/feature_flags.dart | 2 +- .../appflowy_flutter/lib/startup/startup.dart | 2 +- .../lib/startup/tasks/device_info_task.dart | 44 ++++- .../lib/user/application/auth/device_id.dart | 2 +- .../util/color_generator/color_generator.dart | 7 +- .../lib/util/color_to_hex_string.dart | 10 +- .../application/user/user_workspace_bloc.dart | 21 ++- .../home/menu/sidebar/sidebar.dart | 3 + .../home/menu/sidebar/sidebar_workspace.dart | 2 +- .../workspace/_sidebar_workspace_icon.dart | 4 +- .../settings/widgets/settings_menu.dart | 19 ++- .../more_view_actions/more_view_actions.dart | 7 +- .../presentation/widgets/user_avatar.dart | 2 +- frontend/appflowy_flutter/pubspec.lock | 12 +- frontend/appflowy_flutter/pubspec.yaml | 5 +- frontend/appflowy_tauri/src-tauri/Cargo.lock | 7 - frontend/appflowy_tauri/src-tauri/Cargo.toml | 14 +- frontend/appflowy_web/wasm-libs/Cargo.toml | 14 +- frontend/rust-lib/Cargo.lock | 14 +- frontend/rust-lib/Cargo.toml | 14 +- .../src/deps_resolve/document_deps.rs | 8 + .../rust-lib/flowy-document/src/document.rs | 28 +++- .../rust-lib/flowy-document/src/entities.rs | 132 ++++++++++++++- .../flowy-document/src/event_handler.rs | 21 ++- .../rust-lib/flowy-document/src/event_map.rs | 8 + .../rust-lib/flowy-document/src/manager.rs | 32 ++++ .../flowy-document/src/notification.rs | 2 + .../flowy-document/tests/document/util.rs | 4 + .../src/services/authenticate_user.rs | 4 + 50 files changed, 1050 insertions(+), 163 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/application/doc_awareness_metadata.dart rename frontend/appflowy_flutter/lib/plugins/document/application/{collab_document_adapter.dart => doc_collab_adapter.dart} (57%) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/application/doc_collaborators_bloc.dart rename frontend/appflowy_flutter/lib/{workspace/application/doc => plugins/document/application}/doc_listener.dart (58%) rename frontend/appflowy_flutter/lib/{workspace/application/doc/sync_state_listener.dart => plugins/document/application/doc_sync_state_listener.dart} (100%) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 94103ffef8..50fdebd203 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -64,4 +64,9 @@ class KVKeys { /// The value is a json string with the following format: /// {'feature_flag_1': true, 'feature_flag_2': false} static const String featureFlag = 'featureFlag'; + + /// The key for saving the last opened workspace id + /// + /// The workspace id is a string. + static const String lastOpenedWorkspaceId = 'lastOpenedWorkspaceId'; } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 038da31ebf..83205128ed 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -4,6 +4,7 @@ import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/shared/feature_flags.dart'; @@ -74,6 +75,14 @@ class _MobileViewPageState extends State { viewPB = view; actions.addAll([ if (FeatureFlag.syncDocument.isOn) ...[ + DocumentCollaborators( + width: 60, + height: 44, + fontSize: 14, + padding: const EdgeInsets.symmetric(vertical: 8), + view: view, + ), + const HSpace(16.0), DocumentSyncIndicator(view: view), const HSpace(8.0), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart index bae27a8b5b..69759fc508 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -94,6 +94,9 @@ class MobileHomePage extends StatelessWidget { previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, builder: (context, state) { + if (state.currentWorkspace == null) { + return const SizedBox.shrink(); + } return Column( children: [ // Header diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart index 535271aadf..1cf3b5515b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart @@ -1,5 +1,8 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_recent_view.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/recent/prelude.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; @@ -7,6 +10,7 @@ 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'; +import 'package:go_router/go_router.dart'; class MobileRecentFolder extends StatefulWidget { const MobileRecentFolder({super.key}); @@ -76,11 +80,70 @@ class _RecentViews extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: FlowyText.semibold( - LocaleKeys.sideBar_recent.tr(), - fontSize: 20.0, + child: GestureDetector( + child: FlowyText.semibold( + LocaleKeys.sideBar_recent.tr(), + fontSize: 20.0, + ), + onTap: () { + showMobileBottomSheet( + context, + showDivider: false, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.background, + builder: (_) { + return Column( + children: [ + FlowyOptionTile.text( + text: LocaleKeys.button_clear.tr(), + leftIcon: FlowySvg( + FlowySvgs.m_delete_s, + color: Theme.of(context).colorScheme.error, + ), + textColor: Theme.of(context).colorScheme.error, + onTap: () { + context.read().add( + RecentViewsEvent.removeRecentViews( + recentViews.map((e) => e.id).toList(), + ), + ); + context.pop(); + }, + ), + ], + ); + }, + ); + }, ), ), + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // Padding( + // padding: const EdgeInsets.symmetric(horizontal: 24), + // child: FlowyText.semibold( + // LocaleKeys.sideBar_recent.tr(), + // fontSize: 20.0, + // ), + // ), + // if (kDebugMode) + // Padding( + // padding: const EdgeInsets.only(right: 16.0), + // child: FlowyButton( + // useIntrinsicWidth: true, + // text: FlowyText(LocaleKeys.button_clear.tr()), + // onTap: () { + // context.read().add( + // RecentViewsEvent.removeRecentViews( + // recentViews.map((e) => e.id).toList(), + // ), + // ); + // }, + // ), + // ), + // ], + // ), SingleChildScrollView( key: const PageStorageKey('recent_views_page_storage_key'), scrollDirection: Axis.horizontal, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart index b315cb5e52..aa938160ae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -2,10 +2,10 @@ import 'dart:io'; import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/plugins/document/application/doc_listener.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; -import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/view/prelude.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; @@ -53,7 +53,7 @@ class _MobileRecentViewState extends State { documentListener = DocumentListener(id: view.id) ..start( - didReceiveUpdate: (document) { + onDocEventUpdate: (document) { setState(() { view = view; }); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart index 6697db99c9..337ce2549d 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -47,7 +47,7 @@ class AboutSettingGroup extends StatelessWidget { MobileSettingItem( name: LocaleKeys.settings_mobile_version.tr(), trailing: FlowyText( - '${DeviceOrApplicationInfoTask.applicationVersion} (${DeviceOrApplicationInfoTask.buildNumber})', + '${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})', color: Theme.of(context).colorScheme.onSurface, ), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart index 840306f34a..ebc58290b9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart @@ -5,7 +5,6 @@ import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; class SelfHostUrlBottomSheet extends StatefulWidget { const SelfHostUrlBottomSheet({ @@ -38,32 +37,9 @@ class _SelfHostUrlBottomSheetState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - LocaleKeys.editor_urlHint.tr(), - style: theme.textTheme.labelSmall, - ), - IconButton( - icon: Icon( - Icons.close, - color: theme.hintColor, - ), - onPressed: () { - context.pop(); - }, - ), - ], - ), - const SizedBox( - height: 16, - ), Form( key: _formKey, child: TextFormField( diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart index 060dacedf0..095214d6ef 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart @@ -36,6 +36,14 @@ class _SelfHostSettingGroupState extends State { onTap: () { showMobileBottomSheet( context, + showHeader: true, + title: LocaleKeys.editor_urlHint.tr(), + showCloseButton: true, + showDivider: false, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), builder: (_) { return SelfHostUrlBottomSheet( url: url, diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_awareness_metadata.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_awareness_metadata.dart new file mode 100644 index 0000000000..2aa288c58b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_awareness_metadata.dart @@ -0,0 +1,22 @@ +// This file is "main.dart" +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'doc_awareness_metadata.freezed.dart'; +part 'doc_awareness_metadata.g.dart'; + +@freezed +class DocumentAwarenessMetadata with _$DocumentAwarenessMetadata { + const factory DocumentAwarenessMetadata({ + // ignore: invalid_annotation_target + @JsonKey(name: 'cursor_color') required String cursorColor, + // ignore: invalid_annotation_target + @JsonKey(name: 'selection_color') required String selectionColor, + // ignore: invalid_annotation_target + @JsonKey(name: 'user_name') required String userName, + // ignore: invalid_annotation_target + @JsonKey(name: 'user_avatar') required String userAvatar, + }) = _DocumentAwarenessMetadata; + + factory DocumentAwarenessMetadata.fromJson(Map json) => + _$DocumentAwarenessMetadataFromJson(json); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart index 560397a33d..aefa957358 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_bloc.dart @@ -1,15 +1,21 @@ import 'dart:async'; +import 'dart:convert'; -import 'package:appflowy/plugins/document/application/collab_document_adapter.dart'; +import 'package:appflowy/plugins/document/application/doc_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/doc_collab_adapter.dart'; +import 'package:appflowy/plugins/document/application/doc_listener.dart'; import 'package:appflowy/plugins/document/application/doc_service.dart'; +import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/doc/doc_listener.dart'; -import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/util/debounce.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; @@ -50,14 +56,17 @@ class DocumentBloc extends Bloc { final DocumentService _documentService = DocumentService(); final TrashService _trashService = TrashService(); - late CollabDocumentAdapter _collabDocumentAdapter; + late DocumentCollabAdapter _documentCollabAdapter; late final TransactionAdapter _transactionAdapter = TransactionAdapter( documentId: view.id, documentService: _documentService, ); - StreamSubscription? _subscription; + StreamSubscription? _transactionSubscription; + + final _updateSelectionDebounce = Debounce(); + final _syncDocDebounce = Debounce(); bool get isLocalMode { final userProfilePB = state.userProfilePB; @@ -70,7 +79,7 @@ class DocumentBloc extends Bloc { await _documentListener.stop(); await _syncStateListener.stop(); await _viewListener.stop(); - await _subscription?.cancel(); + await _transactionSubscription?.cancel(); await _documentService.closeDocument(view: view); state.editorState?.service.keyboardService?.closeKeyboard(); state.editorState?.dispose(); @@ -104,6 +113,9 @@ class DocumentBloc extends Bloc { ), ); emit(newState); + if (newState.userProfilePB != null) { + await _updateCollaborator(); + } }, moveToTrash: () async { emit(state.copyWith(isDeleted: true)); @@ -143,7 +155,8 @@ class DocumentBloc extends Bloc { /// subscribe to the document content change void _onDocumentChanged() { _documentListener.start( - didReceiveUpdate: syncDocumentDataPB, + onDocEventUpdate: _debounceSyncDoc, + onDocAwarenessUpdate: _onAwarenessStatesUpdate, ); _syncStateListener.start( @@ -173,24 +186,31 @@ class DocumentBloc extends Bloc { final editorState = EditorState(document: document); - _collabDocumentAdapter = CollabDocumentAdapter(editorState, view.id); + _documentCollabAdapter = DocumentCollabAdapter(editorState, view.id); // subscribe to the document change from the editor - _subscription = editorState.transactionStream.listen((event) async { - final time = event.$1; - if (time != TransactionTime.before) { - return; - } - await _transactionAdapter.apply(event.$2, editorState); + _transactionSubscription = editorState.transactionStream.listen( + (event) async { + final time = event.$1; + final transaction = event.$2; + if (time != TransactionTime.before) { + return; + } - // check if the document is empty. - await applyRules(); + // apply transaction to backend + await _transactionAdapter.apply(transaction, editorState); - if (!isClosed) { - // ignore: invalid_use_of_visible_for_testing_member - emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); - } - }); + // check if the document is empty. + await _applyRules(); + + if (!isClosed) { + // ignore: invalid_use_of_visible_for_testing_member + emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); + } + }, + ); + + editorState.selectionNotifier.addListener(_debounceOnSelectionUpdate); // output the log from the editor when debug mode if (kDebugMode) { @@ -204,14 +224,14 @@ class DocumentBloc extends Bloc { return editorState; } - Future applyRules() async { + Future _applyRules() async { await Future.wait([ - ensureAtLeastOneParagraphExists(), - ensureLastNodeIsEditable(), + _ensureAtLeastOneParagraphExists(), + _ensureLastNodeIsEditable(), ]); } - Future ensureLastNodeIsEditable() async { + Future _ensureLastNodeIsEditable() async { final editorState = state.editorState; if (editorState == null) { return; @@ -226,7 +246,7 @@ class DocumentBloc extends Bloc { } } - Future ensureAtLeastOneParagraphExists() async { + Future _ensureAtLeastOneParagraphExists() async { final editorState = state.editorState; if (editorState == null) { return; @@ -242,12 +262,89 @@ class DocumentBloc extends Bloc { } } - Future syncDocumentDataPB(DocEventPB docEvent) async { + Future _onDocumentStateUpdate(DocEventPB docEvent) async { if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { return; } - await _collabDocumentAdapter.syncV3(); + unawaited(_documentCollabAdapter.syncV3(docEvent)); + } + + Future _onAwarenessStatesUpdate( + DocumentAwarenessStatesPB awarenessStates, + ) async { + if (!FeatureFlag.syncDocument.isOn) { + return; + } + + final userId = state.userProfilePB?.id; + if (userId != null) { + await _documentCollabAdapter.updateRemoteSelection( + userId.toString(), + awarenessStates, + ); + } + } + + void _debounceOnSelectionUpdate() { + _updateSelectionDebounce.call(_onSelectionUpdate); + } + + void _debounceSyncDoc(DocEventPB docEvent) { + _syncDocDebounce.call(() { + _onDocumentStateUpdate(docEvent); + }); + } + + Future _onSelectionUpdate() async { + final user = state.userProfilePB; + final deviceId = ApplicationInfo.deviceId; + if (!FeatureFlag.syncDocument.isOn || user == null) { + return; + } + + final editorState = state.editorState; + if (editorState == null) { + return; + } + final selection = editorState.selection; + + // sync the selection + final id = user.id.toString() + deviceId; + final basicColor = ColorGenerator(id.toString()).toColor(); + final metadata = DocumentAwarenessMetadata( + cursorColor: basicColor.toHexString(), + selectionColor: basicColor.withOpacity(0.6).toHexString(), + userName: user.name, + userAvatar: user.iconUrl, + ); + await _documentService.syncAwarenessStates( + documentId: view.id, + selection: selection, + metadata: jsonEncode(metadata.toJson()), + ); + } + + Future _updateCollaborator() async { + final user = state.userProfilePB; + final deviceId = ApplicationInfo.deviceId; + if (!FeatureFlag.syncDocument.isOn || user == null) { + return; + } + + // sync the selection + final id = user.id.toString() + deviceId; + final basicColor = ColorGenerator(id.toString()).toColor(); + final metadata = DocumentAwarenessMetadata( + cursorColor: basicColor.toHexString(), + selectionColor: basicColor.withOpacity(0.6).toHexString(), + userName: user.name, + userAvatar: user.iconUrl, + ); + await _documentService.syncAwarenessStates( + documentId: view.id, + metadata: jsonEncode(metadata.toJson()), + ); } } @@ -274,6 +371,7 @@ class DocumentState with _$DocumentState { UserProfilePB? userProfilePB, EditorState? editorState, FlowyError? error, + @Default(null) DocumentAwarenessStatesPB? awarenessStates, }) = _DocumentState; factory DocumentState.initial() => const DocumentState( diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_collab_adapter.dart similarity index 57% rename from frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart rename to frontend/appflowy_flutter/lib/plugins/document/application/doc_collab_adapter.dart index 6c971ffdeb..656b0c9d85 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/collab_document_adapter.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_collab_adapter.dart @@ -1,15 +1,21 @@ import 'dart:convert'; +import 'package:appflowy/plugins/document/application/doc_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:appflowy/util/json_print.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; -class CollabDocumentAdapter { - CollabDocumentAdapter(this.editorState, this.docId); +class DocumentCollabAdapter { + DocumentCollabAdapter(this.editorState, this.docId); final EditorState editorState; final String docId; @@ -61,7 +67,7 @@ class CollabDocumentAdapter { /// Sync version 3 /// /// Diff the local document with the remote document and apply the changes - Future syncV3() async { + Future syncV3(DocEventPB docEvent) async { final result = await _service.getDocument(viewId: docId); final document = result.fold((s) => s.toDocument(), (f) => null); if (document == null) { @@ -70,9 +76,12 @@ class CollabDocumentAdapter { final ops = diffNodes(editorState.document.root, document.root); if (ops.isEmpty) { + debugPrint('[collab] received empty ops'); return; } + debugPrint('[collab] received ops: $ops'); + final transaction = editorState.transaction; for (final op in ops) { transaction.add(op); @@ -122,6 +131,80 @@ class CollabDocumentAdapter { } } } + + Future updateRemoteSelection( + String userId, + DocumentAwarenessStatesPB states, + ) async { + final List remoteSelections = []; + final deviceId = ApplicationInfo.deviceId; + for (final state in states.value.values) { + // the following code is only for version 1 + if (state.version != 1) { + return; + } + final uid = state.user.uid.toString(); + final did = state.user.deviceId; + final metadata = DocumentAwarenessMetadata.fromJson( + jsonDecode(state.metadata), + ); + final selectionColor = metadata.selectionColor.tryToColor(); + final cursorColor = metadata.cursorColor.tryToColor(); + if ((uid == userId && did == deviceId) || + (cursorColor == null || selectionColor == null)) { + continue; + } + final start = state.selection.start; + final end = state.selection.end; + final selection = Selection( + start: Position( + path: start.path.toIntList(), + offset: start.offset.toInt(), + ), + end: Position( + path: end.path.toIntList(), + offset: end.offset.toInt(), + ), + ); + final color = ColorGenerator(uid + did).toColor(); + final remoteSelection = RemoteSelection( + id: uid, + selection: selection, + selectionColor: selectionColor, + cursorColor: cursorColor, + builder: (_, __, rect) { + return Positioned( + top: rect.top - 10, + left: selection.isCollapsed ? rect.right : rect.left, + child: ColoredBox( + color: color, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2.0, + vertical: 1.0, + ), + child: FlowyText( + metadata.userName, + color: Colors.black, + fontSize: 12.0, + ), + ), + ), + ); + }, + ); + remoteSelections.add(remoteSelection); + } + if (remoteSelections.isNotEmpty) { + editorState.remoteSelections.value = remoteSelections; + } + } +} + +extension on List { + List toIntList() { + return map((e) => e.toInt()).toList(); + } } extension on List { diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_collaborators_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_collaborators_bloc.dart new file mode 100644 index 0000000000..902476d4c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_collaborators_bloc.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/doc_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/doc_listener.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'doc_collaborators_bloc.freezed.dart'; + +bool _filterCurrentUser = false; + +class DocumentCollaboratorsBloc + extends Bloc { + DocumentCollaboratorsBloc({ + required this.view, + }) : _listener = DocumentListener(id: view.id), + super(DocumentCollaboratorsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final result = await getIt().getUser(); + final userProfile = result.fold((s) => s, (f) => null); + final deviceId = ApplicationInfo.deviceId; + _listener.start( + onDocAwarenessUpdate: (states) { + if (userProfile == null) { + return; + } + add( + DocumentCollaboratorsEvent.update( + userProfile, + deviceId, + states, + ), + ); + }, + ); + }, + update: (userProfile, deviceId, states) { + final collaborators = _buildCollaborators( + userProfile, + deviceId, + states, + ); + emit(state.copyWith(collaborators: collaborators)); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + List _buildCollaborators( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) { + final result = []; + final ids = {}; + final sorted = states.value.values.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)) + ..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId)); + for (final state in sorted) { + if (state.version != 1) { + continue; + } + // filter current user + if (_filterCurrentUser && + userProfile.id == state.user.uid && + deviceId == state.user.deviceId) { + continue; + } + try { + final metadata = DocumentAwarenessMetadata.fromJson( + jsonDecode(state.metadata), + ); + result.add(metadata); + } catch (e) { + Log.error('Failed to parse metadata: $e'); + } + } + return result; + } +} + +@freezed +class DocumentCollaboratorsEvent with _$DocumentCollaboratorsEvent { + const factory DocumentCollaboratorsEvent.initial() = Initial; + const factory DocumentCollaboratorsEvent.update( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) = Update; +} + +@freezed +class DocumentCollaboratorsState with _$DocumentCollaboratorsState { + const factory DocumentCollaboratorsState({ + @Default([]) List collaborators, + }) = _DocumentCollaboratorsState; + + factory DocumentCollaboratorsState.initial() => + const DocumentCollaboratorsState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_listener.dart similarity index 58% rename from frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart rename to frontend/appflowy_flutter/lib/plugins/document/application/doc_listener.dart index 61ff86edcd..ab102c7ee8 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/doc/doc_listener.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_listener.dart @@ -8,6 +8,11 @@ import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; +typedef OnDocumentEventUpdate = void Function(DocEventPB docEvent); +typedef OnDocumentAwarenessStateUpdate = void Function( + DocumentAwarenessStatesPB awarenessStates, +); + class DocumentListener { DocumentListener({ required this.id, @@ -18,12 +23,15 @@ class DocumentListener { StreamSubscription? _subscription; DocumentNotificationParser? _parser; - Function(DocEventPB docEvent)? didReceiveUpdate; + OnDocumentEventUpdate? _onDocEventUpdate; + OnDocumentAwarenessStateUpdate? _onDocAwarenessUpdate; void start({ - Function(DocEventPB docEvent)? didReceiveUpdate, + OnDocumentEventUpdate? onDocEventUpdate, + OnDocumentAwarenessStateUpdate? onDocAwarenessUpdate, }) { - this.didReceiveUpdate = didReceiveUpdate; + _onDocEventUpdate = onDocEventUpdate; + _onDocAwarenessUpdate = onDocAwarenessUpdate; _parser = DocumentNotificationParser( id: id, @@ -40,7 +48,16 @@ class DocumentListener { ) { switch (ty) { case DocumentNotification.DidReceiveUpdate: - result.map((r) => didReceiveUpdate?.call(DocEventPB.fromBuffer(r))); + result.map( + (s) => _onDocEventUpdate?.call(DocEventPB.fromBuffer(s)), + ); + break; + case DocumentNotification.DidUpdateDocumentAwarenessState: + result.map( + (s) => _onDocAwarenessUpdate?.call( + DocumentAwarenessStatesPB.fromBuffer(s), + ), + ); break; default: break; @@ -48,6 +65,8 @@ class DocumentListener { } Future stop() async { + _onDocAwarenessUpdate = null; + _onDocEventUpdate = null; await _subscription?.cancel(); _subscription = null; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart index 110a5dc766..7c7f09ddf6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_service.dart @@ -2,7 +2,9 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; class DocumentService { // unused now. @@ -143,4 +145,39 @@ class DocumentService { return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); }); } + + /// Sync the awareness states + /// For example, the cursor position, selection, who is viewing the document. + Future> syncAwarenessStates({ + required String documentId, + Selection? selection, + String? metadata, + }) async { + final payload = UpdateDocumentAwarenessStatePB( + documentId: documentId, + selection: convertSelectionToAwarenessSelection(selection), + metadata: metadata, + ); + + final result = await DocumentEventSetAwarenessState(payload).send(); + return result; + } + + DocumentAwarenessSelectionPB? convertSelectionToAwarenessSelection( + Selection? selection, + ) { + if (selection == null) { + return null; + } + return DocumentAwarenessSelectionPB( + start: DocumentAwarenessPositionPB( + offset: Int64(selection.startIndex), + path: selection.start.path.map((e) => Int64(e)), + ), + end: DocumentAwarenessPositionPB( + offset: Int64(selection.endIndex), + path: selection.end.path.map((e) => Int64(e)), + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart index 078727bc94..8214bacedf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_bloc.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/workspace/application/doc/sync_state_listener.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -49,12 +49,13 @@ class DocumentSyncBloc extends Bloc { connectivityStream = _connectivity.onConnectivityChanged.listen((result) { - if (!isClosed) {} - emit( - state.copyWith( - isNetworkConnected: result != ConnectivityResult.none, - ), - ); + if (!isClosed) { + emit( + state.copyWith( + isNetworkConnected: result != ConnectivityResult.none, + ), + ); + } }); }, syncStateChanged: (syncState) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart similarity index 100% rename from frontend/appflowy_flutter/lib/workspace/application/doc/sync_state_listener.dart rename to frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart index 9762ae7020..e60782605a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -144,10 +144,11 @@ extension BlockToNode on BlockPB { final deltaString = meta.textMap[externalId]; if (deltaString != null) { final delta = jsonDecode(deltaString); - map.putIfAbsent( - 'delta', - () => delta, - ); + map['delta'] = delta; + // map.putIfAbsent( + // 'delta', + // () => delta, + // ); } } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 352258cc9e..882758f87a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; import 'package:appflowy/plugins/document/presentation/document_sync_indicator.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/plugins/util.dart'; @@ -140,20 +141,27 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder child: Row( mainAxisSize: MainAxisSize.min, children: [ - DocumentShareButton( - key: ValueKey('share_button_${view.id}'), - view: view, - ), ...FeatureFlag.syncDocument.isOn ? [ - const HSpace(20), + DocumentCollaborators( + key: ValueKey('collaborators_${view.id}'), + width: 100, + height: 32, + view: view, + ), + const HSpace(16), DocumentSyncIndicator( key: ValueKey('sync_state_${view.id}'), view: view, ), - const HSpace(12), + const HSpace(16), ] : [const HSpace(8)], + DocumentShareButton( + key: ValueKey('share_button_${view.id}'), + view: view, + ), + const HSpace(4), ViewFavoriteButton( key: ValueKey('favorite_button_${view.id}'), view: view, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart new file mode 100644 index 0000000000..bf1e47fd13 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart @@ -0,0 +1,83 @@ +import 'package:avatar_stack/avatar_stack.dart'; +import 'package:avatar_stack/positions.dart'; +import 'package:flutter/material.dart'; + +class CollaboratorAvatarStack extends StatelessWidget { + const CollaboratorAvatarStack({ + super.key, + required this.avatars, + this.settings, + this.infoWidgetBuilder, + this.width, + this.height, + this.borderWidth, + this.borderColor, + this.backgroundColor, + }); + + final List avatars; + + final Positions? settings; + + final InfoWidgetBuilder? infoWidgetBuilder; + + final double? width; + + final double? height; + + final double? borderWidth; + + final Color? borderColor; + + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final settings = this.settings ?? + RestrictedPositions( + maxCoverage: 0.3, + minCoverage: 0.1, + align: StackAlign.right, + ); + + final border = BorderSide( + color: borderColor ?? Theme.of(context).colorScheme.onPrimary, + width: borderWidth ?? 2.0, + ); + + Widget textInfoWidgetBuilder(surplus) => BorderedCircleAvatar( + border: border, + backgroundColor: backgroundColor, + child: FittedBox( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '+$surplus', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ); + final infoWidgetBuilder = this.infoWidgetBuilder ?? textInfoWidgetBuilder; + + return SizedBox( + height: height, + width: width, + child: WidgetStack( + positions: settings, + buildInfoWidget: infoWidgetBuilder, + stackedWidgets: avatars + .map( + (avatar) => CircleAvatar( + backgroundColor: border.color, + child: Padding( + padding: EdgeInsets.all(border.width), + child: avatar, + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart new file mode 100644 index 0000000000..0d1e074573 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/plugins/document/application/doc_collaborators_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/collaborator_avater_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentCollaborators extends StatelessWidget { + const DocumentCollaborators({ + super.key, + required this.height, + required this.width, + required this.view, + this.padding, + this.fontSize, + }); + + final ViewPB view; + final double height; + final double width; + final EdgeInsets? padding; + final double? fontSize; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DocumentCollaboratorsBloc(view: view) + ..add(const DocumentCollaboratorsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final collaborators = state.collaborators; + if (collaborators.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: padding ?? EdgeInsets.zero, + child: CollaboratorAvatarStack( + height: height, + width: width, + borderWidth: 1.0, + backgroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + avatars: collaborators + .map( + (c) => FlowyTooltip( + message: c.userName, + child: CircleAvatar( + backgroundColor: c.selectionColor.tryToColor(), + child: FlowyText( + c.userName.characters.firstOrNull ?? ' ', + fontSize: fontSize, + ), + ), + ), + ) + .toList(), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart index 5037add78b..912bdb044f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart @@ -35,7 +35,7 @@ class KeyboardHeightObserver { void notify(double height) { // the keyboard height will notify twice with the same value on Android 14 - if (DeviceOrApplicationInfoTask.androidSDKVersion == 34) { + if (ApplicationInfo.androidSDKVersion == 34) { if (height == 0 && currentKeyboardHeight == 0) { return; } diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart index 4bc9271e55..496d9534e8 100644 --- a/frontend/appflowy_flutter/lib/shared/feature_flags.dart +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -87,7 +87,7 @@ enum FeatureFlag { case FeatureFlag.membersSettings: return false; case FeatureFlag.syncDocument: - return false; + return true; case FeatureFlag.unknown: return false; } diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index 38a4911da8..0454bea651 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -130,7 +130,7 @@ class FlowyRunner { if (!mode.isUnitTest) ...[ // The DeviceOrApplicationInfoTask should be placed before the AppWidgetTask to fetch the app information. // It is unable to get the device information from the test environment. - const DeviceOrApplicationInfoTask(), + const ApplicationInfoTask(), const HotKeyTask(), if (isSupabaseEnabled) InitSupabaseTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart index 61225b8f58..61e1f52460 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart @@ -1,16 +1,20 @@ import 'dart:io'; +import 'package:appflowy_backend/log.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../startup.dart'; -class DeviceOrApplicationInfoTask extends LaunchTask { - const DeviceOrApplicationInfoTask(); - +class ApplicationInfo { static int androidSDKVersion = -1; static String applicationVersion = ''; static String buildNumber = ''; + static String deviceId = ''; +} + +class ApplicationInfoTask extends LaunchTask { + const ApplicationInfoTask(); @override Future initialize(LaunchContext context) async { @@ -19,13 +23,41 @@ class DeviceOrApplicationInfoTask extends LaunchTask { if (Platform.isAndroid) { final androidInfo = await deviceInfoPlugin.androidInfo; - androidSDKVersion = androidInfo.version.sdkInt; + ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt; } if (Platform.isAndroid || Platform.isIOS) { - applicationVersion = packageInfo.version; - buildNumber = packageInfo.buildNumber; + ApplicationInfo.applicationVersion = packageInfo.version; + ApplicationInfo.buildNumber = packageInfo.buildNumber; } + + String? deviceId; + try { + if (Platform.isAndroid) { + final AndroidDeviceInfo androidInfo = + await deviceInfoPlugin.androidInfo; + deviceId = androidInfo.device; + } else if (Platform.isIOS) { + final IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; + deviceId = iosInfo.identifierForVendor; + } else if (Platform.isMacOS) { + final MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo; + deviceId = macInfo.systemGUID; + } else if (Platform.isWindows) { + final WindowsDeviceInfo windowsInfo = + await deviceInfoPlugin.windowsInfo; + deviceId = windowsInfo.deviceId; + } else if (Platform.isLinux) { + final LinuxDeviceInfo linuxInfo = await deviceInfoPlugin.linuxInfo; + deviceId = linuxInfo.machineId; + } else { + deviceId = null; + } + } catch (e) { + Log.error('Failed to get platform version, $e'); + } + + ApplicationInfo.deviceId = deviceId ?? ''; } @override diff --git a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart index ba8161d5a0..2d7fe580ae 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart @@ -25,7 +25,7 @@ Future getDeviceId() async { deviceId = macInfo.systemGUID; } else if (Platform.isWindows) { final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; - deviceId = windowsInfo.computerName; + deviceId = windowsInfo.deviceId; } else if (Platform.isLinux) { final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; deviceId = linuxInfo.machineId; diff --git a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart index c77650443e..6777beb0e1 100644 --- a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart +++ b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -class ColorGenerator { - static Color generateColorFromString(String string) { - final int hash = - string.codeUnits.fold(0, (int acc, int unit) => acc + unit); +extension type ColorGenerator(String value) { + Color toColor() { + final int hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit); final double hue = (hash % 360).toDouble(); return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor(); } diff --git a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart index b7fcbb9443..34925235cb 100644 --- a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart +++ b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart @@ -1,8 +1,16 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; -extension ColorExtensionn on Color { +extension ColorExtension on Color { /// return a hex string in 0xff000000 format String toHexString() { return '0x${value.toRadixString(16).padLeft(8, '0')}'; } + + /// return a random color + static Color random({double opacity = 1.0}) { + return Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) + .withOpacity(opacity); + } } 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 96558d5c04..e5d10b8af4 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 @@ -1,5 +1,8 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; @@ -135,6 +138,12 @@ class UserWorkspaceBloc extends Bloc { ), (e) => state.currentWorkspace, ); + result.onSuccess((_) async { + await getIt().set( + KVKeys.lastOpenedWorkspaceId, + workspaceId, + ); + }); emit( state.copyWith( currentWorkspace: currentWorkspace, @@ -220,11 +229,21 @@ class UserWorkspaceBloc extends Bloc { Future<(UserWorkspacePB currentWorkspace, List workspaces)?> _fetchWorkspaces() async { try { + final lastOpenedWorkspaceId = await getIt().get( + KVKeys.lastOpenedWorkspaceId, + ); final currentWorkspace = await _userService.getCurrentWorkspace().getOrThrow(); final workspaces = await _userService.getWorkspaces().getOrThrow(); - final currentWorkspaceInList = + UserWorkspacePB currentWorkspaceInList = workspaces.firstWhere((e) => e.workspaceId == currentWorkspace.id); + if (lastOpenedWorkspaceId != null) { + final lastOpenedWorkspace = workspaces + .firstWhereOrNull((e) => e.workspaceId == lastOpenedWorkspaceId); + if (lastOpenedWorkspace != null) { + currentWorkspaceInList = lastOpenedWorkspace; + } + } return (currentWorkspaceInList, workspaces); } catch (e) { Log.error('fetch workspace error: $e'); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index a35248629b..4aaff8b74d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -67,6 +67,9 @@ class HomeSideBar extends StatelessWidget { previous.currentWorkspace?.workspaceId != current.currentWorkspace?.workspaceId, builder: (context, state) { + if (state.currentWorkspace == null) { + return const SizedBox.shrink(); + } return MultiBlocProvider( providers: [ BlocProvider( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart index bb8ab29781..7a5925db57 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart @@ -181,7 +181,7 @@ class _SidebarSwitchWorkspaceButtonState enableEdit: false, ), ), - const HSpace(4), + const HSpace(6), Expanded( child: FlowyText.medium( widget.currentWorkspace.name, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart index a4d7763bd3..ffc5083db8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -44,9 +44,7 @@ class _WorkspaceIconState extends State { width: widget.iconSize, height: max(widget.iconSize, 26), decoration: BoxDecoration( - color: ColorGenerator.generateColorFromString( - widget.workspace.name, - ), + color: ColorGenerator(widget.workspace.name).toColor(), borderRadius: BorderRadius.circular(4), ), child: FlowyText( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index 27a03aab08..f9ae9b3124 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dar import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class SettingsMenu extends StatelessWidget { @@ -79,15 +80,15 @@ class SettingsMenu extends StatelessWidget { icon: Icons.people, changeSelectedPage: changeSelectedPage, ), - // if (kDebugMode) - // SettingsMenuElement( - // // no need to translate this page - // page: SettingsPage.featureFlags, - // selectedPage: currentPage, - // label: 'Feature Flags', - // icon: Icons.flag, - // changeSelectedPage: changeSelectedPage, - // ), + if (kDebugMode) + SettingsMenuElement( + // no need to translate this page + page: SettingsPage.featureFlags, + selectedPage: currentPage, + label: 'Feature Flags', + icon: Icons.flag, + changeSelectedPage: changeSelectedPage, + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart index c8634b3df5..8750eddb0b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; @@ -13,6 +11,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MoreViewActions extends StatefulWidget { @@ -105,8 +104,8 @@ class _MoreViewActionsState extends State { builder: (context, isHovering) => Padding( padding: const EdgeInsets.all(6), child: FlowySvg( - FlowySvgs.details_s, - size: const Size(18, 18), + FlowySvgs.three_dots_vertical_s, + size: const Size.square(16), color: isHovering ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).iconTheme.color, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart index bb2277bbf1..3f86b57181 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -29,7 +29,7 @@ class UserAvatar extends StatelessWidget { if (iconUrl.isEmpty) { final String nameOrDefault = _userName(name); - final Color color = ColorGenerator.generateColorFromString(name); + final Color color = ColorGenerator(name).toColor(); const initialsCount = 2; // Taking the first letters of the name components and limiting to 2 elements diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 45c8db1e3f..29eb4a04ca 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: a571f2b - resolved-ref: a571f2bc9df764d90569951f40364c8c59787f30 + ref: b927ec0 + resolved-ref: b927ec0685c870c731c5b6d9688a031d0cd31e76 url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "2.3.3" @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + avatar_stack: + dependency: "direct main" + description: + name: avatar_stack + sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49 + url: "https://pub.dev" + source: hosted + version: "1.2.0" bloc: dependency: "direct main" description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 64ea852529..3bc2a9ba3c 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -19,7 +19,7 @@ version: 0.5.3 environment: flutter: ">=3.19.0" - sdk: ">=3.1.5 <4.0.0" + sdk: ">=3.3.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -132,6 +132,7 @@ dependencies: share_plus: ^7.2.1 sheet: file: ^7.0.0 + avatar_stack: ^1.2.0 dev_dependencies: flutter_lints: ^3.0.1 @@ -168,7 +169,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "a571f2b" + ref: "b927ec0" sheet: git: diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index c31ad69794..41f06c9d12 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -838,7 +838,6 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "async-trait", @@ -862,7 +861,6 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "async-trait", @@ -892,7 +890,6 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "collab", @@ -911,7 +908,6 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "bytes", @@ -926,7 +922,6 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "chrono", @@ -964,7 +959,6 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "async-stream", @@ -1041,7 +1035,6 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" dependencies = [ "anyhow", "collab", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 0f9fc2560b..428b85ab29 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -96,10 +96,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } diff --git a/frontend/appflowy_web/wasm-libs/Cargo.toml b/frontend/appflowy_web/wasm-libs/Cargo.toml index 18a50c1609..5f51ae6aeb 100644 --- a/frontend/appflowy_web/wasm-libs/Cargo.toml +++ b/frontend/appflowy_web/wasm-libs/Cargo.toml @@ -65,10 +65,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 42af85e173..2a17b93a37 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "async-trait", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "async-trait", @@ -818,7 +818,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "collab", @@ -837,7 +837,7 @@ dependencies = [ [[package]] name = "collab-entity" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "bytes", @@ -852,7 +852,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "chrono", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "async-stream", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=2b42012#2b42012c830682f9bb8314a376c14229804889ff" dependencies = [ "anyhow", "collab", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index e99f0649e2..2f28584cd7 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -120,10 +120,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } -collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } +collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" } diff --git a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs index 5cc7bdbf03..1876392eeb 100644 --- a/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs +++ b/frontend/rust-lib/flowy-core/src/deps_resolve/document_deps.rs @@ -89,6 +89,14 @@ impl DocumentUserService for DocumentUserImpl { .user_id() } + fn device_id(&self) -> Result { + self + .0 + .upgrade() + .ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))? + .device_id() + } + fn workspace_id(&self) -> Result { self .0 diff --git a/frontend/rust-lib/flowy-document/src/document.rs b/frontend/rust-lib/flowy-document/src/document.rs index 928ebebea5..c83ee6a6c2 100644 --- a/frontend/rust-lib/flowy-document/src/document.rs +++ b/frontend/rust-lib/flowy-document/src/document.rs @@ -10,8 +10,11 @@ use parking_lot::Mutex; use flowy_error::FlowyResult; use lib_dispatch::prelude::af_spawn; +use tracing::trace; -use crate::entities::{DocEventPB, DocumentSnapshotStatePB, DocumentSyncStatePB}; +use crate::entities::{ + DocEventPB, DocumentAwarenessStatesPB, DocumentSnapshotStatePB, DocumentSyncStatePB, +}; use crate::notification::{send_notification, DocumentNotification}; /// This struct wrap the document::Document @@ -50,15 +53,30 @@ impl MutexDocument { } fn subscribe_document_changed(doc_id: &str, document: &MutexDocument) { - let doc_id = doc_id.to_string(); + let doc_id_clone_for_block_changed = doc_id.to_owned(); document .lock() .subscribe_block_changed(move |events, is_remote| { + trace!("subscribe_document_changed: {:?}", events); // send notification to the client. - send_notification(&doc_id, DocumentNotification::DidReceiveUpdate) - .payload::((events, is_remote).into()) - .send(); + send_notification( + &doc_id_clone_for_block_changed, + DocumentNotification::DidReceiveUpdate, + ) + .payload::((events, is_remote, None).into()) + .send(); }); + + let doc_id_clone_for_awareness_state = doc_id.to_owned(); + document.lock().subscribe_awareness_state(move |events| { + trace!("subscribe_awareness_state: {:?}", events); + send_notification( + &doc_id_clone_for_awareness_state, + DocumentNotification::DidUpdateDocumentAwarenessState, + ) + .payload::(events.into()) + .send(); + }); } fn subscribe_document_snapshot_state(collab: &Arc) { diff --git a/frontend/rust-lib/flowy-document/src/entities.rs b/frontend/rust-lib/flowy-document/src/entities.rs index 9c6b318706..65e9dcf820 100644 --- a/frontend/rust-lib/flowy-document/src/entities.rs +++ b/frontend/rust-lib/flowy-document/src/entities.rs @@ -1,7 +1,13 @@ use std::collections::HashMap; use collab::core::collab_state::SyncState; -use collab_document::blocks::{json_str_to_hashmap, Block, BlockAction, DocumentData}; +use collab_document::{ + blocks::{json_str_to_hashmap, Block, BlockAction, DocumentData}, + document_awareness::{ + DocumentAwarenessPosition, DocumentAwarenessSelection, DocumentAwarenessState, + DocumentAwarenessUser, + }, +}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; @@ -301,6 +307,9 @@ pub struct DocEventPB { #[pb(index = 2)] pub is_remote: bool, + + #[pb(index = 3, one_of)] + pub new_snapshot: Option, } #[derive(Default, ProtoBuf)] @@ -512,3 +521,124 @@ pub struct DocumentSnapshotData { pub object_id: String, pub encoded_v1: Vec, } + +#[derive(ProtoBuf, Debug, Default)] +pub struct DocumentAwarenessStatesPB { + #[pb(index = 1)] + pub value: HashMap, +} + +impl From> for DocumentAwarenessStatesPB { + fn from(value: HashMap) -> Self { + let value = value + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect(); + Self { value } + } +} + +#[derive(ProtoBuf, Debug, Default)] +pub struct UpdateDocumentAwarenessStatePB { + #[pb(index = 1)] + pub document_id: String, + #[pb(index = 2, one_of)] + pub selection: Option, + #[pb(index = 3, one_of)] + pub metadata: Option, +} + +#[derive(ProtoBuf, Debug, Default)] +pub struct DocumentAwarenessStatePB { + #[pb(index = 1)] + pub version: i64, + #[pb(index = 2)] + pub user: DocumentAwarenessUserPB, + #[pb(index = 3, one_of)] + pub selection: Option, + #[pb(index = 4, one_of)] + pub metadata: Option, + #[pb(index = 5)] + pub timestamp: i64, +} + +impl From for DocumentAwarenessStatePB { + fn from(value: DocumentAwarenessState) -> Self { + DocumentAwarenessStatePB { + version: value.version, + user: value.user.into(), + selection: value.selection.map(|s| s.into()), + metadata: value.metadata, + timestamp: value.timestamp, + } + } +} + +#[derive(ProtoBuf, Debug, Default)] +pub struct DocumentAwarenessUserPB { + #[pb(index = 1)] + pub uid: i64, + #[pb(index = 2)] + pub device_id: String, +} + +impl From for DocumentAwarenessUserPB { + fn from(value: DocumentAwarenessUser) -> Self { + DocumentAwarenessUserPB { + uid: value.uid, + device_id: value.device_id.to_string(), + } + } +} + +#[derive(ProtoBuf, Debug, Default)] +pub struct DocumentAwarenessSelectionPB { + #[pb(index = 1)] + pub start: DocumentAwarenessPositionPB, + #[pb(index = 2)] + pub end: DocumentAwarenessPositionPB, +} + +#[derive(ProtoBuf, Debug, Default)] +pub struct DocumentAwarenessPositionPB { + #[pb(index = 1)] + pub path: Vec, + #[pb(index = 2)] + pub offset: u64, +} + +impl From for DocumentAwarenessSelection { + fn from(value: DocumentAwarenessSelectionPB) -> Self { + DocumentAwarenessSelection { + start: value.start.into(), + end: value.end.into(), + } + } +} + +impl From for DocumentAwarenessSelectionPB { + fn from(value: DocumentAwarenessSelection) -> Self { + DocumentAwarenessSelectionPB { + start: value.start.into(), + end: value.end.into(), + } + } +} + +impl From for DocumentAwarenessPosition { + fn from(value: DocumentAwarenessPositionPB) -> Self { + DocumentAwarenessPosition { + path: value.path, + offset: value.offset, + } + } +} + +impl From for DocumentAwarenessPositionPB { + fn from(value: DocumentAwarenessPosition) -> Self { + DocumentAwarenessPositionPB { + path: value.path, + offset: value.offset, + } + } +} diff --git a/frontend/rust-lib/flowy-document/src/event_handler.rs b/frontend/rust-lib/flowy-document/src/event_handler.rs index b8176f79a7..a055885176 100644 --- a/frontend/rust-lib/flowy-document/src/event_handler.rs +++ b/frontend/rust-lib/flowy-document/src/event_handler.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Weak}; use collab_document::blocks::{ BlockAction, BlockActionPayload, BlockActionType, BlockEvent, BlockEventPayload, DeltaType, + DocumentData, }; use flowy_error::{FlowyError, FlowyResult}; @@ -293,12 +294,15 @@ impl From for DeltaTypePB { } } -impl From<(&Vec, bool)> for DocEventPB { - fn from((events, is_remote): (&Vec, bool)) -> Self { +impl From<(&Vec, bool, Option)> for DocEventPB { + fn from( + (events, is_remote, new_snapshot): (&Vec, bool, Option), + ) -> Self { // Convert each individual `BlockEvent` to a protobuf `BlockEventPB`, and collect the results into a `Vec` Self { events: events.iter().map(|e| e.to_owned().into()).collect(), is_remote, + new_snapshot: new_snapshot.map(|d| d.into()), } } } @@ -451,3 +455,16 @@ pub(crate) async fn delete_file_handler( let manager = upgrade_document(manager)?; manager.delete_file(local_file_path, url).await } + +pub(crate) async fn set_awareness_local_state_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_document(manager)?; + let data = data.into_inner(); + let doc_id = data.document_id.clone(); + manager + .set_document_awareness_local_state(&doc_id, data) + .await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-document/src/event_map.rs b/frontend/rust-lib/flowy-document/src/event_map.rs index 7ef1ecde5f..1e11db6356 100644 --- a/frontend/rust-lib/flowy-document/src/event_map.rs +++ b/frontend/rust-lib/flowy-document/src/event_map.rs @@ -4,6 +4,7 @@ use strum_macros::Display; use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use lib_dispatch::prelude::AFPlugin; +use tracing::event; use crate::event_handler::get_snapshot_meta_handler; use crate::{event_handler::*, manager::DocumentManager}; @@ -42,6 +43,10 @@ pub fn init(document_manager: Weak) -> AFPlugin { .event(DocumentEvent::UploadFile, upload_file_handler) .event(DocumentEvent::DownloadFile, download_file_handler) .event(DocumentEvent::DeleteFile, delete_file_handler) + .event( + DocumentEvent::SetAwarenessState, + set_awareness_local_state_handler, + ) } #[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)] @@ -118,4 +123,7 @@ pub enum DocumentEvent { DownloadFile = 16, #[event(input = "UploadedFilePB")] DeleteFile = 17, + + #[event(input = "UpdateDocumentAwarenessStatePB")] + SetAwarenessState = 18, } diff --git a/frontend/rust-lib/flowy-document/src/manager.rs b/frontend/rust-lib/flowy-document/src/manager.rs index f1d6a3f069..03d4eae0cd 100644 --- a/frontend/rust-lib/flowy-document/src/manager.rs +++ b/frontend/rust-lib/flowy-document/src/manager.rs @@ -8,10 +8,13 @@ use collab::core::origin::CollabOrigin; use collab::preclude::Collab; use collab_document::blocks::DocumentData; use collab_document::document::Document; +use collab_document::document_awareness::DocumentAwarenessState; +use collab_document::document_awareness::DocumentAwarenessUser; use collab_document::document_data::default_document_data; use collab_entity::CollabType; use collab_plugins::CollabKVDB; use flowy_storage::object_from_disk; +use lib_infra::util::timestamp; use lru::LruCache; use parking_lot::Mutex; use tokio::io::AsyncWriteExt; @@ -26,6 +29,7 @@ use flowy_storage::ObjectStorageService; use lib_dispatch::prelude::af_spawn; use crate::document::MutexDocument; +use crate::entities::UpdateDocumentAwarenessStatePB; use crate::entities::{ DocumentSnapshotData, DocumentSnapshotMeta, DocumentSnapshotMetaPB, DocumentSnapshotPB, }; @@ -33,6 +37,7 @@ use crate::reminder::DocumentReminderAction; pub trait DocumentUserService: Send + Sync { fn user_id(&self) -> Result; + fn device_id(&self) -> Result; fn workspace_id(&self) -> Result; fn collab_db(&self, uid: i64) -> Result, FlowyError>; } @@ -204,6 +209,8 @@ impl DocumentManager { if let Ok(doc) = self.get_document(doc_id).await { trace!("close document: {}", doc_id); if let Some(doc) = doc.try_lock() { + // clear the awareness state when close the document + doc.clean_awareness_local_state(); let _ = doc.flush(); } } @@ -222,6 +229,31 @@ impl DocumentManager { Ok(()) } + pub async fn set_document_awareness_local_state( + &self, + doc_id: &str, + state: UpdateDocumentAwarenessStatePB, + ) -> FlowyResult { + let uid = self.user_service.user_id()?; + let device_id = self.user_service.device_id()?; + if let Ok(doc) = self.get_document(doc_id).await { + if let Some(doc) = doc.try_lock() { + let user = DocumentAwarenessUser { uid, device_id }; + let selection = state.selection.map(|s| s.into()); + let state = DocumentAwarenessState { + version: 1, + user, + selection, + metadata: state.metadata, + timestamp: timestamp(), + }; + doc.set_awareness_local_state(state); + return Ok(true); + } + } + Ok(false) + } + /// Return the list of snapshots of the document. pub async fn get_document_snapshot_meta( &self, diff --git a/frontend/rust-lib/flowy-document/src/notification.rs b/frontend/rust-lib/flowy-document/src/notification.rs index b468ec20c7..9909971667 100644 --- a/frontend/rust-lib/flowy-document/src/notification.rs +++ b/frontend/rust-lib/flowy-document/src/notification.rs @@ -11,6 +11,7 @@ pub enum DocumentNotification { DidReceiveUpdate = 1, DidUpdateDocumentSnapshotState = 2, DidUpdateDocumentSyncState = 3, + DidUpdateDocumentAwarenessState = 4, } impl std::convert::From for i32 { @@ -24,6 +25,7 @@ impl std::convert::From for DocumentNotification { 1 => DocumentNotification::DidReceiveUpdate, 2 => DocumentNotification::DidUpdateDocumentSnapshotState, 3 => DocumentNotification::DidUpdateDocumentSyncState, + 4 => DocumentNotification::DidUpdateDocumentAwarenessState, _ => DocumentNotification::Unknown, } } diff --git a/frontend/rust-lib/flowy-document/tests/document/util.rs b/frontend/rust-lib/flowy-document/tests/document/util.rs index 7958418772..12af2008be 100644 --- a/frontend/rust-lib/flowy-document/tests/document/util.rs +++ b/frontend/rust-lib/flowy-document/tests/document/util.rs @@ -82,6 +82,10 @@ impl DocumentUserService for FakeUser { fn collab_db(&self, _uid: i64) -> Result, FlowyError> { Ok(Arc::downgrade(&self.collab_db)) } + + fn device_id(&self) -> Result { + Ok("".to_string()) + } } pub fn setup_log() { diff --git a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs index 6f560f0811..a2aad80b8b 100644 --- a/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/authenticate_user.rs @@ -56,6 +56,10 @@ impl AuthenticateUser { Ok(session.user_id) } + pub fn device_id(&self) -> FlowyResult { + Ok(self.user_config.device_id.to_string()) + } + pub fn workspace_id(&self) -> FlowyResult { let session = self.get_session()?; Ok(session.user_workspace.id)