42083efb7e
* Add group plan selection modal for upgrades Allow users to select an existing group to upgrade before creating a new one. * crlf -> lf lint * set selection of group plan Also tiny UI fixes * Update group plan selection to include expired plans * Add includeExpiredPlans option to group fetching * force flag when fetching group plans * Update group plan eligibility check * Fix eslint error in push notification import * replace chaining (?.) w/null check * Remove comment * set initial selected group plan, and fix card rounding * format member count * Show warning for pending party invites when upgrading to paid group plan Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites * suppress error toasts for group modal, and UI tweaks for group modal suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans * Clear upgradingGroup state after group plan payment * Update emoji system to native Unicode rendering * Fix line endings in habiticaMarkdown test * fix indented code block detection for markdown-it v14 * update habitica-markdown to include v3 emoji dataset (pointed towards test branch) * size emoji in markdown * emoji autocomplete to chat, messages, tasks, and profile add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper) * try upping github-action fix * trying another github actions fix * update habitica-markdown package version (v3.0.0 -> v4.0.0) * Fix emoji autocomplete overlapping actual text position dropdown below text * update group-plans info card styles * Support Melior emoji autocomplete & more places for emoji autocomplete Include emoji autocomplete in task checklists, tags, challenge name/summary/description * position emoji autocomplete dropdown below text area * fix: replace nested ternary * Emoji autocomplete fixes Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete * Have emoji autocomplete dropdown directly below text, add to task tag * Fix emoji autocomplete starting at beginning/end initially * lint/line length * Add group plan selection modal for upgrades Allow users to select an existing group to upgrade before creating a new one. * crlf -> lf lint * set selection of group plan Also tiny UI fixes * Update group plan selection to include expired plans * Add includeExpiredPlans option to group fetching * force flag when fetching group plans * Update group plan eligibility check * Fix eslint error in push notification import * replace chaining (?.) w/null check * Remove comment * set initial selected group plan, and fix card rounding * format member count * Show warning for pending party invites when upgrading to paid group plan Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites * suppress error toasts for group modal, and UI tweaks for group modal suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans * Clear upgradingGroup state after group plan payment * Update emoji system to native Unicode rendering * Fix line endings in habiticaMarkdown test * fix indented code block detection for markdown-it v14 * update habitica-markdown to include v3 emoji dataset (pointed towards test branch) * size emoji in markdown * emoji autocomplete to chat, messages, tasks, and profile add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper) * try upping github-action fix * trying another github actions fix * update habitica-markdown package version (v3.0.0 -> v4.0.0) * Fix emoji autocomplete overlapping actual text position dropdown below text * update group-plans info card styles * Support Melior emoji autocomplete & more places for emoji autocomplete Include emoji autocomplete in task checklists, tags, challenge name/summary/description * position emoji autocomplete dropdown below text area * fix: replace nested ternary * Emoji autocomplete fixes Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete * Have emoji autocomplete dropdown directly below text, add to task tag * Fix emoji autocomplete starting at beginning/end initially * lint/line length * Revert "trying another github actions fix" This reverts commit72fc7fc20e. * Revert "try upping github-action fix" This reverts commit70e48a57aa. * fix(git): revert ci changes --------- Co-authored-by: Kalista Payne <kalista@habitica.com>
190 lines
6.4 KiB
JavaScript
190 lines
6.4 KiB
JavaScript
import escapeRegExp from 'lodash/escapeRegExp';
|
|
import habiticaMarkdown from 'habitica-markdown';
|
|
|
|
import { model as User } from '../models/user';
|
|
import logger from './logger';
|
|
|
|
const mentionRegex = /\B@[-\w]+/g;
|
|
const ignoreTokenTypes = ['code_block', 'code_inline', 'fence', 'link_open'];
|
|
|
|
/**
|
|
* Container class for valid text blocks and text blocks that should be ignored.
|
|
* Blocks have the properties `text` and `ignore`
|
|
*/
|
|
class TextBlocks {
|
|
constructor (blocks) {
|
|
this.blocks = blocks;
|
|
this.validBlocks = blocks.filter(block => !block.ignore);
|
|
this.allValidText = this.validBlocks.map(block => block.text).join('\n');
|
|
}
|
|
|
|
transformValidBlocks (transform) {
|
|
this.validBlocks.forEach(block => {
|
|
block.text = transform(block.text);
|
|
});
|
|
}
|
|
|
|
rebuild () {
|
|
return this.blocks.map(block => block.text).join('');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Since tokens have both order and can be nested until infinite depth,
|
|
* use a branching recursive algorithm to maintain order and check all tokens.
|
|
*/
|
|
function findIgnoreBlocks (tokens) {
|
|
// Links span multiple tokens, so keep local state of whether we're in a link
|
|
let inLink = false;
|
|
|
|
function recursor (ts, result) {
|
|
const [head, ...tail] = ts;
|
|
if (!head) {
|
|
return result;
|
|
}
|
|
|
|
if (!inLink && ignoreTokenTypes.includes(head.type)) {
|
|
result.push(head);
|
|
}
|
|
|
|
if (head.type.includes('link')) {
|
|
inLink = !inLink;
|
|
} else if (inLink && head.type === 'text') {
|
|
const linkBlock = result[result.length - 1];
|
|
linkBlock.textContents = (linkBlock.textContents || []).concat(head.content);
|
|
}
|
|
|
|
return recursor(tail, head.children ? recursor(head.children, result) : result);
|
|
}
|
|
|
|
return recursor(tokens, []);
|
|
}
|
|
|
|
/**
|
|
* Since there are many factors that can prefix lines with indentation in
|
|
* markdown, each line from a token's content needs to be prefixed with a
|
|
* variable whitespace matcher.
|
|
*
|
|
* See for example: https://spec.commonmark.org/0.29/#example-224
|
|
*/
|
|
function withOptionalIndentation (content) {
|
|
return content.split('\n').map(line => `\\s*${line}`).join('\n');
|
|
}
|
|
|
|
/* This is essentially a workaround around the fact that markdown-it doesn't
|
|
* provide sourcemap functionality and is the most brittle part of this code.
|
|
*
|
|
* Known errors (Not supported markdown link variants):
|
|
* - [a](<b)c>) https://spec.commonmark.org/0.29/#example-489
|
|
* - [link](\(foo\)) https://spec.commonmark.org/0.29/#example-492
|
|
* - [link](foo(and(bar))) https://spec.commonmark.org/0.29/#example-493
|
|
* - [link](foo\(and\(bar\)) https://spec.commonmark.org/0.29/#example-494
|
|
* - [link](<foo(and(bar)>) https://spec.commonmark.org/0.29/#example-495
|
|
* - [link](foo\)\:) https://spec.commonmark.org/0.29/#example-496
|
|
*/
|
|
function toSourceMapRegex (token) {
|
|
const { type, content, markup } = token;
|
|
const contentRegex = escapeRegExp(content);
|
|
let regexStr = '';
|
|
|
|
if (type === 'code_block') {
|
|
regexStr = withOptionalIndentation(contentRegex.replace(/\n$/, ''));
|
|
} else if (type === 'fence') {
|
|
regexStr = `\\s*${markup}.*\n${withOptionalIndentation(contentRegex)}\\s*${markup}`;
|
|
} else if (type === 'code_inline') {
|
|
regexStr = `${markup} ?${contentRegex} ?${markup}`;
|
|
} else if (type === 'link_open') {
|
|
const texts = token.textContents ? token.textContents.map(escapeRegExp) : [''];
|
|
regexStr = markup === 'linkify' || markup === 'autolink' ? texts[0]
|
|
: `\\[[^\\]]*${texts.join('[^\\]]*')}[^\\]]*\\]\\([^)]*\\)`;
|
|
} else {
|
|
throw new Error(`No source mapping regex defined for ignore blocks of type ${type}`);
|
|
}
|
|
|
|
return new RegExp(regexStr, 's');
|
|
}
|
|
|
|
/**
|
|
* Uses habiticaMarkdown to determine which text blocks should be ignored (links and code blocks)
|
|
* according to the specification here: https://spec.commonmark.org/0.29/
|
|
*/
|
|
function findTextBlocks (text) {
|
|
// For token description see https://markdown-it.github.io/markdown-it/#Token
|
|
// The second parameter is mandatory even if not used, see
|
|
// https://markdown-it.github.io/markdown-it/#MarkdownIt.parse
|
|
const tokens = habiticaMarkdown.parse(text, {});
|
|
const ignoreBlockRegexes = findIgnoreBlocks(tokens).map(toSourceMapRegex);
|
|
|
|
const blocks = [];
|
|
let index = 0;
|
|
ignoreBlockRegexes.forEach(regex => {
|
|
const targetText = text.substr(index);
|
|
const match = targetText.match(regex);
|
|
|
|
if (!match) {
|
|
logger.error(
|
|
new Error('Failed to match source-mapping regex to find ignore block'),
|
|
{ text, targetText, regex: String(regex) },
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (match.index) {
|
|
blocks.push({ text: targetText.substr(0, match.index), ignore: false });
|
|
}
|
|
|
|
blocks.push({ text: match[0], ignore: true });
|
|
index += match.index + match[0].length;
|
|
});
|
|
|
|
if (index < text.length) {
|
|
blocks.push({ text: text.substr(index), ignore: false });
|
|
}
|
|
|
|
return new TextBlocks(blocks);
|
|
}
|
|
|
|
function determineBaseUrl () {
|
|
// eslint-disable-next-line no-process-env
|
|
return process.env.NODE_ENV === 'production' ? 'https://habitica.com' : '';
|
|
}
|
|
|
|
/**
|
|
* Replaces `@user` mentions by `[@user](/profile/{user-id})` markup to inject
|
|
* a link towards the user's profile page.
|
|
* - Only works if there are no more that 5 user mentions
|
|
* - Skips mentions in code blocks as defined by https://spec.commonmark.org/0.29/
|
|
* - Skips mentions in links
|
|
*/
|
|
export default async function highlightMentions (text) {
|
|
const textBlocks = findTextBlocks(text);
|
|
|
|
const mentions = textBlocks.allValidText.match(mentionRegex);
|
|
let members = [];
|
|
|
|
if (mentions && mentions.length <= 5) {
|
|
const usernames = mentions.map(mention => mention.substr(1));
|
|
const usernameRegexes = usernames.map(username => new RegExp(`^${escapeRegExp(username)}$`, 'i'));
|
|
members = await User
|
|
.find({
|
|
$or: usernameRegexes.map(regex => ({ 'auth.local.username': regex })),
|
|
'flags.verifiedUsername': true,
|
|
})
|
|
.select(['auth.local.username', '_id', 'preferences.pushNotifications', 'pushDevices', 'party', 'guilds'])
|
|
.lean()
|
|
.exec();
|
|
const baseUrl = determineBaseUrl();
|
|
members.forEach(member => {
|
|
const { username } = member.auth.local;
|
|
const regex = new RegExp(`@${escapeRegExp(username)}(?![\\-\\w])`, 'gi');
|
|
|
|
textBlocks.transformValidBlocks(blockText => blockText.replace(regex, match => {
|
|
const mentionedUsername = match.substr(1);
|
|
return `[@${mentionedUsername}](${baseUrl}/profile/${member._id})`;
|
|
}));
|
|
});
|
|
}
|
|
|
|
return [textBlocks.rebuild(), mentions, members];
|
|
}
|