diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index aefd5e5d36..228f2ad5d6 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -120,4 +120,9 @@ class KVKeys { /// /// The value is a json list of id static const String compactModeIds = 'compactModeIds'; + + /// v0.9.4: has the user clicked the upgrade to pro button + /// The value is a boolean string + static const String hasClickedUpgradeToProButton = + 'hasClickedUpgradeToProButton'; } diff --git a/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/page_access_level_repository.dart b/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/page_access_level_repository.dart index aefe238ac7..a188eb4a96 100644 --- a/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/page_access_level_repository.dart +++ b/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/page_access_level_repository.dart @@ -1,6 +1,7 @@ import 'package:appflowy/features/share_tab/data/models/models.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-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; /// Abstract repository for managing view lock status. @@ -26,4 +27,7 @@ abstract class PageAccessLevelRepository { Future> getSectionType( String pageId, ); + + /// Get current workspace + Future> getCurrentWorkspace(); } diff --git a/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart b/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart index e4c9c025e8..9204ca5008 100644 --- a/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart +++ b/frontend/appflowy_flutter/lib/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart @@ -7,8 +7,9 @@ import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart' + hide AFRolePB; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; @@ -18,7 +19,7 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { final result = await ViewBackendService.getView(pageId); return result.fold( (view) { - Log.info('get view success: ${view.id}'); + Log.debug('get view(${view.id}) success'); return FlowyResult.success(view); }, (error) { @@ -33,7 +34,7 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { final result = await ViewBackendService.lockView(pageId); return result.fold( (_) { - Log.info('lock view success: $pageId'); + Log.debug('lock view($pageId) success'); return FlowyResult.success(null); }, (error) { @@ -48,7 +49,7 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { final result = await ViewBackendService.unlockView(pageId); return result.fold( (_) { - Log.info('unlock view success: $pageId'); + Log.debug('unlock view($pageId) success'); return FlowyResult.success(null); }, (error) { @@ -58,6 +59,11 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { ); } + /// 1. local users have full access + /// 2. local workspace users have full access + /// 3. page creator has full access + /// 4. owner and members in public page have full access + /// 5. check the shared users list @override Future> getAccessLevel( String pageId, @@ -67,6 +73,7 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { (s) => s, (_) => null, ); + if (user == null) { return FlowyResult.failure( FlowyError( @@ -86,6 +93,41 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { return FlowyResult.success(ShareAccessLevel.fullAccess); } + // If the user is the creator of the page, they can always have full access. + final viewResult = await getView(pageId); + final view = viewResult.fold( + (s) => s, + (_) => null, + ); + if (view?.createdBy == user.id) { + return FlowyResult.success(ShareAccessLevel.fullAccess); + } + + // If the page is public, the user can always have full access. + final workspaceResult = await getCurrentWorkspace(); + final workspace = workspaceResult.fold( + (s) => s, + (_) => null, + ); + if (workspace == null) { + return FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: 'Current workspace not found', + ), + ); + } + + final sectionTypeResult = await getSectionType(pageId); + final sectionType = sectionTypeResult.fold( + (s) => s, + (_) => null, + ); + if (sectionType == SharedSectionType.public && + workspace.role != AFRolePB.Guest) { + return FlowyResult.success(ShareAccessLevel.fullAccess); + } + final email = user.email; final request = GetSharedUsersPayloadPB( @@ -100,7 +142,7 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { ) ?.accessLevel .shareAccessLevel ?? - ShareAccessLevel.readAndWrite; + ShareAccessLevel.readOnly; Log.debug('current user access level: $accessLevel, in page: $pageId'); @@ -111,8 +153,8 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { 'failed to get user access level: $failure, in page: $pageId', ); - // return the read and write access level if the user is not found - return FlowyResult.success(ShareAccessLevel.readAndWrite); + // return the read access level if the user is not found + return FlowyResult.success(ShareAccessLevel.readOnly); }, ); } @@ -138,4 +180,27 @@ class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository { }, ); } + + @override + Future> getCurrentWorkspace() async { + final result = await UserBackendService.getCurrentWorkspace(); + final currentWorkspaceId = result.fold( + (s) => s.id, + (_) => null, + ); + + if (currentWorkspaceId == null) { + return FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: 'Current workspace not found', + ), + ); + } + + final workspaceResult = await UserBackendService.getWorkspaceById( + currentWorkspaceId, + ); + return workspaceResult; + } } diff --git a/frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_state.dart b/frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_state.dart index b674820a30..e1a7c12c59 100644 --- a/frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_state.dart +++ b/frontend/appflowy_flutter/lib/features/page_access_level/logic/page_access_level_state.dart @@ -8,8 +8,7 @@ class PageAccessLevelState { isLocked: false, lockCounter: 0, sectionType: SharedSectionType.public, - accessLevel: ShareAccessLevel - .readAndWrite, // replace it with readOnly if we support offline. + accessLevel: ShareAccessLevel.readOnly, ); const PageAccessLevelState({ diff --git a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart index 1e14aa2cfc..8a33f6eedb 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart @@ -181,4 +181,18 @@ class LocalShareWithUserRepositoryImpl extends ShareWithUserRepository { }) async { return FlowySuccess(SharedSectionType.private); } + + @override + Future getUpgradeToProButtonClicked({ + required String workspaceId, + }) async { + return false; + } + + @override + Future setUpgradeToProButtonClicked({ + required String workspaceId, + }) async { + return; + } } diff --git a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart index b32ebc9851..72a95cb53b 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart @@ -1,5 +1,8 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/util/extensions.dart'; +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; @@ -52,12 +55,12 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository { return result.fold( (success) { - Log.info('remove users($emails) from shared page($pageId)'); + Log.debug('remove users($emails) from shared page($pageId)'); return FlowySuccess(success); }, (failure) { - Log.error('removeUserFromPage: $failure'); + Log.error('remove users($emails) from shared page($pageId): $failure'); return FlowyFailure(failure); }, @@ -74,13 +77,13 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository { viewId: pageId, emails: emails, accessLevel: accessLevel.accessLevel, - autoConfirm: true, // TODO: remove this after the backend is ready + autoConfirm: true, ); final result = await FolderEventSharePageWithUser(request).send(); return result.fold( (success) { - Log.info( + Log.debug( 'share page($pageId) with users($emails) with access level($accessLevel)', ); @@ -117,7 +120,7 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository { final result = await UserEventUpdateWorkspaceMember(request).send(); return result.fold( (success) { - Log.info( + Log.debug( 'change role($role) for user($email) in workspaceId($workspaceId)', ); return FlowySuccess(success); @@ -161,4 +164,28 @@ class RustShareWithUserRepositoryImpl extends ShareWithUserRepository { return FlowySuccess(sectionType); } + + @override + Future getUpgradeToProButtonClicked({ + required String workspaceId, + }) async { + final result = await getIt().getWithFormat( + '${KVKeys.hasClickedUpgradeToProButton}_$workspaceId', + (value) => bool.parse(value), + ); + if (result == null) { + return false; + } + return result; + } + + @override + Future setUpgradeToProButtonClicked({ + required String workspaceId, + }) async { + await getIt().set( + '${KVKeys.hasClickedUpgradeToProButton}_$workspaceId', + 'true', + ); + } } diff --git a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/share_with_user_repository.dart b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/share_with_user_repository.dart index 9251bfec2a..e71379fbae 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/share_with_user_repository.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/data/repositories/share_with_user_repository.dart @@ -47,4 +47,14 @@ abstract class ShareWithUserRepository { Future> getCurrentPageSectionType({ required String pageId, }); + + /// Get the upgrade to pro button has been clicked. + Future getUpgradeToProButtonClicked({ + required String workspaceId, + }); + + /// Set the upgrade to pro button has been clicked. + Future setUpgradeToProButtonClicked({ + required String workspaceId, + }); } diff --git a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_bloc.dart b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_bloc.dart index 1f6637ba3a..66c0e904cc 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_bloc.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_bloc.dart @@ -1,11 +1,15 @@ +import 'package:appflowy/core/notification/folder_notification.dart'; import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_event.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_state.dart'; +import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/shared/share/constants.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:bloc/bloc.dart'; @@ -28,12 +32,23 @@ class ShareTabBloc extends Bloc { on(_onSearchAvailableUsers); on(_onTurnIntoMember); on(_onClearState); + on(_onUpdateSharedUsers); + on(_onUpgradeToProClicked); } final ShareWithUserRepository repository; final String workspaceId; final String pageId; + // Used to listen for shared view updates. + FolderNotificationListener? _folderNotificationListener; + + @override + Future close() async { + await _folderNotificationListener?.stop(); + await super.close(); + } + Future _onInitial( ShareTabEventInitialize event, Emitter emit, @@ -49,6 +64,8 @@ class ShareTabBloc extends Bloc { return; } + _initFolderNotificationListener(); + final result = await repository.getCurrentUserProfile(); final currentUser = result.fold( (user) => user, @@ -70,12 +87,18 @@ class ShareTabBloc extends Bloc { final users = await _getSharedUsers(); + final hasClickedUpgradeToPro = + await repository.getUpgradeToProButtonClicked( + workspaceId: workspaceId, + ); + emit( state.copyWith( currentUser: currentUser, shareLink: shareLink, users: users, sectionType: sectionType, + hasClickedUpgradeToPro: hasClickedUpgradeToPro, ), ); } @@ -343,4 +366,55 @@ class ShareTabBloc extends Bloc { ), ); } + + void _onUpdateSharedUsers( + ShareTabEventUpdateSharedUsers event, + Emitter emit, + ) { + emit( + state.copyWith( + users: event.users, + ), + ); + } + + Future _onUpgradeToProClicked( + ShareTabEventUpgradeToProClicked event, + Emitter emit, + ) async { + await repository.setUpgradeToProButtonClicked( + workspaceId: workspaceId, + ); + emit( + state.copyWith( + hasClickedUpgradeToPro: true, + ), + ); + } + + void _initFolderNotificationListener() { + _folderNotificationListener = FolderNotificationListener( + objectId: pageId, + handler: (notification, result) { + if (notification == FolderNotification.DidUpdateSharedUsers) { + final response = result.fold( + (payload) { + final repeatedSharedUsers = + RepeatedSharedUserPB.fromBuffer(payload); + return repeatedSharedUsers; + }, + (error) => null, + ); + Log.debug('update shared users: $response'); + if (response != null) { + add( + ShareTabEvent.updateSharedUsers( + users: response.sharedUsers.reversed.toList(), + ), + ); + } + } + }, + ); + } } diff --git a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_event.dart b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_event.dart index e48aedd98e..6e01244224 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_event.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_event.dart @@ -50,6 +50,14 @@ sealed class ShareTabEvent { ShareTabEventConvertToMember(email: email); factory ShareTabEvent.clearState() => const ShareTabEventClearState(); + + factory ShareTabEvent.updateSharedUsers({ + required SharedUsers users, + }) => + ShareTabEventUpdateSharedUsers(users: users); + + factory ShareTabEvent.upgradeToProClicked() => + const ShareTabEventUpgradeToProClicked(); } /// Initializes the share tab bloc. @@ -132,3 +140,15 @@ class ShareTabEventConvertToMember extends ShareTabEvent { class ShareTabEventClearState extends ShareTabEvent { const ShareTabEventClearState(); } + +class ShareTabEventUpdateSharedUsers extends ShareTabEvent { + const ShareTabEventUpdateSharedUsers({ + required this.users, + }); + + final SharedUsers users; +} + +class ShareTabEventUpgradeToProClicked extends ShareTabEvent { + const ShareTabEventUpgradeToProClicked(); +} diff --git a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_state.dart b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_state.dart index 43a501865d..ac89f5fdf4 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_state.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/logic/share_tab_state.dart @@ -21,6 +21,7 @@ class ShareTabState { this.removeResult, this.updateAccessLevelResult, this.turnIntoMemberResult, + this.hasClickedUpgradeToPro = false, }); final UserProfilePB? currentUser; @@ -37,6 +38,7 @@ class ShareTabState { final FlowyResult? removeResult; final FlowyResult? updateAccessLevelResult; final FlowyResult? turnIntoMemberResult; + final bool hasClickedUpgradeToPro; ShareTabState copyWith({ UserProfilePB? currentUser, @@ -53,6 +55,7 @@ class ShareTabState { FlowyResult? removeResult, FlowyResult? updateAccessLevelResult, FlowyResult? turnIntoMemberResult, + bool? hasClickedUpgradeToPro, }) { return ShareTabState( currentUser: currentUser ?? this.currentUser, @@ -69,6 +72,8 @@ class ShareTabState { removeResult: removeResult, updateAccessLevelResult: updateAccessLevelResult, turnIntoMemberResult: turnIntoMemberResult, + hasClickedUpgradeToPro: + hasClickedUpgradeToPro ?? this.hasClickedUpgradeToPro, ); } @@ -89,7 +94,8 @@ class ShareTabState { other.shareResult == shareResult && other.removeResult == removeResult && other.updateAccessLevelResult == updateAccessLevelResult && - other.turnIntoMemberResult == turnIntoMemberResult; + other.turnIntoMemberResult == turnIntoMemberResult && + other.hasClickedUpgradeToPro == hasClickedUpgradeToPro; } @override @@ -109,11 +115,12 @@ class ShareTabState { removeResult, updateAccessLevelResult, turnIntoMemberResult, + hasClickedUpgradeToPro, ); } @override String toString() { - return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, shareSectionType: $SharedSectionType, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult)'; + return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, shareSectionType: $SharedSectionType, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult, hasClickedUpgradeToPro: $hasClickedUpgradeToPro)'; } } diff --git a/frontend/appflowy_flutter/lib/features/share_tab/presentation/share_tab.dart b/frontend/appflowy_flutter/lib/features/share_tab/presentation/share_tab.dart index f703a6c185..5311f0eacb 100644 --- a/frontend/appflowy_flutter/lib/features/share_tab/presentation/share_tab.dart +++ b/frontend/appflowy_flutter/lib/features/share_tab/presentation/share_tab.dart @@ -5,6 +5,7 @@ import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widge import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart'; import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart'; +import 'package:appflowy/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -23,6 +24,8 @@ class ShareTab extends StatefulWidget { required this.pageId, required this.workspaceName, required this.workspaceIcon, + required this.isInProPlan, + required this.onUpgradeToPro, }); final String workspaceId; @@ -32,6 +35,9 @@ class ShareTab extends StatefulWidget { final String workspaceName; final String workspaceIcon; + final bool isInProPlan; + final VoidCallback onUpgradeToPro; + @override State createState() => _ShareTabState(); } @@ -94,8 +100,18 @@ class _ShareTabState extends State { ), ), - // shared users + if (!widget.isInProPlan && !state.hasClickedUpgradeToPro) ...[ + UpgradeToProWidget( + onClose: () { + context.read().add( + ShareTabEvent.upgradeToProClicked(), + ); + }, + onUpgrade: widget.onUpgradeToPro, + ), + ], + // shared users if (state.users.isNotEmpty) ...[ VSpace(theme.spacing.l), PeopleWithAccessSection( @@ -171,7 +187,7 @@ class _ShareTabState extends State { description: '', style: ConfirmPopupStyle.cancelAndOk, confirmLabel: 'Remove', - onConfirm: () { + onConfirm: (_) { shareTabBloc.add( ShareTabEvent.removeUsers(emails: [user.email]), ); diff --git a/frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart b/frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart new file mode 100644 index 0000000000..04a2469073 --- /dev/null +++ b/frontend/appflowy_flutter/lib/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class UpgradeToProWidget extends StatelessWidget { + const UpgradeToProWidget({ + super.key, + required this.onUpgrade, + required this.onClose, + }); + + final VoidCallback onClose; + final VoidCallback onUpgrade; + + @override + Widget build(BuildContext context) { + final theme = AppFlowyTheme.of(context); + return Container( + decoration: BoxDecoration( + color: Color(0x129327ff), + borderRadius: BorderRadius.circular(theme.borderRadius.m), + ), + padding: EdgeInsets.symmetric( + vertical: theme.spacing.m, + horizontal: theme.spacing.l, + ), + margin: EdgeInsets.only( + top: theme.spacing.l, + ), + child: Row( + children: [ + FlowySvg( + FlowySvgs.upgrade_pro_crown_m, + blendMode: null, + ), + HSpace( + theme.spacing.m, + ), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.shareTab_upgrade.tr(), + style: theme.textStyle.caption.standard().copyWith( + color: theme.textColorScheme.featured, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + onUpgrade(); + }, + mouseCursor: SystemMouseCursors.click, + ), + TextSpan( + text: LocaleKeys.shareTab_toProPlanToInviteGuests.tr(), + style: theme.textStyle.caption.standard().copyWith( + color: theme.textColorScheme.featured, + ), + ), + ], + ), + ), + const Spacer(), + AFGhostButton.normal( + size: AFButtonSize.s, + padding: EdgeInsets.all(theme.spacing.xs), + onTap: () { + context + .read() + .add(ShareTabEvent.upgradeToProClicked()); + onClose(); + }, + builder: (context, isHovering, disabled) => FlowySvg( + FlowySvgs.upgrade_to_pro_close_m, + size: const Size.square(20), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart index b3266ac032..ccdc29b35a 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart @@ -31,4 +31,9 @@ class LocalSharedPagesRepositoryImpl implements SharedPagesRepository { ]; return FlowyResult.success(pages); } + + @override + Future> leaveSharedPage(String pageId) async { + return FlowyResult.success(null); + } } diff --git a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart index 0e0ddf79a2..1cd6b902e9 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart @@ -4,6 +4,7 @@ import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; class RustSharePagesRepositoryImpl implements SharedPagesRepository { @@ -14,7 +15,7 @@ class RustSharePagesRepositoryImpl implements SharedPagesRepository { (success) { final sharedPages = success.sharedPages; - Log.info('get shared pages success, len: ${sharedPages.length}'); + Log.debug('get shared pages success, len: ${sharedPages.length}'); return FlowyResult.success(sharedPages); }, @@ -25,4 +26,38 @@ class RustSharePagesRepositoryImpl implements SharedPagesRepository { }, ); } + + @override + Future> leaveSharedPage(String pageId) async { + final user = await UserEventGetUserProfile().send(); + final userEmail = user.fold( + (success) => success.email, + (error) => null, + ); + + if (userEmail == null) { + return FlowyResult.failure(FlowyError(msg: 'User email is null')); + } + + final request = RemoveUserFromSharedPagePayloadPB( + viewId: pageId, + emails: [userEmail], + ); + final result = await FolderEventRemoveUserFromSharedPage(request).send(); + + return result.fold( + (success) { + Log.debug('remove user($userEmail) from shared page($pageId)'); + + return FlowySuccess(success); + }, + (failure) { + Log.error( + 'remove user($userEmail) from shared page($pageId): $failure', + ); + + return FlowyFailure(failure); + }, + ); + } } diff --git a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/shared_pages_repository.dart b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/shared_pages_repository.dart index c70efc83af..e78c9ef84d 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/shared_pages_repository.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/data/repositories/shared_pages_repository.dart @@ -9,4 +9,7 @@ import 'package:appflowy_result/appflowy_result.dart'; abstract class SharedPagesRepository { /// Gets the list of users and their roles for a shared page. Future> getSharedPages(); + + /// Removes a shared page from the repository. + Future> leaveSharedPage(String pageId); } diff --git a/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_bloc.dart b/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_bloc.dart index 9e650c07cd..56481e3f6c 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_bloc.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_bloc.dart @@ -8,6 +8,7 @@ import 'package:appflowy/features/util/extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:bloc/bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; export 'shared_section_event.dart'; export 'shared_section_state.dart'; @@ -23,6 +24,7 @@ class SharedSectionBloc extends Bloc { on(_onRefresh); on(_onUpdateSharedPages); on(_onToggleExpanded); + on(_onLeaveSharedPage); } final String workspaceId; @@ -155,6 +157,28 @@ class SharedSectionBloc extends Bloc { ); } + void _onLeaveSharedPage( + SharedSectionLeaveSharedPageEvent event, + Emitter emit, + ) async { + final result = await repository.leaveSharedPage(event.pageId); + result.fold( + (success) { + add( + SharedSectionEvent.updateSharedPages( + sharedPages: state.sharedPages + ..removeWhere( + (page) => page.view.id == event.pageId, + ), + ), + ); + }, + (error) { + emit(state.copyWith(errorMessage: error.msg)); + }, + ); + } + void _startPollingIfNeeded() { _pollingTimer?.cancel(); if (enablePolling && pollingIntervalSeconds > 0) { diff --git a/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_event.dart b/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_event.dart index ac7381667e..7ceca02df5 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_event.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/logic/shared_section_event.dart @@ -19,6 +19,11 @@ sealed class SharedSectionEvent { /// Toggle the expanded status of the shared section. const factory SharedSectionEvent.toggleExpanded() = SharedSectionToggleExpandedEvent; + + /// Leave shared page. + const factory SharedSectionEvent.leaveSharedPage({ + required String pageId, + }) = SharedSectionLeaveSharedPageEvent; } class SharedSectionInitEvent extends SharedSectionEvent { @@ -40,3 +45,11 @@ class SharedSectionUpdateSharedPagesEvent extends SharedSectionEvent { class SharedSectionToggleExpandedEvent extends SharedSectionEvent { const SharedSectionToggleExpandedEvent(); } + +class SharedSectionLeaveSharedPageEvent extends SharedSectionEvent { + const SharedSectionLeaveSharedPageEvent({ + required this.pageId, + }); + + final String pageId; +} diff --git a/frontend/appflowy_flutter/lib/features/shared_section/presentation/m_shared_section.dart b/frontend/appflowy_flutter/lib/features/shared_section/presentation/m_shared_section.dart index ea642df6d0..f4e3ababcd 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/presentation/m_shared_section.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/presentation/m_shared_section.dart @@ -3,6 +3,7 @@ import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart' import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_page_list.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_section_header.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart'; +import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_empty.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart'; import 'package:appflowy/mobile/application/mobile_router.dart'; @@ -43,7 +44,7 @@ class MSharedSection extends StatelessWidget { // hide the shared section if there are no shared pages if (state.sharedPages.isEmpty) { - return const SizedBox.shrink(); + return const SharedSectionEmpty(); } return Column( diff --git a/frontend/appflowy_flutter/lib/features/shared_section/presentation/shared_section.dart b/frontend/appflowy_flutter/lib/features/shared_section/presentation/shared_section.dart index 5fb8ebd928..d4a7384038 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/presentation/shared_section.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/presentation/shared_section.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/features/shared_section/data/repositories/local_shared_pages_repository_impl.dart'; +import 'package:appflowy/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart'; import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart'; import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_list.dart'; @@ -14,7 +14,9 @@ import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,8 +32,8 @@ class SharedSection extends StatelessWidget { @override Widget build(BuildContext context) { - // final repository = RustSharePagesRepositoryImpl(); - final repository = LocalSharedPagesRepositoryImpl(); + final theme = AppFlowyTheme.of(context); + final repository = RustSharePagesRepositoryImpl(); return BlocProvider( create: (_) => SharedSectionBloc( @@ -105,11 +107,18 @@ class SharedSection extends StatelessWidget { await showConfirmDialog( context: context, title: 'Remove your own access', + titleStyle: theme.textStyle.body.standard( + color: theme.textColorScheme.primary, + ), description: '', style: ConfirmPopupStyle.cancelAndOk, confirmLabel: 'Remove', - onConfirm: () { - // todo: remove the access + onConfirm: (_) { + context.read().add( + SharedSectionEvent.leaveSharedPage( + pageId: view.id, + ), + ); }, ); break; @@ -138,6 +147,8 @@ class SharedSection extends StatelessWidget { ); }, ), + + const VSpace(16.0), ], ); }, diff --git a/frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/refresh_button.dart b/frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/refresh_button.dart index 2bb4ee2f7c..efd087e25a 100644 --- a/frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/refresh_button.dart +++ b/frontend/appflowy_flutter/lib/features/shared_section/presentation/widgets/refresh_button.dart @@ -1,7 +1,5 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:flutter/material.dart'; -import 'package:easy_localization/easy_localization.dart'; class RefreshSharedSectionButton extends StatelessWidget { const RefreshSharedSectionButton({ @@ -20,7 +18,7 @@ class RefreshSharedSectionButton extends StatelessWidget { size: 20, color: theme.iconColorScheme.secondary, ), - title: LocaleKeys.shareSection_refresh.tr(), + title: 'Refresh', onTap: onTap, ); } diff --git a/frontend/appflowy_flutter/lib/features/workspace/logic/workspace_bloc.dart b/frontend/appflowy_flutter/lib/features/workspace/logic/workspace_bloc.dart index 629f5d80ac..5d5c8358c9 100644 --- a/frontend/appflowy_flutter/lib/features/workspace/logic/workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/features/workspace/logic/workspace_bloc.dart @@ -36,11 +36,10 @@ class _WorkspaceFetchResult { class UserWorkspaceBloc extends Bloc { UserWorkspaceBloc({ - required WorkspaceRepository repository, - required UserProfilePB userProfile, + required this.repository, + required this.userProfile, this.initialWorkspaceId, - }) : _repository = repository, - _listener = UserListener(userProfile: userProfile), + }) : _listener = UserListener(userProfile: userProfile), super(UserWorkspaceState.initial(userProfile)) { on(_onInitialize); on(_onFetchWorkspaces); @@ -59,7 +58,8 @@ class UserWorkspaceBloc extends Bloc { } final String? initialWorkspaceId; - final WorkspaceRepository _repository; + final WorkspaceRepository repository; + final UserProfilePB userProfile; final UserListener _listener; @override @@ -124,7 +124,7 @@ class UserWorkspaceBloc extends Bloc { ), ); - final result = await _repository.createWorkspace( + final result = await repository.createWorkspace( name: event.name, workspaceType: event.workspaceType, ); @@ -197,7 +197,7 @@ class UserWorkspaceBloc extends Bloc { ); } - final result = await _repository.deleteWorkspace( + final result = await repository.deleteWorkspace( workspaceId: event.workspaceId, ); final workspacesResult = await _fetchWorkspaces(); @@ -264,7 +264,7 @@ class UserWorkspaceBloc extends Bloc { ), ); - final result = await _repository.openWorkspace( + final result = await repository.openWorkspace( workspaceId: event.workspaceId, workspaceType: event.workspaceType, ); @@ -310,7 +310,7 @@ class UserWorkspaceBloc extends Bloc { WorkspaceEventRenameWorkspace event, Emitter emit, ) async { - final result = await _repository.renameWorkspace( + final result = await repository.renameWorkspace( workspaceId: event.workspaceId, name: event.name, ); @@ -364,7 +364,7 @@ class UserWorkspaceBloc extends Bloc { return; } - final result = await _repository.updateWorkspaceIcon( + final result = await repository.updateWorkspaceIcon( workspaceId: event.workspaceId, icon: event.icon, ); @@ -409,7 +409,7 @@ class UserWorkspaceBloc extends Bloc { WorkspaceEventLeaveWorkspace event, Emitter emit, ) async { - final result = await _repository.leaveWorkspace( + final result = await repository.leaveWorkspace( workspaceId: event.workspaceId, ); @@ -453,14 +453,14 @@ class UserWorkspaceBloc extends Bloc { WorkspaceEventFetchWorkspaceSubscriptionInfo event, Emitter emit, ) async { - final enabled = await _repository.isBillingEnabled(); + final enabled = await repository.isBillingEnabled(); // If billing is not enabled, we don't need to fetch the workspace subscription info if (!enabled) { return; } unawaited( - _repository + repository .getWorkspaceSubscriptionInfo( workspaceId: event.workspaceId, ) @@ -558,7 +558,7 @@ class UserWorkspaceBloc extends Bloc { if (currentWorkspace != null && result.shouldOpenWorkspace == true) { Log.info('init open workspace: ${currentWorkspace.workspaceId}'); - await _repository.openWorkspace( + await repository.openWorkspace( workspaceId: currentWorkspace.workspaceId, workspaceType: currentWorkspace.workspaceType, ); @@ -611,13 +611,13 @@ class UserWorkspaceBloc extends Bloc { String? initialWorkspaceId, }) async { try { - final currentWorkspaceResult = await _repository.getCurrentWorkspace(); + final currentWorkspaceResult = await repository.getCurrentWorkspace(); final currentWorkspace = currentWorkspaceResult.fold( (s) => s, (e) => null, ); final currentWorkspaceId = initialWorkspaceId ?? currentWorkspace?.id; - final workspacesResult = await _repository.getWorkspaces(); + final workspacesResult = await repository.getWorkspaces(); final workspaces = workspacesResult.getOrThrow(); if (workspaces.isEmpty && currentWorkspace != null) { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 162ada63f5..1b5ced1e0c 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,4 +1,5 @@ import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; +import 'package:appflowy/features/share_tab/data/models/share_access_level.dart'; import 'package:appflowy/features/workspace/data/repositories/rust_workspace_repository_impl.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -244,6 +245,7 @@ class _MobileViewPageState extends State { context.read().state.isImmersiveMode; final isLocked = context.read()?.state.isLocked ?? false; + final accessLevel = context.read().state.accessLevel; final actions = []; if (FeatureFlag.syncDocument.isOn) { @@ -273,7 +275,7 @@ class _MobileViewPageState extends State { ]); } - if (widget.showMoreButton) { + if (widget.showMoreButton && accessLevel != ShareAccessLevel.readOnly) { actions.addAll([ MobileViewPageMoreButton( view: view, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart index 0cec8b0f82..b7930fab59 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart @@ -111,12 +111,7 @@ class MobileViewPageMoreButton extends StatelessWidget { BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), BlocProvider.value(value: context.read()), - BlocProvider( - create: (context) => PageAccessLevelBloc(view: view) - ..add( - PageAccessLevelEvent.initial(), - ), - ), + BlocProvider.value(value: context.read()), ], child: MobileViewPageMoreBottomSheet(view: view), ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index 28ac0c2d2d..745601d8dc 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -44,10 +44,16 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { }, child: ViewPageBottomSheet( view: view, - onAction: (action, {arguments}) async => - _onAction(context, action, arguments), + onAction: (action, {arguments}) async => _onAction( + context, + action, + arguments, + ), onRename: (name) { - _onRename(context, name); + _onRename( + context, + name, + ); context.pop(); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart index 88cd88ee68..a8b7cda7da 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart @@ -347,7 +347,7 @@ enum FieldAction { title: LocaleKeys.grid_field_label.tr(), description: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () { + onConfirm: (_) { FieldBackendService.clearField( viewId: viewId, fieldId: fieldInfo.id, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart index 9a816f3de0..cc6e6d24ae 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart @@ -83,7 +83,7 @@ class _AiWriterScrollWrapperState extends State { description: LocaleKeys .ai_continueWritingEmptyDocumentDescription .tr(), - onConfirm: state.onConfirm, + onConfirm: (_) => state.onConfirm(), ); } }, @@ -151,7 +151,7 @@ class _AiWriterScrollWrapperState extends State { description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: stopAndExit, + onConfirm: (_) => stopAndExit(), onCancel: () {}, ); } else { @@ -182,7 +182,7 @@ class _AiWriterScrollWrapperState extends State { description: LocaleKeys.document_plugins_discardResponse.tr(), confirmLabel: LocaleKeys.button_discard.tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: stopAndExit, + onConfirm: (_) => stopAndExit(), onCancel: () {}, ).then((_) => dialogShown = false); } else { diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart index 41eede73f3..4d79eac3a9 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart @@ -96,6 +96,9 @@ class _ShareMenuButtonState extends State { child: ShareMenu( tabs: widget.tabs, viewName: state.viewName, + onClose: () { + popoverController.hide(); + }, ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart index 244ded0bf6..cb81d2af7c 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -1,4 +1,6 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/features/page_access_level/logic/page_access_level_bloc.dart'; +import 'package:appflowy/features/share_tab/data/models/models.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; @@ -262,6 +264,34 @@ class _PublishWidgetState extends State<_PublishWidget> { @override Widget build(BuildContext context) { + final accessLevel = context.read().state.accessLevel; + + Widget publishButton = PublishButton( + onPublish: () { + if (context.read().view.layout.isDatabaseView) { + // check if any database is selected + if (_selectedViews.isEmpty) { + showToastNotification( + message: LocaleKeys.publish_noDatabaseSelected.tr(), + ); + return; + } + } + + widget.onPublish(_selectedViews); + }, + ); + + if (accessLevel == ShareAccessLevel.readOnly) { + // readonly user can't publish a page. + publishButton = FlowyTooltip( + message: 'You are a readonly user, you can\'t publish a page.', + child: AbsorbPointer( + child: publishButton, + ), + ); + } + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -279,21 +309,7 @@ class _PublishWidgetState extends State<_PublishWidget> { ), const VSpace(16), ], - PublishButton( - onPublish: () { - if (context.read().view.layout.isDatabaseView) { - // check if any database is selected - if (_selectedViews.isEmpty) { - showToastNotification( - message: LocaleKeys.publish_noDatabaseSelected.tr(), - ); - return; - } - } - - widget.onPublish(_selectedViews); - }, - ), + publishButton, ], ); } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart index edf2968313..8e49bdb1bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/features/share_tab/data/repositories/local_share_with_user_repository_impl.dart'; +import 'package:appflowy/features/share_tab/data/repositories/rust_share_with_user_repository_impl.dart'; import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart'; import 'package:appflowy/features/workspace/logic/workspace_bloc.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -11,6 +11,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.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-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -25,9 +26,10 @@ class ShareButton extends StatelessWidget { @override Widget build(BuildContext context) { - final workspaceId = - context.read().state.currentWorkspace?.workspaceId ?? - ''; + final workspaceBloc = context.read(); + final workspaceId = workspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + final workspaceType = workspaceBloc.state.currentWorkspace?.workspaceType; + return MultiBlocProvider( providers: [ BlocProvider( @@ -43,12 +45,19 @@ class ShareButton extends StatelessWidget { )..add(const DatabaseTabBarEvent.initial()), ), BlocProvider( - create: (context) => ShareTabBloc( - // repository: RustShareWithUserRepositoryImpl(), - repository: LocalShareWithUserRepositoryImpl(), - pageId: view.id, - workspaceId: workspaceId, - )..add(ShareTabEvent.initialize()), + create: (context) { + final bloc = ShareTabBloc( + repository: RustShareWithUserRepositoryImpl(), + pageId: view.id, + workspaceId: workspaceId, + ); + + if (workspaceType != WorkspaceTypePB.LocalW) { + bloc.add(ShareTabEvent.initialize()); + } + + return bloc; + }, ), ], child: BlocListener( diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart index 7e4840dde1..be10d8d1f4 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart @@ -8,6 +8,12 @@ import 'package:appflowy/plugins/shared/share/export_tab.dart'; import 'package:appflowy/plugins/shared/share/share_bloc.dart'; import 'package:appflowy/plugins/shared/share/share_tab.dart' as share_plugin; import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -38,10 +44,12 @@ class ShareMenu extends StatefulWidget { super.key, required this.tabs, required this.viewName, + required this.onClose, }); final List tabs; final String viewName; + final VoidCallback onClose; @override State createState() => _ShareMenuState(); @@ -136,17 +144,103 @@ class _ShareMenuState extends State final workspaceId = workspace?.workspaceId ?? context.read().state.workspaceId; final pageId = context.read().state.viewId; + final isInProPlan = context + .read() + .state + .workspaceSubscriptionInfo + ?.plan == + WorkspacePlanPB.ProPlan; + return share_section.ShareTab( workspaceId: workspaceId, pageId: pageId, workspaceName: workspace?.name ?? '', workspaceIcon: workspace?.icon ?? '', + isInProPlan: isInProPlan, + onUpgradeToPro: () { + widget.onClose(); + + _showUpgradeToProDialog(context); + }, ); } return const share_plugin.ShareTab(); } } + + void _showUpgradeToProDialog(BuildContext context) { + final state = context.read().state; + final workspace = state.currentWorkspace; + if (workspace == null) { + Log.error('workspace is null'); + return; + } + + final workspaceId = workspace.workspaceId; + final subscriptionInfo = state.workspaceSubscriptionInfo; + final userProfile = state.userProfile; + if (subscriptionInfo == null) { + Log.error('subscriptionInfo is null'); + return; + } + + final role = workspace.role; + final title = switch (role) { + AFRolePB.Owner => + LocaleKeys.shareTab_upgradeToInviteGuest_title_owner.tr(), + AFRolePB.Member => + LocaleKeys.shareTab_upgradeToInviteGuest_title_member.tr(), + AFRolePB.Guest || + _ => + LocaleKeys.shareTab_upgradeToInviteGuest_title_guest.tr(), + }; + final description = switch (role) { + AFRolePB.Owner => + LocaleKeys.shareTab_upgradeToInviteGuest_description_owner.tr(), + AFRolePB.Member => + LocaleKeys.shareTab_upgradeToInviteGuest_description_member.tr(), + AFRolePB.Guest || + _ => + LocaleKeys.shareTab_upgradeToInviteGuest_description_guest.tr(), + }; + final style = switch (role) { + AFRolePB.Owner => ConfirmPopupStyle.cancelAndOk, + AFRolePB.Member || AFRolePB.Guest || _ => ConfirmPopupStyle.onlyOk, + }; + final confirmLabel = switch (role) { + AFRolePB.Owner => LocaleKeys.shareTab_upgrade.tr(), + AFRolePB.Member || AFRolePB.Guest || _ => LocaleKeys.button_ok.tr(), + }; + + if (role == AFRolePB.Owner) { + showDialog( + context: context, + builder: (_) => BlocProvider( + create: (_) => SettingsPlanBloc( + workspaceId: workspaceId, + userId: userProfile.id, + )..add(const SettingsPlanEvent.started()), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + subscriptionInfo: subscriptionInfo, + ), + ), + ); + } else { + showConfirmDialog( + context: Navigator.of(context, rootNavigator: true).context, + title: title, + description: description, + style: style, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.primary, + onConfirm: (context) { + // fixme: show the upgrade to pro dialog + }, + ); + } + } } class _Segment extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart index 16e82a3089..45728feef1 100644 --- a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/trash/src/sizes.dart'; @@ -14,6 +12,7 @@ import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -112,7 +111,7 @@ class _TrashPageState extends State { confirmLabel: LocaleKeys.trash_restore.tr(), title: LocaleKeys.trash_confirmRestoreAll_title.tr(), description: LocaleKeys.trash_confirmRestoreAll_caption.tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(const TrashEvent.restoreAll()), ), @@ -163,7 +162,7 @@ class _TrashPageState extends State { LocaleKeys.trash_restorePage_title.tr(args: [object.name]), description: LocaleKeys.trash_restorePage_caption.tr(), confirmLabel: LocaleKeys.trash_restore.tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(TrashEvent.putback(object.id)), ), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart index a8935e01f9..352c896e24 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart @@ -39,6 +39,9 @@ class DebugTask extends LaunchTask { }, ), ); + + // enable rust request tracing + // Dispatch.enableTracing = true; } } } diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index d5094f2f98..ab72f579b9 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -121,6 +121,21 @@ class UserBackendService implements IUserBackendService { }); } + static Future> getWorkspaceById( + String workspaceId, + ) async { + final result = await UserEventGetAllWorkspace().send(); + return result.fold( + (workspaces) { + final workspace = workspaces.items.firstWhere( + (workspace) => workspace.workspaceId == workspaceId, + ); + return FlowyResult.success(workspace); + }, + (error) => FlowyResult.failure(error), + ); + } + Future> openWorkspace( String workspaceId, WorkspaceTypePB workspaceType, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart index b870d0dc89..c4af8b4712 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart @@ -64,7 +64,7 @@ class SidebarToast extends StatelessWidget { description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), confirmLabel: LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), - onConfirm: () { + onConfirm: (_) { WidgetsBinding.instance.addPostFrameCallback( (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), ); @@ -78,7 +78,7 @@ class SidebarToast extends StatelessWidget { LocaleKeys.sideBar_singleFileProPlanLimitationDescription.tr(), confirmLabel: LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), - onConfirm: () { + onConfirm: (_) { WidgetsBinding.instance.addPostFrameCallback( (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index c55a1852e3..9e46795f22 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -369,9 +369,18 @@ class _SidebarState extends State<_Sidebar> { child: const _SidebarSearchButton(), ), ], - const VSpace(6.0), - // new page button - const SidebarNewPageButton(), + + if (context + .read() + .state + .currentWorkspace + ?.role != + AFRolePB.Guest) ...[ + const VSpace(6.0), + // new page button + const SidebarNewPageButton(), + ], + // scrollable document list const VSpace(12.0), Padding( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index ff87b15090..2a4fcb8b02 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -294,7 +294,7 @@ class ConfirmPopup extends StatefulWidget { final TextStyle? titleStyle; final String description; final TextStyle? descriptionStyle; - final VoidCallback onConfirm; + final void Function(BuildContext context) onConfirm; final VoidCallback? onCancel; final Color? confirmButtonColor; final ConfirmPopupStyle style; @@ -352,7 +352,7 @@ class _ConfirmPopupState extends State { Navigator.of(context).pop(); } else if (event is KeyUpEvent && event.logicalKey == LogicalKeyboardKey.enter) { - widget.onConfirm(); + widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } @@ -445,7 +445,7 @@ class _ConfirmPopupState extends State { return SpaceOkButton( onConfirm: () { - widget.onConfirm(); + widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } @@ -461,7 +461,7 @@ class _ConfirmPopupState extends State { Navigator.of(context).pop(); }, onConfirm: () { - widget.onConfirm(); + widget.onConfirm(context); if (widget.closeOnAction) { Navigator.of(context).pop(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart index 1614b992bf..238a4fcf5d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart @@ -57,15 +57,18 @@ class SidebarSpace extends StatelessWidget { if (state.views.isEmpty) { return const SizedBox.shrink(); } - return FavoriteFolder( - views: state.views.map((e) => e.item).toList(), + + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FavoriteFolder( + views: state.views.map((e) => e.item).toList(), + ), ); }, ), // shared if (FeatureFlag.sharedSection.isOn) ...[ - const VSpace(16.0), SharedSection( key: ValueKey(currentWorkspaceId), workspaceId: currentWorkspaceId, @@ -74,7 +77,6 @@ class SidebarSpace extends StatelessWidget { // spaces if (shouldShowSpaces) ...[ - const VSpace(16.0), // spaces const _Space(), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart index 3bb5fd388a..cdf7884054 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -176,7 +176,7 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { description: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), confirmLabel: LocaleKeys.button_yes.tr(), - onConfirm: () { + onConfirm: (_) { workspaceBloc.add( UserWorkspaceEvent.leaveWorkspace( workspaceId: workspace.workspaceId, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart index b647010b69..147b5f5c15 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -81,7 +81,7 @@ class AccountSignInOutButton extends StatelessWidget { title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), description: LocaleKeys.settings_menu_logoutPrompt.tr(), confirmLabel: LocaleKeys.button_yes.tr(), - onConfirm: () async { + onConfirm: (_) async { await getIt().signOut(); onAction(); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 4992864f99..1a36453ae0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -108,7 +108,7 @@ class LocalAiSettingHeader extends StatelessWidget { description: LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () { + onConfirm: (_) { context .read() .add(const LocalAiPluginEvent.toggle()); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart index 77c1116319..2abed9bf18 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -322,7 +322,7 @@ class _AITileState extends State<_AITile> { .settings_billingPage_addons_removeDialog_description .tr(namedArgs: {"plan": widget.plan.label.tr()}), confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(SettingsBillingEvent.cancelSubscription(widget.plan)), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index a2d911ea40..db01670a65 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -70,7 +70,7 @@ class SettingsManageDataView extends StatelessWidget { description: LocaleKeys .settings_manageDataPage_dataStorage_resetDialog_description .tr(), - onConfirm: () async { + onConfirm: (_) async { final directory = await appFlowyApplicationDataDirectory(); final path = directory.path; @@ -146,7 +146,7 @@ class SettingsManageDataView extends StatelessWidget { .settings_manageDataPage_cache_dialog_description .tr(), confirmLabel: LocaleKeys.button_ok.tr(), - onConfirm: () async { + onConfirm: (_) async { // clear all cache await getIt().clearAllCache(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart index 420daa8698..e21ca47466 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -74,7 +74,7 @@ class _SettingsPlanComparisonDialogState .settings_comparePlanDialog_paymentSuccess_description .tr(args: [readyState.successfulPlanUpgrade!.label]), confirmLabel: LocaleKeys.button_close.tr(), - onConfirm: () {}, + onConfirm: (_) {}, ); } @@ -211,7 +211,7 @@ class _SettingsPlanComparisonDialogState .settings_comparePlanDialog_downgradeDialog_downgradeLabel .tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: () => + onConfirm: (_) => context.read().add( SettingsPlanEvent.cancelSubscription( reason: reason, @@ -658,6 +658,7 @@ final _planLabels = [ ), _PlanItem( label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipFive.tr(), ), _PlanItem( label: @@ -706,7 +707,6 @@ final List<_CellItem> _freeLabels = [ ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), - icon: FlowySvgs.check_m, ), _CellItem( label: @@ -743,7 +743,6 @@ final List<_CellItem> _proLabels = [ ), _CellItem( label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), - icon: FlowySvgs.check_m, ), _CellItem( label: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart index 21896ead0e..f94a53a8c3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -385,7 +385,7 @@ class _PlanUsageSummary extends StatelessWidget { value: false, label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(), badgeLabel: - LocaleKeys.settings_planPage_planUsage_aiMaxBadge.tr(), + LocaleKeys.settings_planPage_planUsage_proBadge.tr(), onTap: () async { context.read().add( const SettingsPlanEvent.addSubscription( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart index d381cf0f2b..3730f5e22f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -69,7 +69,7 @@ class _SettingsShortcutsViewState extends State { confirmLabel: LocaleKeys .settings_shortcutsPage_resetDialog_buttonLabel .tr(), - onConfirm: () { + onConfirm: (_) { context.read().resetToDefault(); Navigator.of(context).pop(); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart index 5793f8f7ad..0dfc3825fc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -203,7 +203,7 @@ class SettingsWorkspaceView extends StatelessWidget { .settings_workspacePage_leaveWorkspacePrompt_content .tr(), style: ConfirmPopupStyle.cancelAndOk, - onConfirm: () => context.read().add( + onConfirm: (_) => context.read().add( currentWorkspaceMemberRole?.isOwner ?? false ? const WorkspaceSettingsEvent.deleteWorkspace() : const WorkspaceSettingsEvent.leaveWorkspace(), @@ -1172,7 +1172,7 @@ class _DocumentCursorColorSetting extends StatelessWidget { .tr(), style: ConfirmPopupStyle.cancelAndOk, confirmLabel: LocaleKeys.settings_common_reset.tr(), - onConfirm: () => context + onConfirm: (_) => context ..read().resetDocumentCursorColor() ..read().syncCursorColor(null), ); @@ -1242,7 +1242,7 @@ class _DocumentSelectionColorSetting extends StatelessWidget { .tr(), style: ConfirmPopupStyle.cancelAndOk, confirmLabel: LocaleKeys.settings_common_reset.tr(), - onConfirm: () => context + onConfirm: (_) => context ..read().resetDocumentSelectionColor() ..read().syncSelectionColor(null), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart index 9cd20d5236..f2e9255851 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/invite_member_by_link.dart @@ -102,7 +102,7 @@ class _Description extends StatelessWidget { description: LocaleKeys.settings_appearance_members_inviteFailedMemberLimit.tr(), confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); @@ -119,7 +119,7 @@ class _Description extends StatelessWidget { .settings_appearance_members_resetInviteLinkDescription .tr(), confirmLabel: LocaleKeys.settings_appearance_members_reset.tr(), - onConfirm: () { + onConfirm: (_) { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); @@ -181,7 +181,7 @@ class _CopyLinkButtonState extends State<_CopyLinkButton> { .settings_appearance_members_inviteFailedMemberLimit .tr(), confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart index 6ac78cadd4..f14cd937a6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart @@ -93,7 +93,7 @@ class _Description extends StatelessWidget { .settings_appearance_members_resetInviteLinkDescription .tr(), confirmLabel: LocaleKeys.settings_appearance_members_reset.tr(), - onConfirm: () { + onConfirm: (_) { context.read().add( const WorkspaceMemberEvent.generateInviteLink(), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart index b2921a1521..3f39ca9fbe 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -255,7 +255,7 @@ class WorkspaceMembersPage extends StatelessWidget { confirmLabel: LocaleKeys .settings_appearance_members_memberLimitExceededUpgrade .tr(), - onConfirm: () => context + onConfirm: (_) => context .read() .add(const WorkspaceMemberEvent.upgradePlan()), ); @@ -531,7 +531,7 @@ class _MemberMoreActionList extends StatelessWidget { .settings_appearance_members_areYouSureToRemoveMember .tr(), confirmLabel: LocaleKeys.button_yes.tr(), - onConfirm: () => context.read().add( + onConfirm: (_) => context.read().add( WorkspaceMemberEvent.removeWorkspaceMemberByEmail( action.member.email, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index e1046f8b93..767eadf3d8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -407,7 +407,7 @@ class AppFlowyCloudSyncLogEnabled extends StatelessWidget { description: LocaleKeys.settings_menu_enableSyncLogWarning.tr(), confirmLabel: LocaleKeys.button_confirm.tr(), - onConfirm: () { + onConfirm: (_) { context .read() .add(AppFlowyCloudSettingEvent.enableSyncLog(value)); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 7c14fb0f14..df98bda771 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -590,7 +590,7 @@ Future showConfirmDeletionDialog({ child: ConfirmPopup( title: title, description: description, - onConfirm: onConfirm, + onConfirm: (_) => onConfirm(), ), ), ); @@ -604,15 +604,16 @@ Future showConfirmDialog({ required String description, TextStyle? titleStyle, TextStyle? descriptionStyle, - VoidCallback? onConfirm, + void Function(BuildContext context)? onConfirm, VoidCallback? onCancel, String? confirmLabel, ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, WidgetBuilder? confirmButtonBuilder, + Color? confirmButtonColor, }) { return showDialog( context: context, - builder: (_) { + builder: (context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), @@ -625,10 +626,11 @@ Future showConfirmDialog({ titleStyle: titleStyle, descriptionStyle: descriptionStyle, confirmButtonBuilder: confirmButtonBuilder, - onConfirm: () => onConfirm?.call(), + onConfirm: (_) => onConfirm?.call(context), onCancel: () => onCancel?.call(), confirmLabel: confirmLabel, style: style, + confirmButtonColor: confirmButtonColor, ), ), ); @@ -640,7 +642,7 @@ Future showCancelAndConfirmDialog({ required BuildContext context, required String title, required String description, - VoidCallback? onConfirm, + void Function(BuildContext context)? onConfirm, VoidCallback? onCancel, String? confirmLabel, }) { @@ -656,7 +658,7 @@ Future showCancelAndConfirmDialog({ child: ConfirmPopup( title: title, description: description, - onConfirm: () => onConfirm?.call(), + onConfirm: (context) => onConfirm?.call(context), confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.primary, onCancel: () => onCancel?.call(), @@ -694,7 +696,7 @@ Future showCustomConfirmDialog({ child: ConfirmPopup( title: title, description: description, - onConfirm: () => onConfirm?.call(), + onConfirm: (_) => onConfirm?.call(), onCancel: onCancel, confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.primary, @@ -731,7 +733,7 @@ Future showCancelAndDeleteDialog({ child: ConfirmPopup( title: title, description: description, - onConfirm: () => onDelete?.call(), + onConfirm: (_) => onDelete?.call(), closeOnAction: closeOnAction, confirmLabel: confirmLabel, confirmButtonColor: Theme.of(context).colorScheme.error, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart index dc99d7048d..739e901c6b 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -241,6 +241,11 @@ class ViewTitleBar extends StatelessWidget { PageAccessLevelState pageAccessLevelState, ) { final theme = AppFlowyTheme.of(context); + final state = context.read().state; + + if (state.currentWorkspace?.workspaceType == WorkspaceTypePB.LocalW) { + return const SizedBox.shrink(); + } final iconName = switch (pageAccessLevelState.sectionType) { SharedSectionType.public => FlowySvgs.public_section_icon_m, @@ -264,8 +269,7 @@ class ViewTitleBar extends StatelessWidget { throw UnsupportedError('Unknown section type'), }; - final workspaceName = - context.read().state.currentWorkspace?.name; + final workspaceName = state.currentWorkspace?.name; final tooltipText = switch (pageAccessLevelState.sectionType) { SharedSectionType.public => 'Everyone at $workspaceName has access', SharedSectionType.private => 'Only you have access', diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 432b383662..3e18c11f28 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -48,32 +48,27 @@ class DispatchException implements Exception { } class Dispatch { + static bool enableTracing = false; + static Future> asyncRequest( FFIRequest request, ) async { - final event = request.event; + Future> _asyncRequest() async { + final bytesFuture = _sendToRust(request); + final response = await _extractResponse(bytesFuture); + final payload = _extractPayload(response); + return payload; + } - // FFIRequest => Rust SDK - final bytesFuture = _sendToRust(request); + if (enableTracing) { + final start = DateTime.now(); + final result = await _asyncRequest(); + final duration = DateTime.now().difference(start); + Log.debug('Dispatch ${request.event} took ${duration.inMilliseconds}ms'); + return result; + } - _infoLog(event, 'sent request'); - - // Rust SDK => FFIResponse - final response = await _extractResponse(bytesFuture); - - _infoLog(event, 'received response'); - - // FFIResponse's payload is the bytes of the Response object - final payload = _extractPayload(response); - - _infoLog(event, 'parsed payload'); - - return payload; - } - - // only enable it when you need to debug the dispatch - static void _infoLog(String event, String message) { - // Log.info('[dispatch] [$event] $message'); + return _asyncRequest(); } } diff --git a/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart b/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart index 8a370f74d5..cbe83d172b 100644 --- a/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart +++ b/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart @@ -39,7 +39,7 @@ void main() { child: ConfirmPopup( description: "desc", title: "title", - onConfirm: onConfirm, + onConfirm: (_) => onConfirm(), ), ), ); diff --git a/frontend/resources/flowy_icons/20x/upgrade_pro_crown.svg b/frontend/resources/flowy_icons/20x/upgrade_pro_crown.svg new file mode 100644 index 0000000000..f48ada8e5e --- /dev/null +++ b/frontend/resources/flowy_icons/20x/upgrade_pro_crown.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/resources/flowy_icons/20x/upgrade_to_pro_close.svg b/frontend/resources/flowy_icons/20x/upgrade_to_pro_close.svg new file mode 100644 index 0000000000..dbef356de4 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/upgrade_to_pro_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en-US.json b/frontend/resources/translations/en-US.json index cdc0eb9e8c..2ae8cdf044 100644 --- a/frontend/resources/translations/en-US.json +++ b/frontend/resources/translations/en-US.json @@ -951,7 +951,7 @@ "proBadge": "Pro", "aiMaxBadge": "AI Max", "aiOnDeviceBadge": "AI On-device for Mac", - "memberProToggle": "More members & unlimited AI", + "memberProToggle": "More members & unlimited AI & guest access", "aiMaxToggle": "Unlimited AI and access to advanced models", "aiOnDeviceToggle": "Local AI for ultimate privacy", "aiCredit": { @@ -1072,11 +1072,12 @@ "itemTwo": "Members", "itemThree": "Storage", "itemFour": "Real-time collaboration", - "itemFive": "Mobile app", + "itemFive": "Guest editors", "itemSix": "AI Responses", "itemSeven": "AI Images", "itemFileUpload": "File uploads", "customNamespace": "Custom namespace", + "tooltipFive": "Collaborate on specific pages with non-members", "tooltipSix": "Lifetime means the number of responses never reset", "intelligentSearch": "Intelligent search", "tooltipSeven": "Allows you to customize part of the URL for your workspace", @@ -1098,9 +1099,9 @@ "itemTwo": "Up to 10", "itemThree": "Unlimited", "itemFour": "yes", - "itemFive": "yes", + "itemFive": "Up to 100", "itemSix": "Unlimited", - "itemSeven": "10 images per month", + "itemSeven": "50 images per month", "itemFileUpload": "Unlimited", "intelligentSearch": "Intelligent search" }, @@ -3444,10 +3445,23 @@ "canMakeAnyChanges": "Can make any changes", "generalAccess": "General access", "peopleWithAccess": "People with access", - "peopleAboveCanAccessWithTheLink": "People above can access with the link" + "peopleAboveCanAccessWithTheLink": "People above can access with the link", + "upgrade": "Upgrade", + "toProPlanToInviteGuests": " to the Pro plan to invite guests to this page", + "upgradeToInviteGuest": { + "title": { + "owner": "Upgrade to invite guest", + "member": "Upgrade to invite guest", + "guest": "Upgrade to invite guest" + }, + "description": { + "owner": "Your workspace is currently on the Free plan. Upgrade to the Pro plan to allow external users to access this page as guests.", + "member": "Some invitees are outside your workspace. To invite them as guests, please contact your workspace owner to upgrade to the Pro plan.", + "guest": "Some invitees are outside your workspace. To invite them as guests, please contact your workspace owner to upgrade to the Pro plan." + } + } }, "shareSection": { - "shared": "Shared with me", - "refresh": "Refresh" + "shared": "Shared with me" } } \ No newline at end of file diff --git a/frontend/rust-lib/flowy-folder-pub/src/sql/mod.rs b/frontend/rust-lib/flowy-folder-pub/src/sql/mod.rs index 4177ed9026..87f650bf62 100644 --- a/frontend/rust-lib/flowy-folder-pub/src/sql/mod.rs +++ b/frontend/rust-lib/flowy-folder-pub/src/sql/mod.rs @@ -1 +1,2 @@ +pub mod workspace_shared_user_sql; pub mod workspace_shared_view_sql; diff --git a/frontend/rust-lib/flowy-folder-pub/src/sql/workspace_shared_user_sql.rs b/frontend/rust-lib/flowy-folder-pub/src/sql/workspace_shared_user_sql.rs new file mode 100644 index 0000000000..c8474fab9e --- /dev/null +++ b/frontend/rust-lib/flowy-folder-pub/src/sql/workspace_shared_user_sql.rs @@ -0,0 +1,174 @@ +use diesel::{RunQueryDsl, delete, insert_into}; +use flowy_error::FlowyResult; +use flowy_sqlite::schema::workspace_shared_user; +use flowy_sqlite::schema::workspace_shared_user::dsl; +use flowy_sqlite::{DBConnection, ExpressionMethods, SqliteConnection, prelude::*}; + +#[derive(Queryable, Insertable, AsChangeset, Debug, Clone)] +#[diesel(table_name = workspace_shared_user)] +#[diesel(primary_key(workspace_id, view_id, email))] +pub struct WorkspaceSharedUserTable { + pub workspace_id: String, + pub view_id: String, + pub email: String, + pub name: String, + pub avatar_url: String, + pub role: i32, + pub access_level: i32, + pub order: i32, +} + +#[allow(clippy::too_many_arguments)] +impl WorkspaceSharedUserTable { + pub fn new( + workspace_id: String, + view_id: String, + email: String, + name: String, + avatar_url: String, + role: i32, + access_level: i32, + order: i32, + ) -> Self { + Self { + workspace_id, + view_id, + email, + name, + avatar_url, + role, + access_level, + order, + } + } +} + +pub fn upsert_workspace_shared_user>( + conn: &mut SqliteConnection, + shared_user: T, +) -> FlowyResult<()> { + let shared_user = shared_user.into(); + + insert_into(workspace_shared_user::table) + .values(&shared_user) + .on_conflict(( + workspace_shared_user::workspace_id, + workspace_shared_user::view_id, + workspace_shared_user::email, + )) + .do_update() + .set(&shared_user) + .execute(conn)?; + + Ok(()) +} + +pub fn select_workspace_shared_user( + mut conn: DBConnection, + workspace_id: &str, + view_id: &str, + email: &str, +) -> FlowyResult { + let shared_user = dsl::workspace_shared_user + .filter(workspace_shared_user::workspace_id.eq(workspace_id)) + .filter(workspace_shared_user::view_id.eq(view_id)) + .filter(workspace_shared_user::email.eq(email)) + .first::(&mut conn)?; + + Ok(shared_user) +} + +pub fn select_all_workspace_shared_users( + mut conn: DBConnection, + workspace_id: &str, + view_id: &str, +) -> FlowyResult> { + let shared_users = dsl::workspace_shared_user + .filter(workspace_shared_user::workspace_id.eq(workspace_id)) + .filter(workspace_shared_user::view_id.eq(view_id)) + .order(workspace_shared_user::order.desc()) + .load::(&mut conn)?; + Ok(shared_users) +} + +pub fn select_all_workspace_shared_users_by_workspace( + mut conn: DBConnection, + workspace_id: &str, +) -> FlowyResult> { + let shared_users = dsl::workspace_shared_user + .filter(workspace_shared_user::workspace_id.eq(workspace_id)) + .order(workspace_shared_user::order.desc()) + .load::(&mut conn)?; + Ok(shared_users) +} + +pub fn upsert_workspace_shared_users + Clone>( + conn: &mut SqliteConnection, + _workspace_id: &str, + _view_id: &str, + shared_users: &[T], +) -> FlowyResult<()> { + for shared_user in shared_users.iter().cloned() { + let shared_user: WorkspaceSharedUserTable = shared_user.into(); + insert_into(workspace_shared_user::table) + .values(&shared_user) + .on_conflict(( + workspace_shared_user::workspace_id, + workspace_shared_user::view_id, + workspace_shared_user::email, + )) + .do_update() + .set(&shared_user) + .execute(conn)?; + } + Ok(()) +} + +/// Removes all workspace_shared_user items for the given workspace_id and view_id, then inserts the provided new items. +pub fn replace_all_workspace_shared_users + Clone>( + conn: &mut SqliteConnection, + workspace_id: &str, + view_id: &str, + new_shared_users: &[T], +) -> FlowyResult<()> { + // Remove all existing items for the workspace_id and view_id + delete( + workspace_shared_user::table + .filter(workspace_shared_user::workspace_id.eq(workspace_id)) + .filter(workspace_shared_user::view_id.eq(view_id)), + ) + .execute(conn)?; + + upsert_workspace_shared_users(conn, workspace_id, view_id, new_shared_users)?; + + Ok(()) +} + +/// Removes a specific workspace_shared_user by workspace_id, view_id, and email. +pub fn delete_workspace_shared_user( + conn: &mut SqliteConnection, + workspace_id: &str, + view_id: &str, + email: &str, +) -> FlowyResult<()> { + delete( + workspace_shared_user::table + .filter(workspace_shared_user::workspace_id.eq(workspace_id)) + .filter(workspace_shared_user::view_id.eq(view_id)) + .filter(workspace_shared_user::email.eq(email)), + ) + .execute(conn)?; + + Ok(()) +} + +/// Removes all workspace_shared_user items for the given workspace_id. +pub fn delete_all_workspace_shared_users_by_workspace( + conn: &mut SqliteConnection, + workspace_id: &str, +) -> FlowyResult<()> { + delete(workspace_shared_user::table.filter(workspace_shared_user::workspace_id.eq(workspace_id))) + .execute(conn)?; + + Ok(()) +} diff --git a/frontend/rust-lib/flowy-folder-pub/src/workspace_shared_view_sql.rs b/frontend/rust-lib/flowy-folder-pub/src/workspace_shared_view_sql.rs deleted file mode 100644 index 718e616d82..0000000000 --- a/frontend/rust-lib/flowy-folder-pub/src/workspace_shared_view_sql.rs +++ /dev/null @@ -1,51 +0,0 @@ -use diesel::{RunQueryDsl, insert_into}; -use flowy_error::FlowyResult; -use flowy_sqlite::schema::workspace_shared_view; -use flowy_sqlite::schema::workspace_shared_view::dsl; -use flowy_sqlite::{DBConnection, ExpressionMethods, prelude::*}; - -#[derive(Queryable, Insertable, AsChangeset, Debug, Clone)] -#[diesel(table_name = workspace_shared_view)] -#[diesel(primary_key(uid, workspace_id, view_id))] -pub struct WorkspaceSharedViewTable { - pub uid: i64, - pub workspace_id: String, - pub view_id: String, - pub permission_id: i32, - pub created_at: Option, -} - -pub fn upsert_workspace_shared_view>( - conn: &mut SqliteConnection, - shared_view: T, -) -> FlowyResult<()> { - let shared_view = shared_view.into(); - - insert_into(workspace_shared_view::table) - .values(&shared_view) - .on_conflict(( - workspace_shared_view::uid, - workspace_shared_view::workspace_id, - workspace_shared_view::view_id, - )) - .do_update() - .set(&shared_view) - .execute(conn)?; - - Ok(()) -} - -pub fn select_workspace_shared_view( - mut conn: DBConnection, - workspace_id: &str, - view_id: &str, - uid: i64, -) -> FlowyResult { - let shared_view = dsl::workspace_shared_view - .filter(workspace_shared_view::workspace_id.eq(workspace_id)) - .filter(workspace_shared_view::view_id.eq(view_id)) - .filter(workspace_shared_view::uid.eq(uid)) - .first::(&mut conn)?; - - Ok(shared_view) -} diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 469d16f676..adcd35eb96 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -6,6 +6,7 @@ use collab_folder::{View, ViewIcon, ViewLayout}; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_folder_pub::cloud::gen_view_id; +use flowy_folder_pub::sql::workspace_shared_user_sql::WorkspaceSharedUserTable; use lib_infra::validator_fn::required_not_empty_str; use std::collections::HashMap; use std::collections::HashSet; @@ -798,6 +799,17 @@ impl From for AFRole { } } +impl From for AFRolePB { + fn from(value: i32) -> Self { + match value { + 0 => AFRolePB::Owner, + 1 => AFRolePB::Member, + 2 => AFRolePB::Guest, + _ => AFRolePB::Guest, + } + } +} + #[derive(Default, ProtoBuf, Clone, Debug)] pub struct SharePageWithUserPayloadPB { #[pb(index = 1)] @@ -872,6 +884,22 @@ impl From for SharedUserPB { } } +impl From for SharedUserPB { + fn from(table: WorkspaceSharedUserTable) -> Self { + SharedUserPB { + email: table.email, + name: table.name, + role: AFRolePB::from(table.role), + access_level: AFAccessLevelPB::from(table.access_level), + avatar_url: if table.avatar_url.is_empty() { + None + } else { + Some(table.avatar_url) + }, + } + } +} + #[derive(Default, ProtoBuf, Clone, Debug)] pub struct RepeatedSharedUserPB { #[pb(index = 1)] diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index ab38461925..571c7c9894 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -1,10 +1,11 @@ use crate::entities::icon::UpdateViewIconParams; use crate::entities::{ - AFAccessLevelPB, CreateViewParams, DeletedViewPB, DuplicateViewParams, FolderSnapshotPB, - MoveNestedViewParams, RepeatedSharedViewResponsePB, RepeatedTrashPB, RepeatedViewIdPB, - RepeatedViewPB, SharedViewPB, SharedViewSectionPB, UpdateViewParams, ViewLayoutPB, ViewPB, - ViewSectionPB, WorkspaceLatestPB, WorkspacePB, view_pb_with_all_child_views, - view_pb_with_child_views, view_pb_without_child_views, view_pb_without_child_views_from_arc, + AFAccessLevelPB, AFRolePB, CreateViewParams, DeletedViewPB, DuplicateViewParams, + FolderSnapshotPB, MoveNestedViewParams, RepeatedSharedUserPB, RepeatedSharedViewResponsePB, + RepeatedTrashPB, RepeatedViewIdPB, RepeatedViewPB, SharedUserPB, SharedViewPB, + SharedViewSectionPB, UpdateViewParams, ViewLayoutPB, ViewPB, ViewSectionPB, WorkspaceLatestPB, + WorkspacePB, view_pb_with_all_child_views, view_pb_with_child_views, view_pb_without_child_views, + view_pb_without_child_views_from_arc, }; use crate::manager_observer::{ ChildViewChangeReason, notify_child_views_changed, notify_did_update_workspace, @@ -18,11 +19,11 @@ use crate::view_operation::{ FolderOperationHandler, FolderOperationHandlers, GatherEncodedCollab, ViewData, create_view, }; use arc_swap::ArcSwapOption; -use client_api::entity::PublishInfo; use client_api::entity::guest_dto::{ - RevokeSharedViewAccessRequest, ShareViewWithGuestRequest, SharedViewDetails, + RevokeSharedViewAccessRequest, ShareViewWithGuestRequest, SharedUser, SharedViewDetails, }; use client_api::entity::workspace_dto::PublishInfoView; +use client_api::entity::{AFAccessLevel, AFRole, PublishInfo}; use collab::core::collab::{DataSource, IndexContentReceiver}; use collab::lock::RwLock; use collab_entity::{CollabType, EncodedCollab}; @@ -42,6 +43,10 @@ use flowy_folder_pub::entities::{ PublishDatabaseData, PublishDatabasePayload, PublishDocumentPayload, PublishPayload, PublishViewInfo, PublishViewMeta, PublishViewMetaData, }; +use flowy_folder_pub::sql::workspace_shared_user_sql::{ + WorkspaceSharedUserTable, delete_workspace_shared_user, replace_all_workspace_shared_users, + select_all_workspace_shared_users, +}; use flowy_folder_pub::sql::workspace_shared_view_sql::{ WorkspaceSharedViewTable, replace_all_workspace_shared_views, select_all_workspace_shared_views, }; @@ -1384,10 +1389,69 @@ impl FolderManager { params: ShareViewWithGuestRequest, ) -> Result<(), FlowyError> { let workspace_id = self.user.workspace_id()?; + let view_id = params.view_id; + self .cloud_service()? .share_page_with_user(&workspace_id, params) .await?; + + let cloud_workspace_id = workspace_id; + let cloud_page_id = view_id; + let user = self.user.clone(); + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + if let Some(cloud_service) = cloud_service.upgrade() { + if let Ok(details) = cloud_service + .get_shared_page_details(&cloud_workspace_id, &cloud_page_id) + .await + { + if let Ok(uid) = user.user_id() { + if let Ok(mut conn) = user.sqlite_connection(uid) { + let shared_users = details + .shared_with + .iter() + .enumerate() + .map(|(order, user)| { + WorkspaceSharedUserTable::new( + cloud_workspace_id.to_string(), + cloud_page_id.to_string(), + user.email.clone(), + user.name.clone(), + user.avatar_url.clone().unwrap_or_default(), + user.role.clone() as i32, + user.access_level as i32, + order as i32, + ) + }) + .collect::>(); + + let _ = replace_all_workspace_shared_users( + &mut conn, + &cloud_workspace_id.to_string(), + &cloud_page_id.to_string(), + &shared_users, + ); + + // Notify UI to refresh the shared page details + folder_notification_builder( + cloud_page_id.to_string(), + FolderNotification::DidUpdateSharedUsers, + ) + .payload(RepeatedSharedUserPB { + items: details + .shared_with + .into_iter() + .map(|user| user.into()) + .collect(), + }) + .send(); + } + } + } + } + }); + Ok(()) } @@ -1398,10 +1462,46 @@ impl FolderManager { params: RevokeSharedViewAccessRequest, ) -> Result<(), FlowyError> { let workspace_id = self.user.workspace_id()?; + let emails_to_revoke = params.emails.clone(); + self .cloud_service()? .revoke_shared_page_access(&workspace_id, page_id, params) .await?; + + let uid = self.user.user_id()?; + let mut conn = self.user.sqlite_connection(uid)?; + + for email in emails_to_revoke { + let _ = delete_workspace_shared_user( + &mut conn, + &workspace_id.to_string(), + &page_id.to_string(), + &email, + ); + } + + if let Ok(updated_shared_users) = select_all_workspace_shared_users( + self.user.sqlite_connection(uid)?, + &workspace_id.to_string(), + &page_id.to_string(), + ) { + let updated_users_pb: Vec = updated_shared_users + .into_iter() + .map(|user| user.into()) + .collect(); + + // Notify UI to refresh the shared page details + folder_notification_builder( + page_id.to_string(), + FolderNotification::DidUpdateSharedUsers, + ) + .payload(RepeatedSharedUserPB { + items: updated_users_pb, + }) + .send(); + } + Ok(()) } @@ -1411,11 +1511,99 @@ impl FolderManager { page_id: &Uuid, ) -> Result { let workspace_id = self.user.workspace_id()?; - let result = self - .cloud_service()? - .get_shared_page_details(&workspace_id, page_id) - .await?; - Ok(result) + let uid = self.user.user_id()?; + let conn = self.user.sqlite_connection(uid)?; + + let mut local_shared_details = None; + + // 1. Get the data from the local database first + if let Ok(shared_details) = + select_all_workspace_shared_users(conn, &workspace_id.to_string(), &page_id.to_string()) + { + let shared_with = shared_details + .into_iter() + .map(|user| SharedUser { + email: user.email, + name: user.name, + access_level: AFAccessLevel::from(AFAccessLevelPB::from(user.access_level)), + role: AFRole::from(AFRolePB::from(user.role)), + avatar_url: if user.avatar_url.is_empty() { + None + } else { + Some(user.avatar_url) + }, + }) + .collect(); + + local_shared_details = Some(SharedViewDetails { + view_id: *page_id, + shared_with, + }); + } + + // 2. Fetch the data from the cloud service and persist to the local database + let cloud_workspace_id = workspace_id; + let cloud_page_id = *page_id; + let user = self.user.clone(); + let cloud_service = self.cloud_service.clone(); + tokio::spawn(async move { + if let Some(cloud_service) = cloud_service.upgrade() { + if let Ok(details) = cloud_service + .get_shared_page_details(&cloud_workspace_id, &cloud_page_id) + .await + { + if let Ok(mut conn) = user.sqlite_connection(uid) { + let shared_users = details + .shared_with + .iter() + .enumerate() + .map(|(order, user)| { + WorkspaceSharedUserTable::new( + cloud_workspace_id.to_string(), + cloud_page_id.to_string(), + user.email.clone(), + user.name.clone(), + user.avatar_url.clone().unwrap_or_default(), + user.role.clone() as i32, + user.access_level as i32, + order as i32, + ) + }) + .collect::>(); + + let _ = replace_all_workspace_shared_users( + &mut conn, + &cloud_workspace_id.to_string(), + &cloud_page_id.to_string(), + &shared_users, + ); + + // Notify UI to refresh the shared page details + folder_notification_builder( + cloud_page_id.to_string(), + FolderNotification::DidUpdateSharedUsers, + ) + .payload(RepeatedSharedUserPB { + items: details + .shared_with + .into_iter() + .map(|user| user.into()) + .collect(), + }) + .send(); + } + } + } + }); + + if let Some(local_shared_details) = local_shared_details { + Ok(local_shared_details) + } else { + Err(FlowyError::new( + ErrorCode::Internal, + "Failed to get shared page details".to_string(), + )) + } } /// Publishes a view identified by the given `view_id`. diff --git a/frontend/rust-lib/flowy-folder/src/notification.rs b/frontend/rust-lib/flowy-folder/src/notification.rs index a887779527..17bf2583fd 100644 --- a/frontend/rust-lib/flowy-folder/src/notification.rs +++ b/frontend/rust-lib/flowy-folder/src/notification.rs @@ -41,6 +41,7 @@ pub enum FolderNotification { DidUpdateSectionViews = 39, DidUpdateSharedViews = 40, + DidUpdateSharedUsers = 41, } #[tracing::instrument(level = "trace", skip_all)] diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/down.sql index 2c466f11c8..afd5ecbe7c 100644 --- a/frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/down.sql +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-05-19-074647_create_shared_views_table/down.sql @@ -1,2 +1,2 @@ -- This file should undo anything in `up.sql` -DROP TABLE IF EXISTS af_workspace_shared_view; +DROP TABLE IF EXISTS workspace_shared_view; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/down.sql new file mode 100644 index 0000000000..da1a0aea85 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS workspace_shared_user; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/up.sql new file mode 100644 index 0000000000..fa0909c1a8 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-072900_create_shared_users_table/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS workspace_shared_user ( + workspace_id TEXT NOT NULL, + view_id TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT NOT NULL, + role INTEGER NOT NULL, + access_level INTEGER NOT NULL, + PRIMARY KEY (workspace_id, view_id, email) +); diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/down.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/down.sql new file mode 100644 index 0000000000..535fbbc142 --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/down.sql @@ -0,0 +1,24 @@ +-- This file should undo anything in `up.sql` + +-- SQLite does not support DROP COLUMN directly, so we recreate the table without the 'order' column +PRAGMA foreign_keys=off; + +CREATE TABLE IF NOT EXISTS workspace_shared_user_temp ( + workspace_id TEXT NOT NULL, + view_id TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + avatar_url TEXT NOT NULL, + role INTEGER NOT NULL, + access_level INTEGER NOT NULL, + PRIMARY KEY (workspace_id, view_id, email) +); + +INSERT INTO workspace_shared_user_temp (workspace_id, view_id, email, name, avatar_url, role, access_level) +SELECT workspace_id, view_id, email, name, avatar_url, role, access_level +FROM workspace_shared_user; + +DROP TABLE workspace_shared_user; +ALTER TABLE workspace_shared_user_temp RENAME TO workspace_shared_user; + +PRAGMA foreign_keys=on; diff --git a/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/up.sql b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/up.sql new file mode 100644 index 0000000000..067c6107ab --- /dev/null +++ b/frontend/rust-lib/flowy-sqlite/migrations/2025-06-04-123016_update_shared_users_table_order/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here + +ALTER TABLE workspace_shared_user ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; diff --git a/frontend/rust-lib/flowy-sqlite/src/schema.rs b/frontend/rust-lib/flowy-sqlite/src/schema.rs index e6dfc4437c..67a17e7240 100644 --- a/frontend/rust-lib/flowy-sqlite/src/schema.rs +++ b/frontend/rust-lib/flowy-sqlite/src/schema.rs @@ -147,6 +147,19 @@ diesel::table! { } } +diesel::table! { + workspace_shared_user (workspace_id, view_id, email) { + workspace_id -> Text, + view_id -> Text, + email -> Text, + name -> Text, + avatar_url -> Text, + role -> Integer, + access_level -> Integer, + order -> Integer, + } +} + diesel::table! { workspace_shared_view (uid, workspace_id, view_id) { uid -> BigInt, @@ -172,5 +185,6 @@ diesel::allow_tables_to_appear_in_same_query!( user_workspace_table, workspace_members_table, workspace_setting_table, + workspace_shared_user, workspace_shared_view, );