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:
/// {'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';
}

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/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<MobileViewPage> {
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),
],

View File

@ -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

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/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<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(
key: const PageStorageKey('recent_views_page_storage_key'),
scrollDirection: Axis.horizontal,

View File

@ -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<MobileRecentView> {
documentListener = DocumentListener(id: view.id)
..start(
didReceiveUpdate: (document) {
onDocEventUpdate: (document) {
setState(() {
view = view;
});

View File

@ -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,
),
),

View File

@ -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<SelfHostUrlBottomSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
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(
key: _formKey,
child: TextFormField(

View File

@ -36,6 +36,14 @@ class _SelfHostSettingGroupState extends State<SelfHostSettingGroup> {
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,

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: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<DocumentEvent, DocumentState> {
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<DocumentEvent, DocumentState> {
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<DocumentEvent, DocumentState> {
),
);
emit(newState);
if (newState.userProfilePB != null) {
await _updateCollaborator();
}
},
moveToTrash: () async {
emit(state.copyWith(isDeleted: true));
@ -143,7 +155,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
/// 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<DocumentEvent, DocumentState> {
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<DocumentEvent, DocumentState> {
return editorState;
}
Future<void> applyRules() async {
Future<void> _applyRules() async {
await Future.wait([
ensureAtLeastOneParagraphExists(),
ensureLastNodeIsEditable(),
_ensureAtLeastOneParagraphExists(),
_ensureLastNodeIsEditable(),
]);
}
Future<void> ensureLastNodeIsEditable() async {
Future<void> _ensureLastNodeIsEditable() async {
final editorState = state.editorState;
if (editorState == null) {
return;
@ -226,7 +246,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
}
Future<void> ensureAtLeastOneParagraphExists() async {
Future<void> _ensureAtLeastOneParagraphExists() async {
final editorState = state.editorState;
if (editorState == null) {
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) {
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,
EditorState? editorState,
FlowyError? error,
@Default(null) DocumentAwarenessStatesPB? awarenessStates,
}) = _DocumentState;
factory DocumentState.initial() => const DocumentState(

View File

@ -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<void> syncV3() async {
Future<void> 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<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> {

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_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<SubscribeObject>? _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<void> stop() async {
_onDocAwarenessUpdate = null;
_onDocEventUpdate = null;
await _subscription?.cancel();
_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-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<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 '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<DocumentSyncEvent, DocumentSyncBlocState> {
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) {

View File

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

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/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,

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) {
// 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;
}

View File

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

View File

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

View File

@ -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<void> 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

View File

@ -25,7 +25,7 @@ Future<String> 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;

View File

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

View File

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

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/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<UserWorkspaceEvent, UserWorkspaceState> {
),
(e) => state.currentWorkspace,
);
result.onSuccess((_) async {
await getIt<KeyValueStorage>().set(
KVKeys.lastOpenedWorkspaceId,
workspaceId,
);
});
emit(
state.copyWith(
currentWorkspace: currentWorkspace,
@ -220,11 +229,21 @@ class UserWorkspaceBloc extends Bloc<UserWorkspaceEvent, UserWorkspaceState> {
Future<(UserWorkspacePB currentWorkspace, List<UserWorkspacePB> workspaces)?>
_fetchWorkspaces() async {
try {
final lastOpenedWorkspaceId = await getIt<KeyValueStorage>().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');

View File

@ -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(

View File

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

View File

@ -44,9 +44,7 @@ class _WorkspaceIconState extends State<WorkspaceIcon> {
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(

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: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,
),
],
),
);

View File

@ -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<MoreViewActions> {
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,

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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",

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:
# 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" }

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:
# 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" }

View File

@ -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",

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:
# 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" }

View File

@ -89,6 +89,14 @@ impl DocumentUserService for DocumentUserImpl {
.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> {
self
.0

View File

@ -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::<DocEventPB>((events, is_remote).into())
.send();
send_notification(
&doc_id_clone_for_block_changed,
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>) {

View File

@ -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<DocumentDataPB>,
}
#[derive(Default, ProtoBuf)]
@ -512,3 +521,124 @@ pub struct DocumentSnapshotData {
pub object_id: String,
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::{
BlockAction, BlockActionPayload, BlockActionType, BlockEvent, BlockEventPayload, DeltaType,
DocumentData,
};
use flowy_error::{FlowyError, FlowyResult};
@ -293,12 +294,15 @@ impl From<DeltaType> for DeltaTypePB {
}
}
impl From<(&Vec<BlockEvent>, bool)> for DocEventPB {
fn from((events, is_remote): (&Vec<BlockEvent>, bool)) -> Self {
impl From<(&Vec<BlockEvent>, bool, Option<DocumentData>)> for DocEventPB {
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`
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<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 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<DocumentManager>) -> 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,
}

View File

@ -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<i64, FlowyError>;
fn device_id(&self) -> Result<String, FlowyError>;
fn workspace_id(&self) -> Result<String, 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 {
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<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.
pub async fn get_document_snapshot_meta(
&self,

View File

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

View File

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

View File

@ -56,6 +56,10 @@ impl AuthenticateUser {
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> {
let session = self.get_session()?;
Ok(session.user_workspace.id)