diff --git a/lib/shared/account-utils.js b/lib/shared/account-utils.js index 176769d3f..ab753f9d0 100644 --- a/lib/shared/account-utils.js +++ b/lib/shared/account-utils.js @@ -1,64 +1,68 @@ // @flow import type { CurrentUserInfo } from '../types/user-types.js'; import { isValidEthereumAddress } from '../utils/siwe-utils.js'; const usernameMaxLength = 191; const usernameMinLength = 1; const secondCharRange = `{${usernameMinLength - 1},${usernameMaxLength - 1}}`; const validUsernameRegexString = `^[a-zA-Z0-9][a-zA-Z0-9-_]${secondCharRange}$`; const validUsernameRegex: RegExp = new RegExp(validUsernameRegexString); // usernames used to be less restrictive (eg single chars were allowed) // use oldValidUsername when dealing with existing accounts const oldValidUsernameRegexString = '[a-zA-Z0-9-_]+'; const oldValidUsernameRegex: RegExp = new RegExp( `^${oldValidUsernameRegexString}$`, ); +// when bolding @-mentions, we want to match both valid usernames and also +// resolved ENS names that have a . character in them (eg. "foo.eth") +const markdownUserMentionRegexString = '[a-zA-Z0-9-_.]+'; + const validEmailRegex: RegExp = new RegExp( /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+/.source + /@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.source + /(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.source, ); const validHexColorRegex: RegExp = /^[a-fA-F0-9]{6}$/; function accountHasPassword(currentUserInfo: ?CurrentUserInfo): boolean { return currentUserInfo?.username ? !isValidEthereumAddress(currentUserInfo.username) : false; } function userIdentifiedByETHAddress( userInfo: ?{ +username?: ?string, ... }, ): boolean { return userInfo?.username ? isValidEthereumAddress(userInfo?.username) : false; } function getETHAddressForUserInfo( userInfo: ?{ +username?: ?string, ... }, ): ?string { if (!userInfo) { return null; } const { username } = userInfo; const ethAddress = username && userIdentifiedByETHAddress(userInfo) ? username : null; return ethAddress; } export { usernameMaxLength, - oldValidUsernameRegexString, validUsernameRegex, oldValidUsernameRegex, + markdownUserMentionRegexString, validEmailRegex, validHexColorRegex, accountHasPassword, userIdentifiedByETHAddress, getETHAddressForUserInfo, }; diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js index 6f5edf5a7..1eb2c233b 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,209 +1,209 @@ // @flow import * as React from 'react'; -import { oldValidUsernameRegexString } from './account-utils.js'; +import { markdownUserMentionRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { threadOtherMembers } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidates, RelativeMemberInfo, ThreadInfo, ResolvedThreadInfo, } from '../types/thread-types.js'; import { idSchemaRegex, chatNameMaxLength } from '../utils/validation-utils.js'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, +query: string, }; export type Selection = { +start: number, +end: number, }; type MentionTypeaheadUserSuggestionItem = { +type: 'user', +userInfo: RelativeMemberInfo, }; type MentionTypeaheadChatSuggestionItem = { +type: 'chat', +threadInfo: ResolvedThreadInfo, }; export type MentionTypeaheadSuggestionItem = | MentionTypeaheadUserSuggestionItem | MentionTypeaheadChatSuggestionItem; export type TypeaheadTooltipActionItem = { +key: string, +execute: () => mixed, +actionButtonContent: SuggestionItemType, }; // The simple-markdown package already breaks words out for us, and we are // supposed to only match when the first word of the input matches const markdownUserMentionRegex: RegExp = new RegExp( - `^(@(${oldValidUsernameRegexString}))\\b`, + `^(@(${markdownUserMentionRegexString}))\\b`, ); function isUserMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const userMentionsExtractionRegex = new RegExp( - `\\B(@(${oldValidUsernameRegexString}))\\b`, + `\\B(@(${markdownUserMentionRegexString}))\\b`, 'g', ); const chatMentionRegexString = `([^\\\\]|^)(@\\[\\[(${idSchemaRegex}):((.{0,${chatNameMaxLength}}?)(?!\\\\).|^)\\]\\])`; const chatMentionRegex: RegExp = new RegExp(`^${chatMentionRegexString}`); const globalChatMentionRegex: RegExp = new RegExp(chatMentionRegexString, 'g'); function encodeChatMentionText(text: string): string { return text.replace(/]/g, '\\]'); } function decodeChatMentionText(text: string): string { return text.replace(/\\]/g, ']'); } function getRawChatMention(threadInfo: ResolvedThreadInfo): string { return `@[[${threadInfo.id}:${encodeChatMentionText(threadInfo.uiName)}]]`; } function renderChatMentionsWithAltText(text: string): string { return text.replace( globalChatMentionRegex, (...match) => `${match[1]}@${decodeChatMentionText(match[4])}`, ); } function extractUserMentionsFromText(text: string): string[] { const iterator = text.matchAll(userMentionsExtractionRegex); return [...iterator].map(matches => matches[2]); } function getTypeaheadRegexMatches( text: string, selection: Selection, regex: RegExp, ): null | RegExp$matchResult { if ( selection.start === selection.end && (selection.start === text.length || /\s/.test(text[selection.end])) ) { return text.slice(0, selection.start).match(regex); } return null; } const useENSNamesOptions = { allAtOnce: true }; function useMentionTypeaheadUserSuggestions( threadMembers: $ReadOnlyArray, viewerID: ?string, typeaheadMatchedStrings: ?TypeaheadMatchedStrings, ): $ReadOnlyArray { const userSearchIndex = useUserSearchIndex(threadMembers); const resolvedThredMembers = useENSNames(threadMembers, useENSNamesOptions); const usernamePrefix: ?string = typeaheadMatchedStrings?.query; return React.useMemo(() => { // If typeaheadMatchedStrings is undefined, we want to return no results if (usernamePrefix === undefined || usernamePrefix === null) { return []; } const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(resolvedThredMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare( stringForUserExplicit(userB), ), ) .map(userInfo => ({ type: 'user', userInfo })); }, [userSearchIndex, resolvedThredMembers, usernamePrefix, viewerID]); } function getMentionTypeaheadChatSuggestions( chatSearchIndex: SentencePrefixSearchIndex, chatMentionCandidates: ChatMentionCandidates, chatNamePrefix: string, ): $ReadOnlyArray { const result = []; const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix); for (const threadID of threadIDs) { if (!chatMentionCandidates[threadID]) { continue; } result.push({ type: 'chat', threadInfo: chatMentionCandidates[threadID], }); } return result; } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, query: string, suggestionText: string, ): { newText: string, newSelectionStart: number, } { const totalMatchLength = textBeforeAtSymbol.length + query.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = textBeforeAtSymbol + suggestionText + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; return { newText, newSelectionStart }; } function useUserMentionsCandidates( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): $ReadOnlyArray { return React.useMemo(() => { if (threadInfo.type !== threadTypes.SIDEBAR) { return threadInfo.members; } if (parentThreadInfo) { return parentThreadInfo.members; } // This scenario should not occur unless the user logs out while looking at // a sidebar. In that scenario, the Redux store may be cleared before // ReactNav finishes transitioning away from the previous screen return []; }, [threadInfo, parentThreadInfo]); } export { markdownUserMentionRegex, isUserMentioned, extractUserMentionsFromText, useMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, useUserMentionsCandidates, chatMentionRegex, encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, };