import React from 'react';
import uuid from 'uuid';
import { Fragment, Node, Schema } from 'prosemirror-model';
import { EditorState, Plugin, PluginKey, StateField } from 'prosemirror-state';
import debounce from 'lodash/debounce';
import { AnalyticsEventPayload } from '@atlaskit/analytics-next';
import {
ELEMENTS_CHANNEL,
isResolvingMentionProvider,
MentionDescription,
SLI_EVENT_TYPE,
SMART_EVENT_TYPE,
buildSliPayload,
MentionProvider,
TeamMentionProvider,
} from '@atlaskit/mention/resource';
import {
TeamMentionHighlight,
TeamMentionHighlightController,
} from '@atlaskit/mention/spotlight';
import { MentionItem } from '@atlaskit/mention/item';
import { TeamMember } from '@atlaskit/mention/team-resource';
import { mention } from '@atlaskit/adf-schema';
import {
ContextIdentifierProvider,
ProviderFactory,
} from '@atlaskit/editor-common';
import { Command, EditorPlugin } from '../../types';
import { Dispatch } from '../../event-dispatcher';
import { PortalProviderAPI } from '../../ui/PortalProvider';
import WithPluginState from '../../ui/WithPluginState';
import {
createInitialPluginState,
pluginKey as typeAheadPluginKey,
TypeAheadPluginState,
} from '../type-ahead/pm-plugins/main';
import InviteItem, { INVITE_ITEM_DESCRIPTION } from './ui/InviteItem';
import ToolbarMention from './ui/ToolbarMention';
import mentionNodeView from './nodeviews/mention';
import {
buildTypeAheadCancelPayload,
buildTypeAheadInsertedPayload,
buildTypeAheadInviteExposurePayload,
buildTypeAheadInviteItemClickedPayload,
buildTypeAheadInviteItemViewedPayload,
buildTypeAheadRenderedPayload,
} from './analytics';
import {
ACTION,
ACTION_SUBJECT,
ACTION_SUBJECT_ID,
addAnalytics,
AnalyticsDispatch,
analyticsPluginKey,
EVENT_TYPE,
INPUT_METHOD,
} from '../analytics';
import { TypeAheadItem } from '../type-ahead/types';
import { isInviteItem, isTeamStats, isTeamType } from './utils';
import { IconMention } from '../quick-insert/assets';
import { messages } from '../insert-block/ui/ToolbarInsertBlock/messages';
import {
MentionPluginOptions,
MentionPluginState,
TeamInfoAttrAnalytics,
} from './types';
import { analyticsEventKey } from '../analytics/consts';
import { EventDispatcher } from '../../event-dispatcher';
const EMPTY: MentionDescription[] = [];
export const mentionToTypeaheadItem = (
mention: MentionDescription,
): TypeAheadItem => {
return {
title: mention.id,
render: ({ isSelected, onClick, onHover }) => (
),
mention,
};
};
export function memoize<
ResultFn extends (mention: MentionDescription) => TypeAheadItem
>(fn: ResultFn): { call: ResultFn; clear(): void } {
// Cache results here
const seen = new Map();
function memoized(mention: MentionDescription): TypeAheadItem {
// Check cache for hits
const hit = seen.get(mention.id);
if (hit) {
return hit;
}
// Generate new result and cache it
const result = fn(mention);
seen.set(mention.id, result);
return result;
}
return {
call: memoized as ResultFn,
clear: seen.clear.bind(seen),
};
}
const memoizedToItem = memoize(mentionToTypeaheadItem);
const mentionsPlugin = (options?: MentionPluginOptions): EditorPlugin => {
let sessionId = uuid();
const fireEvent = (payload: T): void => {
if (options && options.createAnalyticsEvent) {
if (payload.attributes && !payload.attributes.sessionId) {
payload.attributes.sessionId = sessionId;
}
options.createAnalyticsEvent(payload).fire(ELEMENTS_CHANNEL);
}
};
let shouldTrackInviteItemExposure = false;
let inviteExperimentFirstQueryWithNoResults = '';
const debouncedFireEvent = debounce(fireEvent, 200);
return {
name: 'mention',
nodes() {
return [{ name: 'mention', node: mention }];
},
pmPlugins() {
return [
{
name: 'mention',
plugin: ({
providerFactory,
dispatch,
portalProviderAPI,
eventDispatcher,
}) =>
mentionPluginFactory(
dispatch,
providerFactory,
portalProviderAPI,
eventDispatcher,
fireEvent,
options,
),
},
];
},
secondaryToolbarComponent({ editorView, disabled }) {
return (
!mentionState.mentionProvider ? null : (
)
}
/>
);
},
pluginsOptions: {
quickInsert: ({ formatMessage }) => [
{
id: 'mention',
title: formatMessage(messages.mention),
description: formatMessage(messages.mentionDescription),
keywords: ['team', 'user'],
priority: 400,
keyshortcut: '@',
icon: () => ,
action(insert, state) {
const mark = state.schema.mark('typeAheadQuery', {
trigger: '@',
});
const mentionText = state.schema.text('@', [mark]);
const tr = insert(mentionText);
return addAnalytics(state, tr, {
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: ACTION_SUBJECT_ID.TYPEAHEAD_MENTION,
attributes: { inputMethod: INPUT_METHOD.QUICK_INSERT },
eventType: EVENT_TYPE.UI,
});
},
},
],
typeAhead: {
trigger: '@',
// Custom regex must have a capture group around trigger
// so it's possible to use it without needing to scan through all triggers again
customRegex: '\\(?(@)',
getHighlight: (state: EditorState) => {
const CustomHighlightComponent = options?.HighlightComponent;
if (CustomHighlightComponent) {
return ;
}
const pluginState = getMentionPluginState(state);
const provider = pluginState.mentionProvider;
if (provider) {
const teamMentionProvider = provider as TeamMentionProvider;
if (
isTeamMentionProvider(teamMentionProvider) &&
teamMentionProvider.mentionTypeaheadHighlightEnabled()
) {
return (
TeamMentionHighlightController.registerClosed()
}
/>
);
}
}
return null;
},
getItems(
query,
state,
_intl,
{ prevActive, queryChanged },
tr,
dispatch,
) {
if (!prevActive && queryChanged && !tr.getMeta(analyticsPluginKey)) {
// Clear cache on first invoke to reduce memory leaks
memoizedToItem.clear();
(dispatch as AnalyticsDispatch)(analyticsEventKey, {
payload: {
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: ACTION_SUBJECT_ID.TYPEAHEAD_MENTION,
attributes: { inputMethod: INPUT_METHOD.KEYBOARD },
eventType: EVENT_TYPE.UI,
},
});
}
const pluginState = getMentionPluginState(state);
const mentions =
!prevActive && queryChanged ? EMPTY : pluginState.mentions || EMPTY;
const mentionContext = {
...pluginState.contextIdentifierProvider,
sessionId,
};
if (queryChanged && pluginState.mentionProvider) {
// get ready to track invite item exposure once re-fetched is just invoked
shouldTrackInviteItemExposure = true;
pluginState.mentionProvider.filter(query || '', mentionContext);
}
const mentionItems = mentions.map((mention) =>
memoizedToItem.call(mention),
);
// to show invite teammate item only if there is 2 or less mentionable user/team available
if (pluginState.mentionProvider && !!query && mentions.length <= 2) {
const {
inviteExperimentCohort,
shouldEnableInvite,
userRole,
} = pluginState.mentionProvider;
if (shouldTrackInviteItemExposure) {
// we don't want to overly fire the exposure event for each continuous key press
debouncedFireEvent(
buildTypeAheadInviteExposurePayload(
!!shouldEnableInvite,
sessionId,
pluginState.contextIdentifierProvider,
inviteExperimentCohort,
userRole,
),
);
shouldTrackInviteItemExposure = false;
}
if (shouldEnableInvite) {
if (
inviteExperimentFirstQueryWithNoResults === '' &&
mentions.length === 0
) {
inviteExperimentFirstQueryWithNoResults = query;
}
if (
mentions.length === 0 &&
!shouldKeepInviteItem(
query,
inviteExperimentFirstQueryWithNoResults,
)
) {
return mentionItems;
}
return [
...mentionItems,
// invite item should be shown at the bottom
{
title: INVITE_ITEM_DESCRIPTION.id,
render: ({ isSelected, onClick, onHover }) => (
{
fireEvent(
buildTypeAheadInviteItemViewedPayload(
sessionId,
pluginState.contextIdentifierProvider,
userRole,
),
);
}}
onMouseEnter={onHover}
onSelection={onClick}
userRole={userRole}
/>
),
mention: INVITE_ITEM_DESCRIPTION,
},
];
}
}
return mentionItems;
},
selectItem(state, item, insert, { mode }) {
const sanitizePrivateContent =
options && options.sanitizePrivateContent;
const mentionInsertDisplayName = options && options.insertDisplayName;
const { schema } = state;
const pluginState = getMentionPluginState(state);
const { mentionProvider } = pluginState;
const { id, name, nickname, accessLevel, userType } = item.mention;
const trimmedNickname =
nickname && nickname.startsWith('@') ? nickname.slice(1) : nickname;
const renderName =
mentionInsertDisplayName || !trimmedNickname
? name
: trimmedNickname;
const typeAheadPluginState = typeAheadPluginKey.getState(
state,
) as TypeAheadPluginState;
const mentionContext = {
...pluginState.contextIdentifierProvider,
sessionId,
};
if (mentionProvider && !isInviteItem(item.mention)) {
mentionProvider.recordMentionSelection(
item.mention,
mentionContext,
);
}
const pickerElapsedTime = typeAheadPluginState.queryStarted
? Date.now() - typeAheadPluginState.queryStarted
: 0;
if (
mentionProvider &&
mentionProvider.shouldEnableInvite &&
isInviteItem(item.mention)
) {
// Don't fire event and the callback with selection by space press
if (mode !== 'space') {
fireEvent(
buildTypeAheadInviteItemClickedPayload(
pickerElapsedTime,
typeAheadPluginState.upKeyCount,
typeAheadPluginState.downKeyCount,
sessionId,
mode,
typeAheadPluginState.query || '',
pluginState.contextIdentifierProvider,
mentionProvider.userRole,
),
);
if (mentionProvider.onInviteItemClick) {
mentionProvider.onInviteItemClick('mention');
}
// reset the query cache upon selection
inviteExperimentFirstQueryWithNoResults = '';
}
return state.tr;
}
fireEvent(
buildTypeAheadInsertedPayload(
pickerElapsedTime,
typeAheadPluginState.upKeyCount,
typeAheadPluginState.downKeyCount,
sessionId,
mode,
item.mention,
pluginState.mentions,
typeAheadPluginState.query || '',
pluginState.contextIdentifierProvider,
),
);
sessionId = uuid();
if (mentionProvider && isTeamType(userType)) {
TeamMentionHighlightController.registerTeamMention();
return insert(
buildNodesForTeamMention(
schema,
item.mention,
mentionProvider,
sanitizePrivateContent,
),
);
}
// Don't insert into document if document data is sanitized.
const text = sanitizePrivateContent ? '' : `@${renderName}`;
if (
sanitizePrivateContent &&
isResolvingMentionProvider(mentionProvider)
) {
// Cache (locally) for later rendering
mentionProvider.cacheMentionName(id, renderName);
}
return insert(
schema.nodes.mention.createChecked({
text,
id,
accessLevel,
userType: userType === 'DEFAULT' ? null : userType,
}),
);
},
dismiss(state) {
const pluginState = getMentionPluginState(state);
const { mentionProvider } = pluginState;
if (mentionProvider && mentionProvider.shouldEnableInvite) {
// reset the query cache upon dismiss
inviteExperimentFirstQueryWithNoResults = '';
}
const typeAheadPluginState = typeAheadPluginKey.getState(
state,
) as TypeAheadPluginState;
const pickerElapsedTime = typeAheadPluginState.queryStarted
? Date.now() - typeAheadPluginState.queryStarted
: 0;
fireEvent(
buildTypeAheadCancelPayload(
pickerElapsedTime,
typeAheadPluginState.upKeyCount,
typeAheadPluginState.downKeyCount,
sessionId,
typeAheadPluginState.query || '',
),
);
sessionId = uuid();
},
},
},
};
};
export default mentionsPlugin;
/**
* Actions
*/
export const ACTIONS = {
SET_PROVIDER: 'SET_PROVIDER',
SET_RESULTS: 'SET_RESULTS',
SET_CONTEXT: 'SET_CONTEXT',
};
export const setProvider = (provider: MentionProvider | undefined): Command => (
state,
dispatch,
) => {
if (dispatch) {
dispatch(
state.tr.setMeta(mentionPluginKey, {
action: ACTIONS.SET_PROVIDER,
params: { provider },
}),
);
}
return true;
};
export const setResults = (results: MentionDescription[]): Command => (
state,
dispatch,
) => {
if (dispatch) {
dispatch(
state.tr.setMeta(mentionPluginKey, {
action: ACTIONS.SET_RESULTS,
params: { results },
}),
);
}
return true;
};
export const setContext = (
context: ContextIdentifierProvider | undefined,
): Command => (state, dispatch) => {
if (dispatch) {
dispatch(
state.tr.setMeta(mentionPluginKey, {
action: ACTIONS.SET_CONTEXT,
params: { context },
}),
);
}
return true;
};
/**
*
* ProseMirror Plugin
*
*/
export const mentionPluginKey = new PluginKey(
'mentionPlugin',
);
export function getMentionPluginState(state: EditorState) {
return mentionPluginKey.getState(state) as MentionPluginState;
}
function mentionPluginFactory(
dispatch: Dispatch,
providerFactory: ProviderFactory,
portalProviderAPI: PortalProviderAPI,
eventDispatcher: EventDispatcher,
fireEvent: (payload: any) => void,
options?: MentionPluginOptions,
) {
let mentionProvider: MentionProvider;
const sendAnalytics = (
event: string,
actionSubject: string,
action: string,
attributes?: {
[key: string]: any;
},
): void => {
if (event === SLI_EVENT_TYPE || event === SMART_EVENT_TYPE) {
fireEvent(buildSliPayload(actionSubject, action, attributes));
}
};
return new Plugin({
key: mentionPluginKey,
state: {
init() {
return {};
},
apply(tr, pluginState) {
const { action, params } = tr.getMeta(mentionPluginKey) || {
action: null,
params: null,
};
let newPluginState = pluginState;
switch (action) {
case ACTIONS.SET_PROVIDER:
newPluginState = {
...pluginState,
mentionProvider: params.provider,
};
dispatch(mentionPluginKey, newPluginState);
return newPluginState;
case ACTIONS.SET_RESULTS:
newPluginState = {
...pluginState,
mentions: params.results,
};
dispatch(mentionPluginKey, newPluginState);
return newPluginState;
case ACTIONS.SET_CONTEXT:
newPluginState = {
...pluginState,
contextIdentifierProvider: params.context,
};
dispatch(mentionPluginKey, newPluginState);
return newPluginState;
}
return newPluginState;
},
} as StateField,
props: {
nodeViews: {
mention: mentionNodeView(
portalProviderAPI,
eventDispatcher,
providerFactory,
options,
),
},
},
view(editorView) {
const providerHandler = (
name: string,
providerPromise?: Promise,
) => {
switch (name) {
case 'mentionProvider':
if (!providerPromise) {
return setProvider(undefined)(
editorView.state,
editorView.dispatch,
);
}
(providerPromise as Promise)
.then((provider) => {
if (mentionProvider) {
mentionProvider.unsubscribe('mentionPlugin');
}
mentionProvider = provider;
setProvider(provider)(editorView.state, editorView.dispatch);
provider.subscribe(
'mentionPlugin',
(mentions, query, stats) => {
setResults(mentions)(editorView.state, editorView.dispatch);
let duration: number = 0;
let userOrTeamIds: string[] | null = null;
let teams: TeamInfoAttrAnalytics[] | null = null;
if (!isTeamStats(stats)) {
// is from primary mention endpoint which could be just user mentions or user/team mentions
duration = stats && stats.duration;
teams = null;
userOrTeamIds = mentions.map((mention) => mention.id);
} else {
// is from dedicated team-only mention endpoint
duration = stats && stats.teamMentionDuration;
userOrTeamIds = null;
teams = mentions
.map((mention) =>
isTeamType(mention.userType)
? {
teamId: mention.id,
includesYou: mention.context!.includesYou,
memberCount: mention.context!.memberCount,
}
: null,
)
.filter((m) => !!m) as TeamInfoAttrAnalytics[];
}
const payload = buildTypeAheadRenderedPayload(
duration,
userOrTeamIds,
query || '',
teams,
);
fireEvent(payload);
},
undefined,
undefined,
undefined,
sendAnalytics,
);
})
.catch(() =>
setProvider(undefined)(editorView.state, editorView.dispatch),
);
break;
case 'contextIdentifierProvider':
if (!providerPromise) {
return setContext(undefined)(
editorView.state,
editorView.dispatch,
);
}
(providerPromise as Promise).then(
(provider) => {
setContext(provider)(editorView.state, editorView.dispatch);
},
);
break;
}
return;
};
providerFactory.subscribe('mentionProvider', providerHandler);
providerFactory.subscribe('contextIdentifierProvider', providerHandler);
return {
destroy() {
if (providerFactory) {
providerFactory.unsubscribe('mentionProvider', providerHandler);
providerFactory.unsubscribe(
'contextIdentifierProvider',
providerHandler,
);
}
if (mentionProvider) {
mentionProvider.unsubscribe('mentionPlugin');
}
},
};
},
});
}
/**
* When a team mention is selected, we render a team link and list of member/user mentions
* in editor content
*/
function buildNodesForTeamMention(
schema: Schema,
selectedMention: MentionDescription,
mentionProvider: MentionProvider,
sanitizePrivateContent?: boolean,
): Fragment {
const { nodes, marks } = schema;
const { name, id: teamId, accessLevel, context } = selectedMention;
// build team link
const defaultTeamLink = `${window.location.origin}/people/team/${teamId}`;
const teamLink =
context && context.teamLink ? context.teamLink : defaultTeamLink;
const teamLinkNode = schema.text(name!, [
marks.link.create({ href: teamLink }),
]);
const openBracketText = schema.text('(');
const closeBracketText = schema.text(')');
const emptySpaceText = schema.text(' ');
const inlineNodes: Node[] = [teamLinkNode, emptySpaceText, openBracketText];
const members: TeamMember[] =
context && context.members ? context.members : [];
members.forEach((member: TeamMember, index) => {
const { name, id } = member;
const mentionName = `@${name}`;
const text = sanitizePrivateContent ? '' : mentionName;
if (sanitizePrivateContent && isResolvingMentionProvider(mentionProvider)) {
mentionProvider.cacheMentionName(id, name);
}
const userMentionNode = nodes.mention.createChecked({
text,
id: member.id,
accessLevel,
userType: 'DEFAULT',
});
inlineNodes.push(userMentionNode);
// should not add empty space after the last user mention.
if (index !== members.length - 1) {
inlineNodes.push(emptySpaceText);
}
});
inlineNodes.push(closeBracketText);
return Fragment.fromArray(inlineNodes);
}
const isTeamMentionProvider = (p: any): p is TeamMentionProvider =>
!!(
(p as TeamMentionProvider).mentionTypeaheadHighlightEnabled &&
(p as TeamMentionProvider).mentionTypeaheadCreateTeamPath
);
export const shouldKeepInviteItem = (
query: string,
firstQueryWithoutResults: string,
): boolean => {
if (!firstQueryWithoutResults) {
return true;
}
let lastIndexWithResults = firstQueryWithoutResults.length - 1;
let suffix = query.slice(lastIndexWithResults);
if (query[lastIndexWithResults - 1] === ' ') {
suffix = ' ' + suffix;
}
const depletedExtraWords = /\s[^\s]+\s/.test(suffix);
return !depletedExtraWords;
};