diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js
index 3efc61de6..8dcb74e35 100644
--- a/lib/components/chat-mention-provider.react.js
+++ b/lib/components/chat-mention-provider.react.js
@@ -1,245 +1,263 @@
// @flow
import * as React from 'react';
import genesis from '../facts/genesis.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import SentencePrefixSearchIndex from '../shared/sentence-prefix-search-index.js';
import { threadTypes } from '../types/thread-types-enum.js';
import type {
- ChatMentionCandidates,
ChatMentionCandidatesObj,
+ ChatMentionCandidate,
ResolvedThreadInfo,
ThreadInfo,
} from '../types/thread-types.js';
import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js';
import { useSelector } from '../utils/redux-utils.js';
type Props = {
+children: React.Node,
};
export type ChatMentionContextType = {
+getChatMentionSearchIndex: (
threadInfo: ThreadInfo,
) => SentencePrefixSearchIndex,
+communityThreadIDForGenesisThreads: { +[id: string]: string },
+chatMentionCandidatesObj: ChatMentionCandidatesObj,
};
const emptySearchIndex = new SentencePrefixSearchIndex();
const ChatMentionContext: React.Context =
React.createContext({
getChatMentionSearchIndex: () => emptySearchIndex,
communityThreadIDForGenesisThreads: {},
chatMentionCandidatesObj: {},
});
function ChatMentionContextProvider(props: Props): React.Node {
const { children } = props;
const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } =
useChatMentionCandidatesObjAndUtils();
const searchIndices = useChatMentionSearchIndex(chatMentionCandidatesObj);
const getChatMentionSearchIndex = React.useCallback(
(threadInfo: ThreadInfo) => {
if (threadInfo.community === genesis.id) {
return searchIndices[communityThreadIDForGenesisThreads[threadInfo.id]];
}
return searchIndices[threadInfo.community ?? threadInfo.id];
},
[communityThreadIDForGenesisThreads, searchIndices],
);
const value = React.useMemo(
() => ({
getChatMentionSearchIndex,
communityThreadIDForGenesisThreads,
chatMentionCandidatesObj,
}),
[
getChatMentionSearchIndex,
communityThreadIDForGenesisThreads,
chatMentionCandidatesObj,
],
);
return (
{children}
);
}
function getChatMentionCandidates(
threadInfos: { +[id: string]: ThreadInfo },
resolvedThreadInfos: { +[id: string]: ResolvedThreadInfo },
): {
chatMentionCandidatesObj: ChatMentionCandidatesObj,
communityThreadIDForGenesisThreads: { +[id: string]: string },
} {
const result: {
[string]: {
- [string]: ResolvedThreadInfo,
+ [string]: ChatMentionCandidate,
},
} = {};
const visitedGenesisThreads = new Set();
const communityThreadIDForGenesisThreads: { [string]: string } = {};
for (const currentThreadID in resolvedThreadInfos) {
const currentResolvedThreadInfo = resolvedThreadInfos[currentThreadID];
const { community: currentThreadCommunity } = currentResolvedThreadInfo;
if (!currentThreadCommunity) {
if (!result[currentThreadID]) {
result[currentThreadID] = {
- [currentThreadID]: currentResolvedThreadInfo,
+ [currentThreadID]: {
+ threadInfo: currentResolvedThreadInfo,
+ rawChatName: threadInfos[currentThreadID].uiName,
+ },
};
}
continue;
}
if (!result[currentThreadCommunity]) {
result[currentThreadCommunity] = {};
- result[currentThreadCommunity][currentThreadCommunity] =
- resolvedThreadInfos[currentThreadCommunity];
+ result[currentThreadCommunity][currentThreadCommunity] = {
+ threadInfo: resolvedThreadInfos[currentThreadCommunity],
+ rawChatName: threadInfos[currentThreadCommunity].uiName,
+ };
}
// Handle GENESIS community case: mentioning inside GENESIS should only
// show chats and threads inside the top level that is below GENESIS.
if (
resolvedThreadInfos[currentThreadCommunity].type === threadTypes.GENESIS
) {
if (visitedGenesisThreads.has(currentThreadID)) {
continue;
}
const threadTraversePath = [currentResolvedThreadInfo];
visitedGenesisThreads.add(currentThreadID);
let currentlySelectedThreadID = currentResolvedThreadInfo.parentThreadID;
while (currentlySelectedThreadID) {
const currentlySelectedThreadInfo =
resolvedThreadInfos[currentlySelectedThreadID];
if (
visitedGenesisThreads.has(currentlySelectedThreadID) ||
!currentlySelectedThreadInfo ||
currentlySelectedThreadInfo.type === threadTypes.GENESIS
) {
break;
}
threadTraversePath.push(currentlySelectedThreadInfo);
visitedGenesisThreads.add(currentlySelectedThreadID);
currentlySelectedThreadID = currentlySelectedThreadInfo.parentThreadID;
}
const lastThreadInTraversePath =
threadTraversePath[threadTraversePath.length - 1];
let lastThreadInTraversePathParentID;
if (lastThreadInTraversePath.parentThreadID) {
lastThreadInTraversePathParentID = resolvedThreadInfos[
lastThreadInTraversePath.parentThreadID
]
? lastThreadInTraversePath.parentThreadID
: lastThreadInTraversePath.id;
} else {
lastThreadInTraversePathParentID = lastThreadInTraversePath.id;
}
if (
resolvedThreadInfos[lastThreadInTraversePathParentID].type ===
threadTypes.GENESIS
) {
if (!result[lastThreadInTraversePath.id]) {
result[lastThreadInTraversePath.id] = {};
}
for (const threadInfo of threadTraversePath) {
- result[lastThreadInTraversePath.id][threadInfo.id] = threadInfo;
+ result[lastThreadInTraversePath.id][threadInfo.id] = {
+ threadInfo,
+ rawChatName: threadInfos[threadInfo.id].uiName,
+ };
communityThreadIDForGenesisThreads[threadInfo.id] =
lastThreadInTraversePath.id;
}
if (
lastThreadInTraversePath.type !== threadTypes.PERSONAL &&
lastThreadInTraversePath.type !== threadTypes.PRIVATE
) {
- result[genesis.id][lastThreadInTraversePath.id] =
- lastThreadInTraversePath;
+ result[genesis.id][lastThreadInTraversePath.id] = {
+ threadInfo: lastThreadInTraversePath,
+ rawChatName: threadInfos[lastThreadInTraversePath.id].uiName,
+ };
}
} else {
if (
!communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]
) {
result[lastThreadInTraversePathParentID] = {};
communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] =
lastThreadInTraversePathParentID;
}
const lastThreadInTraversePathParentCommunityThreadID =
communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID];
for (const threadInfo of threadTraversePath) {
result[lastThreadInTraversePathParentCommunityThreadID][
threadInfo.id
- ] = threadInfo;
+ ] = {
+ threadInfo,
+ rawChatName: threadInfos[threadInfo.id].uiName,
+ };
communityThreadIDForGenesisThreads[threadInfo.id] =
lastThreadInTraversePathParentCommunityThreadID;
}
}
continue;
}
- result[currentThreadCommunity][currentThreadID] = currentResolvedThreadInfo;
+ result[currentThreadCommunity][currentThreadID] = {
+ threadInfo: currentResolvedThreadInfo,
+ rawChatName: threadInfos[currentThreadID].uiName,
+ };
}
return {
chatMentionCandidatesObj: result,
communityThreadIDForGenesisThreads,
};
}
// Without allAtOnce, useChatMentionCandidatesObjAndUtils is very expensive.
// useResolvedThreadInfosObj would trigger its recalculation for each ENS name
// as it streams in, but we would prefer to trigger its recaculation just once
// for every update of the underlying Redux data.
const useResolvedThreadInfosObjOptions = { allAtOnce: true };
function useChatMentionCandidatesObjAndUtils(): {
chatMentionCandidatesObj: ChatMentionCandidatesObj,
- resolvedThreadInfos: ChatMentionCandidates,
+ resolvedThreadInfos: { +[id: string]: ResolvedThreadInfo },
communityThreadIDForGenesisThreads: { +[id: string]: string },
} {
const threadInfos = useSelector(threadInfoSelector);
const resolvedThreadInfos = useResolvedThreadInfosObj(
threadInfos,
useResolvedThreadInfosObjOptions,
);
const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } =
React.useMemo(
() => getChatMentionCandidates(threadInfos, resolvedThreadInfos),
[threadInfos, resolvedThreadInfos],
);
return {
chatMentionCandidatesObj,
resolvedThreadInfos,
communityThreadIDForGenesisThreads,
};
}
function useChatMentionSearchIndex(
chatMentionCandidatesObj: ChatMentionCandidatesObj,
): {
+[id: string]: SentencePrefixSearchIndex,
} {
return React.useMemo(() => {
const result: { [string]: SentencePrefixSearchIndex } = {};
for (const communityThreadID in chatMentionCandidatesObj) {
const searchIndex = new SentencePrefixSearchIndex();
const searchIndexEntries = [];
for (const threadID in chatMentionCandidatesObj[communityThreadID]) {
searchIndexEntries.push({
id: threadID,
- uiName: chatMentionCandidatesObj[communityThreadID][threadID].uiName,
+ uiName:
+ chatMentionCandidatesObj[communityThreadID][threadID].threadInfo
+ .uiName,
});
}
// Sort the keys so that the order of the search result is consistent
searchIndexEntries.sort(({ uiName: uiNameA }, { uiName: uiNameB }) =>
uiNameA.localeCompare(uiNameB),
);
for (const { id, uiName } of searchIndexEntries) {
searchIndex.addEntry(id, uiName);
}
result[communityThreadID] = searchIndex;
}
return result;
}, [chatMentionCandidatesObj]);
}
export { ChatMentionContextProvider, ChatMentionContext };
diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js
index 14b713b01..226b5d16b 100644
--- a/lib/shared/markdown.js
+++ b/lib/shared/markdown.js
@@ -1,407 +1,408 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
markdownUserMentionRegex,
decodeChatMentionText,
} from './mention-utils.js';
import { useENSNames } from '../hooks/ens-cache.js';
import type {
ChatMentionCandidates,
RelativeMemberInfo,
ResolvedThreadInfo,
} from '../types/thread-types.js';
// simple-markdown types
export type State = {
key?: string | number | void,
inline?: ?boolean,
[string]: any,
};
export type Parser = (source: string, state?: ?State) => Array;
export type Capture =
| (Array & { +index: number, ... })
| (Array & { +index?: number, ... });
export type SingleASTNode = {
type: string,
[string]: any,
};
export type ASTNode = SingleASTNode | Array;
type UnTypedASTNode = {
[string]: any,
...
};
type MatchFunction = { regex?: RegExp, ... } & ((
source: string,
state: State,
prevCapture: string,
) => ?Capture);
export type ReactElement = React$Element;
type ReactElements = React$Node;
export type Output = (node: ASTNode, state?: ?State) => Result;
type ArrayNodeOutput = (
node: Array,
nestedOutput: Output,
state: State,
) => Result;
type ArrayRule = {
+react?: ArrayNodeOutput,
+html?: ArrayNodeOutput,
+[string]: ArrayNodeOutput,
};
type ParseFunction = (
capture: Capture,
nestedParse: Parser,
state: State,
) => UnTypedASTNode | ASTNode;
type ParserRule = {
+order: number,
+match: MatchFunction,
+quality?: (capture: Capture, state: State, prevCapture: string) => number,
+parse: ParseFunction,
...
};
export type ParserRules = {
+Array?: ArrayRule,
+[type: string]: ParserRule,
...
};
const paragraphRegex: RegExp = /^((?:[^\n]*)(?:\n|$))/;
const paragraphStripTrailingNewlineRegex: RegExp = /^([^\n]*)(?:\n|$)/;
const headingRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/;
const headingStripFollowingNewlineRegex: RegExp =
/^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/;
const fenceRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/;
const fenceStripTrailingNewlineRegex: RegExp =
/^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/;
const codeBlockRegex: RegExp = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/;
const codeBlockStripTrailingNewlineRegex: RegExp =
/^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/;
const urlRegex: RegExp = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i;
export type JSONCapture = Array & {
+json: Object,
+index?: void,
...
};
function jsonMatch(source: string): ?JSONCapture {
if (!source.startsWith('{')) {
return null;
}
let jsonString = '';
let counter = 0;
for (let i = 0; i < source.length; i++) {
const char = source[i];
jsonString += char;
if (char === '{') {
counter++;
} else if (char === '}') {
counter--;
}
if (counter === 0) {
break;
}
}
if (counter !== 0) {
return null;
}
let json;
try {
json = JSON.parse(jsonString);
} catch {
return null;
}
if (!json || typeof json !== 'object') {
return null;
}
return { ...([jsonString]: any), json };
}
function jsonPrint(capture: JSONCapture): string {
return JSON.stringify(capture.json, null, ' ');
}
const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/;
const listItemRegex =
/^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm;
const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /;
const listLookBehindRegex = /(?:^|\n)( *)$/;
function matchList(source: string, state: State): RegExp$matchResult | null {
if (state.inline) {
return null;
}
const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : '';
const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr);
if (!isStartOfLineCapture) {
return null;
}
const fullSource = isStartOfLineCapture[1] + source;
return listRegex.exec(fullSource);
}
// We've defined our own parse function for lists because simple-markdown
// handles newlines differently. Outside of that our implementation is fairly
// similar. For more details about list parsing works, take a look at the
// comments in the simple-markdown package
function parseList(
capture: Capture,
parse: Parser,
state: State,
): UnTypedASTNode {
const bullet = capture[2];
const ordered = bullet.length > 1;
const start = ordered ? Number(bullet) : undefined;
const items = capture[0].match(listItemRegex);
let itemContent = null;
if (items) {
itemContent = items.map((item: string) => {
const prefixCapture = listItemPrefixRegex.exec(item);
const space = prefixCapture ? prefixCapture[0].length : 0;
const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm');
const content: string = item
.replace(spaceRegex, '')
.replace(listItemPrefixRegex, '');
// We're handling this different than simple-markdown -
// each item is a paragraph
return parse(content, state);
});
}
return {
ordered: ordered,
start: start,
items: itemContent,
};
}
const useENSNamesOptions = { allAtOnce: true };
function useMemberMapForUserMentions(
members: $ReadOnlyArray,
): $ReadOnlyMap {
const membersWithRole = React.useMemo(
() => members.filter(member => member.role),
[members],
);
const resolvedMembers = useENSNames(membersWithRole, useENSNamesOptions);
const resolvedMembersMap: $ReadOnlyMap =
React.useMemo(
() => new Map(resolvedMembers.map(member => [member.id, member])),
[resolvedMembers],
);
const membersMap = React.useMemo(() => {
const map = new Map();
for (const member of membersWithRole) {
const rawUsername = member.username;
if (rawUsername) {
map.set(rawUsername.toLowerCase(), member.id);
}
const resolvedMember = resolvedMembersMap.get(member.id);
const resolvedUsername = resolvedMember?.username;
if (resolvedUsername && resolvedUsername !== rawUsername) {
map.set(resolvedUsername.toLowerCase(), member.id);
}
}
return map;
}, [membersWithRole, resolvedMembersMap]);
return membersMap;
}
function matchUserMentions(
membersMap: $ReadOnlyMap,
): MatchFunction {
const match = (source: string, state: State) => {
if (!state.inline) {
return null;
}
const result = markdownUserMentionRegex.exec(source);
if (!result) {
return null;
}
const username = result[2];
invariant(username, 'markdownMentionRegex should match two capture groups');
if (!membersMap.has(username.toLowerCase())) {
return null;
}
return result;
};
match.regex = markdownUserMentionRegex;
return match;
}
type ParsedUserMention = {
+content: string,
+userID: string,
};
function parseUserMentions(
membersMap: $ReadOnlyMap,
capture: Capture,
): ParsedUserMention {
const memberUsername = capture[2];
const memberID = membersMap.get(memberUsername.toLowerCase());
invariant(memberID, 'memberID should be set');
return {
content: capture[0],
userID: memberID,
};
}
function parseChatMention(
chatMentionCandidates: ChatMentionCandidates,
capture: Capture,
): {
threadInfo: ?ResolvedThreadInfo,
content: string,
hasAccessToChat: boolean,
} {
- const threadInfo = chatMentionCandidates[capture[3]];
+ const chatMentionCandidate = chatMentionCandidates[capture[3]];
+ const threadInfo = chatMentionCandidate?.threadInfo;
const threadName = threadInfo?.uiName ?? decodeChatMentionText(capture[4]);
const content = `${capture[1]}@${threadName}`;
return {
threadInfo,
content,
hasAccessToChat: !!threadInfo,
};
}
const blockQuoteRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/;
const blockQuoteStripFollowingNewlineRegex: RegExp =
/^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/;
const maxNestedQuotations = 5;
// Custom match and parse functions implementation for block quotes
// to allow us to specify quotes parsing depth
// to avoid too many recursive calls and e.g. app crash
function matchBlockQuote(quoteRegex: RegExp): MatchFunction {
return (source: string, state: State) => {
if (
state.inline ||
(state?.quotationsDepth && state.quotationsDepth >= maxNestedQuotations)
) {
return null;
}
return quoteRegex.exec(source);
};
}
function parseBlockQuote(
capture: Capture,
parse: Parser,
state: State,
): UnTypedASTNode {
const content = capture[1].replace(/^ *> ?/gm, '');
const currentQuotationsDepth = state?.quotationsDepth ?? 0;
return {
content: parse(content, {
...state,
quotationsDepth: currentQuotationsDepth + 1,
}),
};
}
const spoilerRegex: RegExp = /^\|\|([^\n]+?)\|\|/g;
const replaceSpoilerRegex: RegExp = /\|\|(.+?)\|\|/g;
const spoilerReplacement: string = '⬛⬛⬛';
const stripSpoilersFromNotifications = (text: string): string =>
text.replace(replaceSpoilerRegex, spoilerReplacement);
function stripSpoilersFromMarkdownAST(ast: SingleASTNode[]): SingleASTNode[] {
// Either takes top-level AST, or array of nodes under an items node (list)
return ast.map(replaceSpoilersFromMarkdownAST);
}
function replaceSpoilersFromMarkdownAST(node: SingleASTNode): SingleASTNode {
const { content, items, type } = node;
if (typeof content === 'string') {
// Base case (leaf node)
return node;
} else if (type === 'spoiler') {
// The actual point of this function: replacing the spoilers
return {
type: 'text',
content: spoilerReplacement,
};
} else if (content) {
// Common case... most nodes nest children with content
// If content isn't a string, it should be an array
return {
...node,
content: stripSpoilersFromMarkdownAST(content),
};
} else if (items) {
// Special case for lists, which has a nested array of arrays within items
return {
...node,
items: items.map(stripSpoilersFromMarkdownAST),
};
}
throw new Error(
`unexpected Markdown node of type ${type} with no content or items`,
);
}
const ensRegex: RegExp = /^.{3,}\.eth$/;
export {
paragraphRegex,
paragraphStripTrailingNewlineRegex,
urlRegex,
blockQuoteRegex,
blockQuoteStripFollowingNewlineRegex,
headingRegex,
headingStripFollowingNewlineRegex,
codeBlockRegex,
codeBlockStripTrailingNewlineRegex,
fenceRegex,
fenceStripTrailingNewlineRegex,
spoilerRegex,
matchBlockQuote,
parseBlockQuote,
jsonMatch,
jsonPrint,
matchList,
parseList,
useMemberMapForUserMentions,
matchUserMentions,
parseUserMentions,
stripSpoilersFromNotifications,
stripSpoilersFromMarkdownAST,
parseChatMention,
ensRegex,
};
diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js
index 80922cb6b..aa6318252 100644
--- a/lib/shared/mention-utils.js
+++ b/lib/shared/mention-utils.js
@@ -1,218 +1,218 @@
// @flow
import * as React from 'react';
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(
`^(@(${markdownUserMentionRegexString}))\\b`,
);
function isUserMentioned(username: string, text: string): boolean {
return new RegExp(`\\B@${username}\\b`, 'i').test(text);
}
const userMentionsExtractionRegex = new RegExp(
`\\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 resolvedThreadMembers = 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(resolvedThreadMembers, 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, resolvedThreadMembers, usernamePrefix, viewerID]);
}
function useMentionTypeaheadChatSuggestions(
chatSearchIndex: SentencePrefixSearchIndex,
chatMentionCandidates: ChatMentionCandidates,
typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
): $ReadOnlyArray {
const chatNamePrefix: ?string = typeaheadMatchedStrings?.query;
return React.useMemo(() => {
const result = [];
if (chatNamePrefix === undefined || chatNamePrefix === null) {
return result;
}
const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix);
for (const threadID of threadIDs) {
if (!chatMentionCandidates[threadID]) {
continue;
}
result.push({
type: 'chat',
- threadInfo: chatMentionCandidates[threadID],
+ threadInfo: chatMentionCandidates[threadID].threadInfo,
});
}
return result;
}, [chatSearchIndex, chatMentionCandidates, chatNamePrefix]);
}
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,
useMentionTypeaheadChatSuggestions,
getNewTextAndSelection,
getTypeaheadRegexMatches,
useUserMentionsCandidates,
chatMentionRegex,
encodeChatMentionText,
decodeChatMentionText,
getRawChatMention,
renderChatMentionsWithAltText,
};
diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js
index cbb62c902..eb6c03c73 100644
--- a/lib/types/thread-types.js
+++ b/lib/types/thread-types.js
@@ -1,502 +1,506 @@
// @flow
import t, { type TInterface } from 'tcomb';
import {
type AvatarDBContent,
type ClientAvatar,
clientAvatarValidator,
type UpdateUserAvatarRequest,
} from './avatar-types.js';
import type { CalendarQuery } from './entry-types.js';
import type { Media } from './media-types.js';
import type {
MessageTruncationStatuses,
RawMessageInfo,
} from './message-types.js';
import type {
MinimallyEncodedMemberInfo,
MinimallyEncodedRawThreadInfo,
MinimallyEncodedRelativeMemberInfo,
MinimallyEncodedResolvedThreadInfo,
MinimallyEncodedRoleInfo,
MinimallyEncodedThreadInfo,
} from './minimally-encoded-thread-permissions-types.js';
import {
type ThreadSubscription,
threadSubscriptionValidator,
} from './subscription-types.js';
import {
type ThreadPermissionsInfo,
threadPermissionsInfoValidator,
type ThreadRolePermissionsBlob,
threadRolePermissionsBlobValidator,
type UserSurfacedPermission,
} from './thread-permission-types.js';
import { type ThreadType, threadTypeValidator } from './thread-types-enum.js';
import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js';
import type { UserInfo, UserInfos } from './user-types.js';
import {
type ThreadEntity,
threadEntityValidator,
} from '../utils/entity-text.js';
import { tID, tShape } from '../utils/validation-utils.js';
export type LegacyMemberInfo = {
+id: string,
+role: ?string,
+permissions: ThreadPermissionsInfo,
+isSender: boolean,
};
export const legacyMemberInfoValidator: TInterface =
tShape({
id: t.String,
role: t.maybe(tID),
permissions: threadPermissionsInfoValidator,
isSender: t.Boolean,
});
export type MemberInfo = LegacyMemberInfo | MinimallyEncodedMemberInfo;
export type LegacyRelativeMemberInfo = $ReadOnly<{
...LegacyMemberInfo,
+username: ?string,
+isViewer: boolean,
}>;
const legacyRelativeMemberInfoValidator = tShape({
...legacyMemberInfoValidator.meta.props,
username: t.maybe(t.String),
isViewer: t.Boolean,
});
export type RelativeMemberInfo =
| LegacyRelativeMemberInfo
| MinimallyEncodedRelativeMemberInfo;
export type LegacyRoleInfo = {
+id: string,
+name: string,
+permissions: ThreadRolePermissionsBlob,
+isDefault: boolean,
};
export const legacyRoleInfoValidator: TInterface =
tShape({
id: tID,
name: t.String,
permissions: threadRolePermissionsBlobValidator,
isDefault: t.Boolean,
});
export type RoleInfo = LegacyRoleInfo | MinimallyEncodedRoleInfo;
export type ThreadCurrentUserInfo = {
+role: ?string,
+permissions: ThreadPermissionsInfo,
+subscription: ThreadSubscription,
+unread: ?boolean,
};
export const threadCurrentUserInfoValidator: TInterface =
tShape({
role: t.maybe(tID),
permissions: threadPermissionsInfoValidator,
subscription: threadSubscriptionValidator,
unread: t.maybe(t.Boolean),
});
export type LegacyRawThreadInfo = {
+id: string,
+type: ThreadType,
+name: ?string,
+avatar?: ?ClientAvatar,
+description: ?string,
+color: string, // hex, without "#" or "0x"
+creationTime: number, // millisecond timestamp
+parentThreadID: ?string,
+containingThreadID: ?string,
+community: ?string,
+members: $ReadOnlyArray,
+roles: { +[id: string]: LegacyRoleInfo },
+currentUser: ThreadCurrentUserInfo,
+sourceMessageID?: string,
+repliesCount: number,
+pinnedCount?: number,
};
export type LegacyRawThreadInfos = {
+[id: string]: LegacyRawThreadInfo,
};
export const legacyRawThreadInfoValidator: TInterface =
tShape({
id: tID,
type: threadTypeValidator,
name: t.maybe(t.String),
avatar: t.maybe(clientAvatarValidator),
description: t.maybe(t.String),
color: t.String,
creationTime: t.Number,
parentThreadID: t.maybe(tID),
containingThreadID: t.maybe(tID),
community: t.maybe(tID),
members: t.list(legacyMemberInfoValidator),
roles: t.dict(tID, legacyRoleInfoValidator),
currentUser: threadCurrentUserInfoValidator,
sourceMessageID: t.maybe(tID),
repliesCount: t.Number,
pinnedCount: t.maybe(t.Number),
});
export type RawThreadInfo = LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo;
export type RawThreadInfos = {
+[id: string]: RawThreadInfo,
};
export type MinimallyEncodedRawThreadInfos = {
+[id: string]: MinimallyEncodedRawThreadInfo,
};
export type LegacyThreadInfo = {
+id: string,
+type: ThreadType,
+name: ?string,
+uiName: string | ThreadEntity,
+avatar?: ?ClientAvatar,
+description: ?string,
+color: string, // hex, without "#" or "0x"
+creationTime: number, // millisecond timestamp
+parentThreadID: ?string,
+containingThreadID: ?string,
+community: ?string,
+members: $ReadOnlyArray,
+roles: { +[id: string]: LegacyRoleInfo },
+currentUser: ThreadCurrentUserInfo,
+sourceMessageID?: string,
+repliesCount: number,
+pinnedCount?: number,
};
export const legacyThreadInfoValidator: TInterface =
tShape({
id: tID,
type: threadTypeValidator,
name: t.maybe(t.String),
uiName: t.union([t.String, threadEntityValidator]),
avatar: t.maybe(clientAvatarValidator),
description: t.maybe(t.String),
color: t.String,
creationTime: t.Number,
parentThreadID: t.maybe(tID),
containingThreadID: t.maybe(tID),
community: t.maybe(tID),
members: t.list(legacyRelativeMemberInfoValidator),
roles: t.dict(tID, legacyRoleInfoValidator),
currentUser: threadCurrentUserInfoValidator,
sourceMessageID: t.maybe(tID),
repliesCount: t.Number,
pinnedCount: t.maybe(t.Number),
});
export type ThreadInfo = LegacyThreadInfo | MinimallyEncodedThreadInfo;
export type LegacyResolvedThreadInfo = {
+id: string,
+type: ThreadType,
+name: ?string,
+uiName: string,
+avatar?: ?ClientAvatar,
+description: ?string,
+color: string, // hex, without "#" or "0x"
+creationTime: number, // millisecond timestamp
+parentThreadID: ?string,
+containingThreadID: ?string,
+community: ?string,
+members: $ReadOnlyArray,
+roles: { +[id: string]: LegacyRoleInfo },
+currentUser: ThreadCurrentUserInfo,
+sourceMessageID?: string,
+repliesCount: number,
+pinnedCount?: number,
};
export type ResolvedThreadInfo =
| LegacyResolvedThreadInfo
| MinimallyEncodedResolvedThreadInfo;
export type ServerMemberInfo = {
+id: string,
+role: ?string,
+permissions: ThreadPermissionsInfo,
+subscription: ThreadSubscription,
+unread: ?boolean,
+isSender: boolean,
};
export type ServerThreadInfo = {
+id: string,
+type: ThreadType,
+name: ?string,
+avatar?: AvatarDBContent,
+description: ?string,
+color: string, // hex, without "#" or "0x"
+creationTime: number, // millisecond timestamp
+parentThreadID: ?string,
+containingThreadID: ?string,
+community: ?string,
+depth: number,
+members: $ReadOnlyArray,
+roles: { +[id: string]: LegacyRoleInfo },
+sourceMessageID?: string,
+repliesCount: number,
+pinnedCount: number,
};
export type ThreadStore = {
+threadInfos: RawThreadInfos,
};
export type ClientDBThreadInfo = {
+id: string,
+type: number,
+name: ?string,
+avatar?: ?string,
+description: ?string,
+color: string,
+creationTime: string,
+parentThreadID: ?string,
+containingThreadID: ?string,
+community: ?string,
+members: string,
+roles: string,
+currentUser: string,
+sourceMessageID?: string,
+repliesCount: number,
+pinnedCount?: number,
};
export type ThreadDeletionRequest = {
+threadID: string,
+accountPassword?: empty,
};
export type RemoveMembersRequest = {
+threadID: string,
+memberIDs: $ReadOnlyArray,
};
export type RoleChangeRequest = {
+threadID: string,
+memberIDs: $ReadOnlyArray,
+role: string,
};
export type ChangeThreadSettingsResult = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
+newMessageInfos: $ReadOnlyArray,
};
export type ChangeThreadSettingsPayload = {
+threadID: string,
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
+newMessageInfos: $ReadOnlyArray,
};
export type LeaveThreadRequest = {
+threadID: string,
};
export type LeaveThreadResult = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
export type LeaveThreadPayload = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
export type ThreadChanges = Partial<{
+type: ThreadType,
+name: string,
+description: string,
+color: string,
+parentThreadID: ?string,
+newMemberIDs: $ReadOnlyArray,
+avatar: UpdateUserAvatarRequest,
}>;
export type UpdateThreadRequest = {
+threadID: string,
+changes: ThreadChanges,
+accountPassword?: empty,
};
export type BaseNewThreadRequest = {
+id?: ?string,
+name?: ?string,
+description?: ?string,
+color?: ?string,
+parentThreadID?: ?string,
+initialMemberIDs?: ?$ReadOnlyArray,
+ghostMemberIDs?: ?$ReadOnlyArray,
};
type NewThreadRequest =
| {
+type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12,
...BaseNewThreadRequest,
}
| {
+type: 5,
+sourceMessageID: string,
...BaseNewThreadRequest,
};
export type ClientNewThreadRequest = {
...NewThreadRequest,
+calendarQuery: CalendarQuery,
};
export type ServerNewThreadRequest = {
...NewThreadRequest,
+calendarQuery?: ?CalendarQuery,
};
export type NewThreadResponse = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
+newMessageInfos: $ReadOnlyArray,
+userInfos: UserInfos,
+newThreadID: string,
};
export type NewThreadResult = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
+newMessageInfos: $ReadOnlyArray,
+userInfos: UserInfos,
+newThreadID: string,
};
export type ServerThreadJoinRequest = {
+threadID: string,
+calendarQuery?: ?CalendarQuery,
+inviteLinkSecret?: string,
};
export type ClientThreadJoinRequest = {
+threadID: string,
+calendarQuery: CalendarQuery,
+inviteLinkSecret?: string,
};
export type ThreadJoinResult = {
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
+rawMessageInfos: $ReadOnlyArray,
+truncationStatuses: MessageTruncationStatuses,
+userInfos: UserInfos,
};
export type ThreadJoinPayload = {
+updatesResult: {
newUpdates: $ReadOnlyArray,
},
+rawMessageInfos: $ReadOnlyArray,
+truncationStatuses: MessageTruncationStatuses,
+userInfos: $ReadOnlyArray,
};
export type ThreadFetchMediaResult = {
+media: $ReadOnlyArray,
};
export type ThreadFetchMediaRequest = {
+threadID: string,
+limit: number,
+offset: number,
};
export type SidebarInfo = {
+threadInfo: ThreadInfo,
+lastUpdatedTime: number,
+mostRecentNonLocalMessage: ?string,
};
export type ToggleMessagePinRequest = {
+messageID: string,
+action: 'pin' | 'unpin',
};
export type ToggleMessagePinResult = {
+newMessageInfos: $ReadOnlyArray,
+threadID: string,
};
type CreateRoleAction = {
+community: string,
+name: string,
+permissions: $ReadOnlyArray,
+action: 'create_role',
};
type EditRoleAction = {
+community: string,
+existingRoleID: string,
+name: string,
+permissions: $ReadOnlyArray,
+action: 'edit_role',
};
export type RoleModificationRequest = CreateRoleAction | EditRoleAction;
export type RoleModificationResult = {
+threadInfo: RawThreadInfo,
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
export type RoleModificationPayload = {
+threadInfo: RawThreadInfo,
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
export type RoleDeletionRequest = {
+community: string,
+roleID: string,
};
export type RoleDeletionResult = {
+threadInfo: RawThreadInfo,
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
export type RoleDeletionPayload = {
+threadInfo: RawThreadInfo,
+updatesResult: {
+newUpdates: $ReadOnlyArray,
},
};
// We can show a max of 3 sidebars inline underneath their parent in the chat
// tab. If there are more, we show a button that opens a modal to see the rest
export const maxReadSidebars = 3;
// We can show a max of 5 sidebars inline underneath their parent
// in the chat tab if every one of the displayed sidebars is unread
export const maxUnreadSidebars = 5;
export type ThreadStoreThreadInfos = LegacyRawThreadInfos;
+export type ChatMentionCandidate = {
+ +threadInfo: ResolvedThreadInfo,
+ +rawChatName: string | ThreadEntity,
+};
export type ChatMentionCandidates = {
- +[id: string]: ResolvedThreadInfo,
+ +[id: string]: ChatMentionCandidate,
};
export type ChatMentionCandidatesObj = {
+[id: string]: ChatMentionCandidates,
};
export type UserProfileThreadInfo = {
+threadInfo: ThreadInfo,
+pendingPersonalThreadUserInfo?: UserInfo,
};