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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/edito
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart';
import 'package:appflowy/plugins/inline_actions/handlers/all_members_notification.dart';
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart';
Expand Down Expand Up @@ -83,6 +84,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
if (FeatureFlag.inlineSubPageMention.isOn)
InlineChildPageService(currentViewId: documentBloc.documentId),
InlinePageReferenceService(currentViewId: documentBloc.documentId),
AllMembersNotificationService(context),
DateReferenceService(context),
ReminderReferenceService(context),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ extension TextDeltaExtension on Delta {
if (mentionPageId != null) {
text += await getMentionPageName(mentionPageId);
continue;
} else if (mentionType == MentionType.allMembers.name) {
final label = mention?[MentionBlockKeys.label] ?? 'all';
text += '@$label';
continue;
} else if (mentionType == MentionType.externalLink.name) {
final url = mention?[MentionBlockKeys.url] ?? '';
final info = await LinkInfoCache.get(url);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';

class MentionAllMembersBlock extends StatelessWidget {
const MentionAllMembersBlock({
super.key,
required this.mention,
this.textStyle,
});

final Map<String, dynamic> mention;
final TextStyle? textStyle;

@override
Widget build(BuildContext context) {
final label = mention[MentionBlockKeys.label] as String? ?? 'all';
final effectiveStyle = textStyle?.copyWith(
leadingDistribution: TextLeadingDistribution.even,
);

final iconSize = (textStyle?.fontSize ?? 14.0) / 14.0 * 16.0;

return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'@$label',
style: effectiveStyle,
strutStyle: effectiveStyle != null
? StrutStyle.fromTextStyle(effectiveStyle)
: null,
),
const HSpace(4),
FlowySvg(
FlowySvgs.settings_members_m,
size: Size.square(iconSize),
color: effectiveStyle?.color,
),
],
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_all_members_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
Expand All @@ -12,13 +13,15 @@ enum MentionType {
page,
date,
externalLink,
childPage;
childPage,
allMembers;

static MentionType fromString(String value) => switch (value) {
'page' => page,
'date' => date,
'externalLink' => externalLink,
'childPage' => childPage,
'allMembers' => allMembers,
// Backwards compatibility
'reminder' => date,
_ => throw UnimplementedError(),
Expand Down Expand Up @@ -58,6 +61,10 @@ class MentionBlockKeys {
static const includeTime = 'include_time';
static const reminderId = 'reminder_id'; // ReminderID
static const reminderOption = 'reminder_option';
static const workspaceId = 'workspace_id';
static const memberCount = 'member_count';
static const memberEmails = 'member_emails';
static const label = 'label';

static const mentionChar = '\$';

Expand Down Expand Up @@ -92,6 +99,22 @@ class MentionBlockKeys {
},
};
}

static Map<String, dynamic> buildMentionAllMembersAttributes({
required String workspaceId,
required List<String> memberEmails,
String label = 'all',
}) {
return {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.allMembers.name,
MentionBlockKeys.workspaceId: workspaceId,
MentionBlockKeys.memberCount: memberEmails.length,
Comment on lines +103 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Storing full member email lists in the mention attributes is potentially heavy and exposes sensitive data in the document model.

buildMentionAllMembersAttributes embeds the full memberEmails list into the document state, so every snapshot/sync will include all workspace member emails. This:

  • Exposes sensitive email data to any client that can read the document, regardless of workspace‑level permissions.
  • Increases document/delta size and network payloads, especially for large workspaces.

Consider storing only minimal identifiers (e.g. workspaceId plus a token or memberCount) and resolving the actual member list server‑side in the notification flow instead of persisting raw emails in the document model.

MentionBlockKeys.memberEmails: memberEmails,
MentionBlockKeys.label: label,
},
};
}
}

class MentionBlock extends StatelessWidget {
Expand Down Expand Up @@ -173,6 +196,11 @@ class MentionBlock extends StatelessWidget {
node: node,
index: index,
);
case MentionType.allMembers:
return MentionAllMembersBlock(
mention: mention,
textStyle: textStyle,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,9 @@ class EditorStyleCustomizer {
child: MentionBlock(
key: ValueKey(
switch (type) {
MentionType.page => mention[MentionBlockKeys.pageId],
MentionType.date => mention[MentionBlockKeys.date],
'page' => mention[MentionBlockKeys.pageId],
'date' => mention[MentionBlockKeys.date],
'allMembers' => mention[MentionBlockKeys.label],
_ => MentionBlockKeys.mention,
},
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/service_handler.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class AllMembersNotificationService extends InlineActionsDelegate {
AllMembersNotificationService(this.context);

final BuildContext context;

static const _keywords = ['all', 'everyone', 'members', 'notify'];

List<WorkspaceMemberPB>? _members;
String? _workspaceId;

@override
Future<InlineActionsResult> search([
String? search,
]) async {
final workspace =
context.read<UserWorkspaceBloc?>()?.state.currentWorkspace;
if (workspace == null ||
workspace.workspaceType == WorkspaceTypePB.LocalW) {
return InlineActionsResult(title: 'Notifications', results: const []);
}

if (_workspaceId != workspace.workspaceId || _members == null) {
_workspaceId = workspace.workspaceId;
_members = await _fetchWorkspaceMembers(workspace.workspaceId);
}

final members = _members ?? const <WorkspaceMemberPB>[];
if (members.isEmpty) {
return InlineActionsResult(title: 'Notifications', results: const []);
}

if (!_matchesSearch(search)) {
return InlineActionsResult(title: 'Notifications', results: const []);
}

return InlineActionsResult(
title: 'Notifications',
startsWithKeywords: _keywords,
results: [
InlineActionsMenuItem(
label: '@all',
keywords: _keywords,
iconBuilder: (_) => const FlowySvg(
FlowySvgs.settings_members_m,
size: Size.square(16),
),
onSelected: (context, editorState, menuService, replace) =>
_insertMention(
editorState,
workspace.workspaceId,
members,
replace.$1,
replace.$2,
),
),
],
);
}

bool _matchesSearch(String? search) {
if (search == null || search.isEmpty) {
return true;
}

final normalized = search.toLowerCase();
return _keywords.any((keyword) => keyword.contains(normalized));
}

Future<List<WorkspaceMemberPB>> _fetchWorkspaceMembers(
String workspaceId,
) async {
final userProfile = context.read<UserWorkspaceBloc?>()?.state.userProfile;
if (userProfile == null) {
return const [];
}

final result = await UserBackendService(
userId: userProfile.id,
).getWorkspaceMembers(workspaceId);

return result.fold(
(success) => success.items,
(_) => const [],
);
}

Future<void> _insertMention(
EditorState editorState,
String workspaceId,
List<WorkspaceMemberPB> members,
int start,
int end,
) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}

final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}

final memberEmails = members
.map((member) => member.email)
.where((email) => email.isNotEmpty)
.toList();
if (memberEmails.isEmpty) {
return;
}

final transaction = editorState.transaction
..replaceText(
node,
start,
end,
MentionBlockKeys.mentionChar,
attributes: MentionBlockKeys.buildMentionAllMembersAttributes(
workspaceId: workspaceId,
memberEmails: memberEmails,
),
);

await editorState.apply(transaction);
}
}
Loading