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
This commit is contained in:
Lucas.Xu 2024-03-28 17:46:31 +08:00 committed by GitHub
parent bf98a627b9
commit 60acf8c889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1050 additions and 163 deletions

View File

@ -64,4 +64,9 @@ class KVKeys {
/// The value is a json string with the following format: /// The value is a json string with the following format:
/// {'feature_flag_1': true, 'feature_flag_2': false} /// {'feature_flag_1': true, 'feature_flag_2': false}
static const String featureFlag = 'featureFlag'; 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';
} }

View File

@ -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/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.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/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/document_sync_indicator.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/feature_flags.dart';
@ -74,6 +75,14 @@ class _MobileViewPageState extends State<MobileViewPage> {
viewPB = view; viewPB = view;
actions.addAll([ actions.addAll([
if (FeatureFlag.syncDocument.isOn) ...[ 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), DocumentSyncIndicator(view: view),
const HSpace(8.0), const HSpace(8.0),
], ],

View File

@ -94,6 +94,9 @@ class MobileHomePage extends StatelessWidget {
previous.currentWorkspace?.workspaceId != previous.currentWorkspace?.workspaceId !=
current.currentWorkspace?.workspaceId, current.currentWorkspace?.workspaceId,
builder: (context, state) { builder: (context, state) {
if (state.currentWorkspace == null) {
return const SizedBox.shrink();
}
return Column( return Column(
children: [ children: [
// Header // Header

View File

@ -1,5 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/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/recent/prelude.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.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:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class MobileRecentFolder extends StatefulWidget { class MobileRecentFolder extends StatefulWidget {
const MobileRecentFolder({super.key}); const MobileRecentFolder({super.key});
@ -76,11 +80,70 @@ class _RecentViews extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: FlowyText.semibold( child: GestureDetector(
LocaleKeys.sideBar_recent.tr(), child: FlowyText.semibold(
fontSize: 20.0, 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<RecentViewsBloc>().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<RecentViewsBloc>().add(
// RecentViewsEvent.removeRecentViews(
// recentViews.map((e) => e.id).toList(),
// ),
// );
// },
// ),
// ),
// ],
// ),
SingleChildScrollView( SingleChildScrollView(
key: const PageStorageKey('recent_views_page_storage_key'), key: const PageStorageKey('recent_views_page_storage_key'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View File

@ -2,10 +2,10 @@ import 'dart:io';
import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.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/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.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/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
@ -53,7 +53,7 @@ class _MobileRecentViewState extends State<MobileRecentView> {
documentListener = DocumentListener(id: view.id) documentListener = DocumentListener(id: view.id)
..start( ..start(
didReceiveUpdate: (document) { onDocEventUpdate: (document) {
setState(() { setState(() {
view = view; view = view;
}); });

View File

@ -47,7 +47,7 @@ class AboutSettingGroup extends StatelessWidget {
MobileSettingItem( MobileSettingItem(
name: LocaleKeys.settings_mobile_version.tr(), name: LocaleKeys.settings_mobile_version.tr(),
trailing: FlowyText( trailing: FlowyText(
'${DeviceOrApplicationInfoTask.applicationVersion} (${DeviceOrApplicationInfoTask.buildNumber})', '${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})',
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),

View File

@ -5,7 +5,6 @@ import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class SelfHostUrlBottomSheet extends StatefulWidget { class SelfHostUrlBottomSheet extends StatefulWidget {
const SelfHostUrlBottomSheet({ const SelfHostUrlBottomSheet({
@ -38,32 +37,9 @@ class _SelfHostUrlBottomSheetState extends State<SelfHostUrlBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
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( Form(
key: _formKey, key: _formKey,
child: TextFormField( child: TextFormField(

View File

@ -36,6 +36,14 @@ class _SelfHostSettingGroupState extends State<SelfHostSettingGroup> {
onTap: () { onTap: () {
showMobileBottomSheet( showMobileBottomSheet(
context, context,
showHeader: true,
title: LocaleKeys.editor_urlHint.tr(),
showCloseButton: true,
showDivider: false,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
builder: (_) { builder: (_) {
return SelfHostUrlBottomSheet( return SelfHostUrlBottomSheet(
url: url, url: url,

View File

@ -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<String, Object?> json) =>
_$DocumentAwarenessMetadataFromJson(json);
}

View File

@ -1,15 +1,21 @@
import 'dart:async'; 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_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/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.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/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/util/color_generator/color_generator.dart';
import 'package:appflowy/workspace/application/doc/sync_state_listener.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/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.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-document/protobuf.dart';
@ -50,14 +56,17 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final DocumentService _documentService = DocumentService(); final DocumentService _documentService = DocumentService();
final TrashService _trashService = TrashService(); final TrashService _trashService = TrashService();
late CollabDocumentAdapter _collabDocumentAdapter; late DocumentCollabAdapter _documentCollabAdapter;
late final TransactionAdapter _transactionAdapter = TransactionAdapter( late final TransactionAdapter _transactionAdapter = TransactionAdapter(
documentId: view.id, documentId: view.id,
documentService: _documentService, documentService: _documentService,
); );
StreamSubscription? _subscription; StreamSubscription? _transactionSubscription;
final _updateSelectionDebounce = Debounce();
final _syncDocDebounce = Debounce();
bool get isLocalMode { bool get isLocalMode {
final userProfilePB = state.userProfilePB; final userProfilePB = state.userProfilePB;
@ -70,7 +79,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
await _documentListener.stop(); await _documentListener.stop();
await _syncStateListener.stop(); await _syncStateListener.stop();
await _viewListener.stop(); await _viewListener.stop();
await _subscription?.cancel(); await _transactionSubscription?.cancel();
await _documentService.closeDocument(view: view); await _documentService.closeDocument(view: view);
state.editorState?.service.keyboardService?.closeKeyboard(); state.editorState?.service.keyboardService?.closeKeyboard();
state.editorState?.dispose(); state.editorState?.dispose();
@ -104,6 +113,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
), ),
); );
emit(newState); emit(newState);
if (newState.userProfilePB != null) {
await _updateCollaborator();
}
}, },
moveToTrash: () async { moveToTrash: () async {
emit(state.copyWith(isDeleted: true)); emit(state.copyWith(isDeleted: true));
@ -143,7 +155,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
/// subscribe to the document content change /// subscribe to the document content change
void _onDocumentChanged() { void _onDocumentChanged() {
_documentListener.start( _documentListener.start(
didReceiveUpdate: syncDocumentDataPB, onDocEventUpdate: _debounceSyncDoc,
onDocAwarenessUpdate: _onAwarenessStatesUpdate,
); );
_syncStateListener.start( _syncStateListener.start(
@ -173,24 +186,31 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final editorState = EditorState(document: document); final editorState = EditorState(document: document);
_collabDocumentAdapter = CollabDocumentAdapter(editorState, view.id); _documentCollabAdapter = DocumentCollabAdapter(editorState, view.id);
// subscribe to the document change from the editor // subscribe to the document change from the editor
_subscription = editorState.transactionStream.listen((event) async { _transactionSubscription = editorState.transactionStream.listen(
final time = event.$1; (event) async {
if (time != TransactionTime.before) { final time = event.$1;
return; final transaction = event.$2;
} if (time != TransactionTime.before) {
await _transactionAdapter.apply(event.$2, editorState); return;
}
// check if the document is empty. // apply transaction to backend
await applyRules(); await _transactionAdapter.apply(transaction, editorState);
if (!isClosed) { // check if the document is empty.
// ignore: invalid_use_of_visible_for_testing_member await _applyRules();
emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty));
} 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 // output the log from the editor when debug mode
if (kDebugMode) { if (kDebugMode) {
@ -204,14 +224,14 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
return editorState; return editorState;
} }
Future<void> applyRules() async { Future<void> _applyRules() async {
await Future.wait([ await Future.wait([
ensureAtLeastOneParagraphExists(), _ensureAtLeastOneParagraphExists(),
ensureLastNodeIsEditable(), _ensureLastNodeIsEditable(),
]); ]);
} }
Future<void> ensureLastNodeIsEditable() async { Future<void> _ensureLastNodeIsEditable() async {
final editorState = state.editorState; final editorState = state.editorState;
if (editorState == null) { if (editorState == null) {
return; return;
@ -226,7 +246,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
} }
} }
Future<void> ensureAtLeastOneParagraphExists() async { Future<void> _ensureAtLeastOneParagraphExists() async {
final editorState = state.editorState; final editorState = state.editorState;
if (editorState == null) { if (editorState == null) {
return; return;
@ -242,12 +262,89 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
} }
} }
Future<void> syncDocumentDataPB(DocEventPB docEvent) async { Future<void> _onDocumentStateUpdate(DocEventPB docEvent) async {
if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) {
return; return;
} }
await _collabDocumentAdapter.syncV3(); unawaited(_documentCollabAdapter.syncV3(docEvent));
}
Future<void> _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<void> _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<void> _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, UserProfilePB? userProfilePB,
EditorState? editorState, EditorState? editorState,
FlowyError? error, FlowyError? error,
@Default(null) DocumentAwarenessStatesPB? awarenessStates,
}) = _DocumentState; }) = _DocumentState;
factory DocumentState.initial() => const DocumentState( factory DocumentState.initial() => const DocumentState(

View File

@ -1,15 +1,21 @@
import 'dart:convert'; 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/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/prelude.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/util/json_print.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:collection/collection.dart'; 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 { class DocumentCollabAdapter {
CollabDocumentAdapter(this.editorState, this.docId); DocumentCollabAdapter(this.editorState, this.docId);
final EditorState editorState; final EditorState editorState;
final String docId; final String docId;
@ -61,7 +67,7 @@ class CollabDocumentAdapter {
/// Sync version 3 /// Sync version 3
/// ///
/// Diff the local document with the remote document and apply the changes /// Diff the local document with the remote document and apply the changes
Future<void> syncV3() async { Future<void> syncV3(DocEventPB docEvent) async {
final result = await _service.getDocument(viewId: docId); final result = await _service.getDocument(viewId: docId);
final document = result.fold((s) => s.toDocument(), (f) => null); final document = result.fold((s) => s.toDocument(), (f) => null);
if (document == null) { if (document == null) {
@ -70,9 +76,12 @@ class CollabDocumentAdapter {
final ops = diffNodes(editorState.document.root, document.root); final ops = diffNodes(editorState.document.root, document.root);
if (ops.isEmpty) { if (ops.isEmpty) {
debugPrint('[collab] received empty ops');
return; return;
} }
debugPrint('[collab] received ops: $ops');
final transaction = editorState.transaction; final transaction = editorState.transaction;
for (final op in ops) { for (final op in ops) {
transaction.add(op); transaction.add(op);
@ -122,6 +131,80 @@ class CollabDocumentAdapter {
} }
} }
} }
Future<void> updateRemoteSelection(
String userId,
DocumentAwarenessStatesPB states,
) async {
final List<RemoteSelection> 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<Int64> {
List<int> toIntList() {
return map((e) => e.toInt()).toList();
}
} }
extension on List<String> { extension on List<String> {

View File

@ -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<DocumentCollaboratorsEvent, DocumentCollaboratorsState> {
DocumentCollaboratorsBloc({
required this.view,
}) : _listener = DocumentListener(id: view.id),
super(DocumentCollaboratorsState.initial()) {
on<DocumentCollaboratorsEvent>(
(event, emit) async {
await event.when(
initial: () async {
final result = await getIt<AuthService>().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<void> close() async {
await _listener.stop();
return super.close();
}
List<DocumentAwarenessMetadata> _buildCollaborators(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) {
final result = <DocumentAwarenessMetadata>[];
final ids = <dynamic>{};
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<DocumentAwarenessMetadata> collaborators,
}) = _DocumentCollaboratorsState;
factory DocumentCollaboratorsState.initial() =>
const DocumentCollaboratorsState();
}

View File

@ -8,6 +8,11 @@ import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_backend/rust_stream.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
typedef OnDocumentEventUpdate = void Function(DocEventPB docEvent);
typedef OnDocumentAwarenessStateUpdate = void Function(
DocumentAwarenessStatesPB awarenessStates,
);
class DocumentListener { class DocumentListener {
DocumentListener({ DocumentListener({
required this.id, required this.id,
@ -18,12 +23,15 @@ class DocumentListener {
StreamSubscription<SubscribeObject>? _subscription; StreamSubscription<SubscribeObject>? _subscription;
DocumentNotificationParser? _parser; DocumentNotificationParser? _parser;
Function(DocEventPB docEvent)? didReceiveUpdate; OnDocumentEventUpdate? _onDocEventUpdate;
OnDocumentAwarenessStateUpdate? _onDocAwarenessUpdate;
void start({ void start({
Function(DocEventPB docEvent)? didReceiveUpdate, OnDocumentEventUpdate? onDocEventUpdate,
OnDocumentAwarenessStateUpdate? onDocAwarenessUpdate,
}) { }) {
this.didReceiveUpdate = didReceiveUpdate; _onDocEventUpdate = onDocEventUpdate;
_onDocAwarenessUpdate = onDocAwarenessUpdate;
_parser = DocumentNotificationParser( _parser = DocumentNotificationParser(
id: id, id: id,
@ -40,7 +48,16 @@ class DocumentListener {
) { ) {
switch (ty) { switch (ty) {
case DocumentNotification.DidReceiveUpdate: 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; break;
default: default:
break; break;
@ -48,6 +65,8 @@ class DocumentListener {
} }
Future<void> stop() async { Future<void> stop() async {
_onDocAwarenessUpdate = null;
_onDocEventUpdate = null;
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = null; _subscription = null;
} }

View File

@ -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-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.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_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:fixnum/fixnum.dart';
class DocumentService { class DocumentService {
// unused now. // unused now.
@ -143,4 +145,39 @@ class DocumentService {
return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); return FlowyResult.failure(FlowyError(msg: 'Workspace not found'));
}); });
} }
/// Sync the awareness states
/// For example, the cursor position, selection, who is viewing the document.
Future<FlowyResult<void, FlowyError>> 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)),
),
);
}
} }

View File

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.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/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.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-folder/view.pb.dart';
@ -49,12 +49,13 @@ class DocumentSyncBloc extends Bloc<DocumentSyncEvent, DocumentSyncBlocState> {
connectivityStream = connectivityStream =
_connectivity.onConnectivityChanged.listen((result) { _connectivity.onConnectivityChanged.listen((result) {
if (!isClosed) {} if (!isClosed) {
emit( emit(
state.copyWith( state.copyWith(
isNetworkConnected: result != ConnectivityResult.none, isNetworkConnected: result != ConnectivityResult.none,
), ),
); );
}
}); });
}, },
syncStateChanged: (syncState) { syncStateChanged: (syncState) {

View File

@ -144,10 +144,11 @@ extension BlockToNode on BlockPB {
final deltaString = meta.textMap[externalId]; final deltaString = meta.textMap[externalId];
if (deltaString != null) { if (deltaString != null) {
final delta = jsonDecode(deltaString); final delta = jsonDecode(deltaString);
map.putIfAbsent( map['delta'] = delta;
'delta', // map.putIfAbsent(
() => delta, // 'delta',
); // () => delta,
// );
} }
} }
} }

View File

@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/document_page.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/document_sync_indicator.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart'; import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/plugins/util.dart';
@ -140,20 +141,27 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
DocumentShareButton(
key: ValueKey('share_button_${view.id}'),
view: view,
),
...FeatureFlag.syncDocument.isOn ...FeatureFlag.syncDocument.isOn
? [ ? [
const HSpace(20), DocumentCollaborators(
key: ValueKey('collaborators_${view.id}'),
width: 100,
height: 32,
view: view,
),
const HSpace(16),
DocumentSyncIndicator( DocumentSyncIndicator(
key: ValueKey('sync_state_${view.id}'), key: ValueKey('sync_state_${view.id}'),
view: view, view: view,
), ),
const HSpace(12), const HSpace(16),
] ]
: [const HSpace(8)], : [const HSpace(8)],
DocumentShareButton(
key: ValueKey('share_button_${view.id}'),
view: view,
),
const HSpace(4),
ViewFavoriteButton( ViewFavoriteButton(
key: ValueKey('favorite_button_${view.id}'), key: ValueKey('favorite_button_${view.id}'),
view: view, view: view,

View File

@ -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<Widget> 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(),
),
);
}
}

View File

@ -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<DocumentCollaboratorsBloc, DocumentCollaboratorsState>(
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(),
),
);
},
),
);
}
}

View File

@ -35,7 +35,7 @@ class KeyboardHeightObserver {
void notify(double height) { void notify(double height) {
// the keyboard height will notify twice with the same value on Android 14 // 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) { if (height == 0 && currentKeyboardHeight == 0) {
return; return;
} }

View File

@ -87,7 +87,7 @@ enum FeatureFlag {
case FeatureFlag.membersSettings: case FeatureFlag.membersSettings:
return false; return false;
case FeatureFlag.syncDocument: case FeatureFlag.syncDocument:
return false; return true;
case FeatureFlag.unknown: case FeatureFlag.unknown:
return false; return false;
} }

View File

@ -130,7 +130,7 @@ class FlowyRunner {
if (!mode.isUnitTest) ...[ if (!mode.isUnitTest) ...[
// The DeviceOrApplicationInfoTask should be placed before the AppWidgetTask to fetch the app information. // 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. // It is unable to get the device information from the test environment.
const DeviceOrApplicationInfoTask(), const ApplicationInfoTask(),
const HotKeyTask(), const HotKeyTask(),
if (isSupabaseEnabled) InitSupabaseTask(), if (isSupabaseEnabled) InitSupabaseTask(),
if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(),

View File

@ -1,16 +1,20 @@
import 'dart:io'; import 'dart:io';
import 'package:appflowy_backend/log.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../startup.dart'; import '../startup.dart';
class DeviceOrApplicationInfoTask extends LaunchTask { class ApplicationInfo {
const DeviceOrApplicationInfoTask();
static int androidSDKVersion = -1; static int androidSDKVersion = -1;
static String applicationVersion = ''; static String applicationVersion = '';
static String buildNumber = ''; static String buildNumber = '';
static String deviceId = '';
}
class ApplicationInfoTask extends LaunchTask {
const ApplicationInfoTask();
@override @override
Future<void> initialize(LaunchContext context) async { Future<void> initialize(LaunchContext context) async {
@ -19,13 +23,41 @@ class DeviceOrApplicationInfoTask extends LaunchTask {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo; final androidInfo = await deviceInfoPlugin.androidInfo;
androidSDKVersion = androidInfo.version.sdkInt; ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt;
} }
if (Platform.isAndroid || Platform.isIOS) { if (Platform.isAndroid || Platform.isIOS) {
applicationVersion = packageInfo.version; ApplicationInfo.applicationVersion = packageInfo.version;
buildNumber = packageInfo.buildNumber; 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 @override

View File

@ -25,7 +25,7 @@ Future<String> getDeviceId() async {
deviceId = macInfo.systemGUID; deviceId = macInfo.systemGUID;
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.computerName; deviceId = windowsInfo.deviceId;
} else if (Platform.isLinux) { } else if (Platform.isLinux) {
final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
deviceId = linuxInfo.machineId; deviceId = linuxInfo.machineId;

View File

@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ColorGenerator { extension type ColorGenerator(String value) {
static Color generateColorFromString(String string) { Color toColor() {
final int hash = final int hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit);
string.codeUnits.fold(0, (int acc, int unit) => acc + unit);
final double hue = (hash % 360).toDouble(); final double hue = (hash % 360).toDouble();
return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor(); return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor();
} }

View File

@ -1,8 +1,16 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension ColorExtensionn on Color { extension ColorExtension on Color {
/// return a hex string in 0xff000000 format /// return a hex string in 0xff000000 format
String toHexString() { String toHexString() {
return '0x${value.toRadixString(16).padLeft(8, '0')}'; 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);
}
} }

View File

@ -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/generated/locale_keys.g.dart';
import 'package:appflowy/shared/feature_flags.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/user/application/user_service.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
@ -135,6 +138,12 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
), ),
(e) => state.currentWorkspace, (e) => state.currentWorkspace,
); );
result.onSuccess((_) async {
await getIt<KeyValueStorage>().set(
KVKeys.lastOpenedWorkspaceId,
workspaceId,
);
});
emit( emit(
state.copyWith( state.copyWith(
currentWorkspace: currentWorkspace, currentWorkspace: currentWorkspace,
@ -220,11 +229,21 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
Future<(UserWorkspacePB currentWorkspace, List<UserWorkspacePB> workspaces)?> Future<(UserWorkspacePB currentWorkspace, List<UserWorkspacePB> workspaces)?>
_fetchWorkspaces() async { _fetchWorkspaces() async {
try { try {
final lastOpenedWorkspaceId = await getIt<KeyValueStorage>().get(
KVKeys.lastOpenedWorkspaceId,
);
final currentWorkspace = final currentWorkspace =
await _userService.getCurrentWorkspace().getOrThrow(); await _userService.getCurrentWorkspace().getOrThrow();
final workspaces = await _userService.getWorkspaces().getOrThrow(); final workspaces = await _userService.getWorkspaces().getOrThrow();
final currentWorkspaceInList = UserWorkspacePB currentWorkspaceInList =
workspaces.firstWhere((e) => e.workspaceId == currentWorkspace.id); 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); return (currentWorkspaceInList, workspaces);
} catch (e) { } catch (e) {
Log.error('fetch workspace error: $e'); Log.error('fetch workspace error: $e');

View File

@ -67,6 +67,9 @@ class HomeSideBar extends StatelessWidget {
previous.currentWorkspace?.workspaceId != previous.currentWorkspace?.workspaceId !=
current.currentWorkspace?.workspaceId, current.currentWorkspace?.workspaceId,
builder: (context, state) { builder: (context, state) {
if (state.currentWorkspace == null) {
return const SizedBox.shrink();
}
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider( BlocProvider(

View File

@ -181,7 +181,7 @@ class _SidebarSwitchWorkspaceButtonState
enableEdit: false, enableEdit: false,
), ),
), ),
const HSpace(4), const HSpace(6),
Expanded( Expanded(
child: FlowyText.medium( child: FlowyText.medium(
widget.currentWorkspace.name, widget.currentWorkspace.name,

View File

@ -44,9 +44,7 @@ class _WorkspaceIconState extends State<WorkspaceIcon> {
width: widget.iconSize, width: widget.iconSize,
height: max(widget.iconSize, 26), height: max(widget.iconSize, 26),
decoration: BoxDecoration( decoration: BoxDecoration(
color: ColorGenerator.generateColorFromString( color: ColorGenerator(widget.workspace.name).toColor(),
widget.workspace.name,
),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: FlowyText( child: FlowyText(

View File

@ -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:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsMenu extends StatelessWidget { class SettingsMenu extends StatelessWidget {
@ -79,15 +80,15 @@ class SettingsMenu extends StatelessWidget {
icon: Icons.people, icon: Icons.people,
changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
), ),
// if (kDebugMode) if (kDebugMode)
// SettingsMenuElement( SettingsMenuElement(
// // no need to translate this page // no need to translate this page
// page: SettingsPage.featureFlags, page: SettingsPage.featureFlags,
// selectedPage: currentPage, selectedPage: currentPage,
// label: 'Feature Flags', label: 'Feature Flags',
// icon: Icons.flag, icon: Icons.flag,
// changeSelectedPage: changeSelectedPage, changeSelectedPage: changeSelectedPage,
// ), ),
], ],
), ),
); );

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class MoreViewActions extends StatefulWidget { class MoreViewActions extends StatefulWidget {
@ -105,8 +104,8 @@ class _MoreViewActionsState extends State<MoreViewActions> {
builder: (context, isHovering) => Padding( builder: (context, isHovering) => Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: FlowySvg( child: FlowySvg(
FlowySvgs.details_s, FlowySvgs.three_dots_vertical_s,
size: const Size(18, 18), size: const Size.square(16),
color: isHovering color: isHovering
? Theme.of(context).colorScheme.onPrimary ? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).iconTheme.color, : Theme.of(context).iconTheme.color,

View File

@ -29,7 +29,7 @@ class UserAvatar extends StatelessWidget {
if (iconUrl.isEmpty) { if (iconUrl.isEmpty) {
final String nameOrDefault = _userName(name); final String nameOrDefault = _userName(name);
final Color color = ColorGenerator.generateColorFromString(name); final Color color = ColorGenerator(name).toColor();
const initialsCount = 2; const initialsCount = 2;
// Taking the first letters of the name components and limiting to 2 elements // Taking the first letters of the name components and limiting to 2 elements

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: a571f2b ref: b927ec0
resolved-ref: a571f2bc9df764d90569951f40364c8c59787f30 resolved-ref: b927ec0685c870c731c5b6d9688a031d0cd31e76
url: "https://github.com/AppFlowy-IO/appflowy-editor.git" url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git source: git
version: "2.3.3" version: "2.3.3"
@ -105,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" 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: bloc:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -19,7 +19,7 @@ version: 0.5.3
environment: environment:
flutter: ">=3.19.0" 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. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@ -132,6 +132,7 @@ dependencies:
share_plus: ^7.2.1 share_plus: ^7.2.1
sheet: sheet:
file: ^7.0.0 file: ^7.0.0
avatar_stack: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_lints: ^3.0.1 flutter_lints: ^3.0.1
@ -168,7 +169,7 @@ dependency_overrides:
appflowy_editor: appflowy_editor:
git: git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "a571f2b" ref: "b927ec0"
sheet: sheet:
git: git:

View File

@ -838,7 +838,6 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -862,7 +861,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -892,7 +890,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -911,7 +908,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-entity" name = "collab-entity"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -926,7 +922,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -964,7 +959,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1041,7 +1035,6 @@ dependencies = [
[[package]] [[package]]
name = "collab-user" name = "collab-user"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=22ed64d598cd060a7b68554af61df3568e39a62a#22ed64d598cd060a7b68554af61df3568e39a62a"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",

View File

@ -96,10 +96,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e
# To switch to the local path, run: # To switch to the local path, run:
# scripts/tool/update_collab_source.sh # scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
collab = { 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 = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }

View File

@ -65,10 +65,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e
# To switch to the local path, run: # To switch to the local path, run:
# scripts/tool/update_collab_source.sh # scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
collab = { 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 = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }

View File

@ -764,7 +764,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -788,7 +788,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -818,7 +818,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -837,7 +837,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-entity" name = "collab-entity"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -852,7 +852,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -890,7 +890,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -967,7 +967,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-user" name = "collab-user"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",

View File

@ -120,10 +120,10 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "d4e
# To switch to the local path, run: # To switch to the local path, run:
# scripts/tool/update_collab_source.sh # scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
collab = { 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 = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }
collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "22ed64d598cd060a7b68554af61df3568e39a62a" } collab-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "2b42012" }

View File

@ -89,6 +89,14 @@ impl DocumentUserService for DocumentUserImpl {
.user_id() .user_id()
} }
fn device_id(&self) -> Result<String, FlowyError> {
self
.0
.upgrade()
.ok_or(FlowyError::internal().with_context("Unexpected error: UserSession is None"))?
.device_id()
}
fn workspace_id(&self) -> Result<String, FlowyError> { fn workspace_id(&self) -> Result<String, FlowyError> {
self self
.0 .0

View File

@ -10,8 +10,11 @@ use parking_lot::Mutex;
use flowy_error::FlowyResult; use flowy_error::FlowyResult;
use lib_dispatch::prelude::af_spawn; 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}; use crate::notification::{send_notification, DocumentNotification};
/// This struct wrap the document::Document /// This struct wrap the document::Document
@ -50,15 +53,30 @@ impl MutexDocument {
} }
fn subscribe_document_changed(doc_id: &str, document: &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 document
.lock() .lock()
.subscribe_block_changed(move |events, is_remote| { .subscribe_block_changed(move |events, is_remote| {
trace!("subscribe_document_changed: {:?}", events);
// send notification to the client. // send notification to the client.
send_notification(&doc_id, DocumentNotification::DidReceiveUpdate) send_notification(
.payload::<DocEventPB>((events, is_remote).into()) &doc_id_clone_for_block_changed,
.send(); DocumentNotification::DidReceiveUpdate,
)
.payload::<DocEventPB>((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::<DocumentAwarenessStatesPB>(events.into())
.send();
});
} }
fn subscribe_document_snapshot_state(collab: &Arc<MutexCollab>) { fn subscribe_document_snapshot_state(collab: &Arc<MutexCollab>) {

View File

@ -1,7 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use collab::core::collab_state::SyncState; 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_derive::{ProtoBuf, ProtoBuf_Enum};
use flowy_error::ErrorCode; use flowy_error::ErrorCode;
@ -301,6 +307,9 @@ pub struct DocEventPB {
#[pb(index = 2)] #[pb(index = 2)]
pub is_remote: bool, pub is_remote: bool,
#[pb(index = 3, one_of)]
pub new_snapshot: Option<DocumentDataPB>,
} }
#[derive(Default, ProtoBuf)] #[derive(Default, ProtoBuf)]
@ -512,3 +521,124 @@ pub struct DocumentSnapshotData {
pub object_id: String, pub object_id: String,
pub encoded_v1: Vec<u8>, pub encoded_v1: Vec<u8>,
} }
#[derive(ProtoBuf, Debug, Default)]
pub struct DocumentAwarenessStatesPB {
#[pb(index = 1)]
pub value: HashMap<String, DocumentAwarenessStatePB>,
}
impl From<HashMap<u64, DocumentAwarenessState>> for DocumentAwarenessStatesPB {
fn from(value: HashMap<u64, DocumentAwarenessState>) -> 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<DocumentAwarenessSelectionPB>,
#[pb(index = 3, one_of)]
pub metadata: Option<String>,
}
#[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<DocumentAwarenessSelectionPB>,
#[pb(index = 4, one_of)]
pub metadata: Option<String>,
#[pb(index = 5)]
pub timestamp: i64,
}
impl From<DocumentAwarenessState> 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<DocumentAwarenessUser> 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<u64>,
#[pb(index = 2)]
pub offset: u64,
}
impl From<DocumentAwarenessSelectionPB> for DocumentAwarenessSelection {
fn from(value: DocumentAwarenessSelectionPB) -> Self {
DocumentAwarenessSelection {
start: value.start.into(),
end: value.end.into(),
}
}
}
impl From<DocumentAwarenessSelection> for DocumentAwarenessSelectionPB {
fn from(value: DocumentAwarenessSelection) -> Self {
DocumentAwarenessSelectionPB {
start: value.start.into(),
end: value.end.into(),
}
}
}
impl From<DocumentAwarenessPositionPB> for DocumentAwarenessPosition {
fn from(value: DocumentAwarenessPositionPB) -> Self {
DocumentAwarenessPosition {
path: value.path,
offset: value.offset,
}
}
}
impl From<DocumentAwarenessPosition> for DocumentAwarenessPositionPB {
fn from(value: DocumentAwarenessPosition) -> Self {
DocumentAwarenessPositionPB {
path: value.path,
offset: value.offset,
}
}
}

View File

@ -8,6 +8,7 @@ use std::sync::{Arc, Weak};
use collab_document::blocks::{ use collab_document::blocks::{
BlockAction, BlockActionPayload, BlockActionType, BlockEvent, BlockEventPayload, DeltaType, BlockAction, BlockActionPayload, BlockActionType, BlockEvent, BlockEventPayload, DeltaType,
DocumentData,
}; };
use flowy_error::{FlowyError, FlowyResult}; use flowy_error::{FlowyError, FlowyResult};
@ -293,12 +294,15 @@ impl From<DeltaType> for DeltaTypePB {
} }
} }
impl From<(&Vec<BlockEvent>, bool)> for DocEventPB { impl From<(&Vec<BlockEvent>, bool, Option<DocumentData>)> for DocEventPB {
fn from((events, is_remote): (&Vec<BlockEvent>, bool)) -> Self { fn from(
(events, is_remote, new_snapshot): (&Vec<BlockEvent>, bool, Option<DocumentData>),
) -> Self {
// Convert each individual `BlockEvent` to a protobuf `BlockEventPB`, and collect the results into a `Vec` // Convert each individual `BlockEvent` to a protobuf `BlockEventPB`, and collect the results into a `Vec`
Self { Self {
events: events.iter().map(|e| e.to_owned().into()).collect(), events: events.iter().map(|e| e.to_owned().into()).collect(),
is_remote, 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)?; let manager = upgrade_document(manager)?;
manager.delete_file(local_file_path, url).await manager.delete_file(local_file_path, url).await
} }
pub(crate) async fn set_awareness_local_state_handler(
data: AFPluginData<UpdateDocumentAwarenessStatePB>,
manager: AFPluginState<Weak<DocumentManager>>,
) -> 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(())
}

View File

@ -4,6 +4,7 @@ use strum_macros::Display;
use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; use flowy_derive::{Flowy_Event, ProtoBuf_Enum};
use lib_dispatch::prelude::AFPlugin; use lib_dispatch::prelude::AFPlugin;
use tracing::event;
use crate::event_handler::get_snapshot_meta_handler; use crate::event_handler::get_snapshot_meta_handler;
use crate::{event_handler::*, manager::DocumentManager}; use crate::{event_handler::*, manager::DocumentManager};
@ -42,6 +43,10 @@ pub fn init(document_manager: Weak<DocumentManager>) -> AFPlugin {
.event(DocumentEvent::UploadFile, upload_file_handler) .event(DocumentEvent::UploadFile, upload_file_handler)
.event(DocumentEvent::DownloadFile, download_file_handler) .event(DocumentEvent::DownloadFile, download_file_handler)
.event(DocumentEvent::DeleteFile, delete_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)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)]
@ -118,4 +123,7 @@ pub enum DocumentEvent {
DownloadFile = 16, DownloadFile = 16,
#[event(input = "UploadedFilePB")] #[event(input = "UploadedFilePB")]
DeleteFile = 17, DeleteFile = 17,
#[event(input = "UpdateDocumentAwarenessStatePB")]
SetAwarenessState = 18,
} }

View File

@ -8,10 +8,13 @@ use collab::core::origin::CollabOrigin;
use collab::preclude::Collab; use collab::preclude::Collab;
use collab_document::blocks::DocumentData; use collab_document::blocks::DocumentData;
use collab_document::document::Document; 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_document::document_data::default_document_data;
use collab_entity::CollabType; use collab_entity::CollabType;
use collab_plugins::CollabKVDB; use collab_plugins::CollabKVDB;
use flowy_storage::object_from_disk; use flowy_storage::object_from_disk;
use lib_infra::util::timestamp;
use lru::LruCache; use lru::LruCache;
use parking_lot::Mutex; use parking_lot::Mutex;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@ -26,6 +29,7 @@ use flowy_storage::ObjectStorageService;
use lib_dispatch::prelude::af_spawn; use lib_dispatch::prelude::af_spawn;
use crate::document::MutexDocument; use crate::document::MutexDocument;
use crate::entities::UpdateDocumentAwarenessStatePB;
use crate::entities::{ use crate::entities::{
DocumentSnapshotData, DocumentSnapshotMeta, DocumentSnapshotMetaPB, DocumentSnapshotPB, DocumentSnapshotData, DocumentSnapshotMeta, DocumentSnapshotMetaPB, DocumentSnapshotPB,
}; };
@ -33,6 +37,7 @@ use crate::reminder::DocumentReminderAction;
pub trait DocumentUserService: Send + Sync { pub trait DocumentUserService: Send + Sync {
fn user_id(&self) -> Result<i64, FlowyError>; fn user_id(&self) -> Result<i64, FlowyError>;
fn device_id(&self) -> Result<String, FlowyError>;
fn workspace_id(&self) -> Result<String, FlowyError>; fn workspace_id(&self) -> Result<String, FlowyError>;
fn collab_db(&self, uid: i64) -> Result<Weak<CollabKVDB>, FlowyError>; fn collab_db(&self, uid: i64) -> Result<Weak<CollabKVDB>, FlowyError>;
} }
@ -204,6 +209,8 @@ impl DocumentManager {
if let Ok(doc) = self.get_document(doc_id).await { if let Ok(doc) = self.get_document(doc_id).await {
trace!("close document: {}", doc_id); trace!("close document: {}", doc_id);
if let Some(doc) = doc.try_lock() { if let Some(doc) = doc.try_lock() {
// clear the awareness state when close the document
doc.clean_awareness_local_state();
let _ = doc.flush(); let _ = doc.flush();
} }
} }
@ -222,6 +229,31 @@ impl DocumentManager {
Ok(()) Ok(())
} }
pub async fn set_document_awareness_local_state(
&self,
doc_id: &str,
state: UpdateDocumentAwarenessStatePB,
) -> FlowyResult<bool> {
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. /// Return the list of snapshots of the document.
pub async fn get_document_snapshot_meta( pub async fn get_document_snapshot_meta(
&self, &self,

View File

@ -11,6 +11,7 @@ pub enum DocumentNotification {
DidReceiveUpdate = 1, DidReceiveUpdate = 1,
DidUpdateDocumentSnapshotState = 2, DidUpdateDocumentSnapshotState = 2,
DidUpdateDocumentSyncState = 3, DidUpdateDocumentSyncState = 3,
DidUpdateDocumentAwarenessState = 4,
} }
impl std::convert::From<DocumentNotification> for i32 { impl std::convert::From<DocumentNotification> for i32 {
@ -24,6 +25,7 @@ impl std::convert::From<i32> for DocumentNotification {
1 => DocumentNotification::DidReceiveUpdate, 1 => DocumentNotification::DidReceiveUpdate,
2 => DocumentNotification::DidUpdateDocumentSnapshotState, 2 => DocumentNotification::DidUpdateDocumentSnapshotState,
3 => DocumentNotification::DidUpdateDocumentSyncState, 3 => DocumentNotification::DidUpdateDocumentSyncState,
4 => DocumentNotification::DidUpdateDocumentAwarenessState,
_ => DocumentNotification::Unknown, _ => DocumentNotification::Unknown,
} }
} }

View File

@ -82,6 +82,10 @@ impl DocumentUserService for FakeUser {
fn collab_db(&self, _uid: i64) -> Result<std::sync::Weak<CollabKVDB>, FlowyError> { fn collab_db(&self, _uid: i64) -> Result<std::sync::Weak<CollabKVDB>, FlowyError> {
Ok(Arc::downgrade(&self.collab_db)) Ok(Arc::downgrade(&self.collab_db))
} }
fn device_id(&self) -> Result<String, FlowyError> {
Ok("".to_string())
}
} }
pub fn setup_log() { pub fn setup_log() {

View File

@ -56,6 +56,10 @@ impl AuthenticateUser {
Ok(session.user_id) Ok(session.user_id)
} }
pub fn device_id(&self) -> FlowyResult<String> {
Ok(self.user_config.device_id.to_string())
}
pub fn workspace_id(&self) -> FlowyResult<String> { pub fn workspace_id(&self) -> FlowyResult<String> {
let session = self.get_session()?; let session = self.get_session()?;
Ok(session.user_workspace.id) Ok(session.user_workspace.id)