diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 1730c8fb6..e934ea3fe 100644 --- a/lib/components/chat-mention-provider.react.js +++ b/lib/components/chat-mention-provider.react.js @@ -1,285 +1,280 @@ // @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 type { MinimallyEncodedResolvedThreadInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidate, ChatMentionCandidatesObj, - LegacyResolvedThreadInfo, LegacyThreadInfo, } from '../types/thread-types.js'; import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js'; import { getNameForThreadEntity } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; type Props = { +children: React.Node, }; export type ChatMentionContextType = { +getChatMentionSearchIndex: ( threadInfo: LegacyThreadInfo | 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: LegacyThreadInfo | 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]: LegacyThreadInfo | ThreadInfo }, + threadInfos: { +[id: string]: ThreadInfo }, resolvedThreadInfos: { - +[id: string]: - | LegacyResolvedThreadInfo - | MinimallyEncodedResolvedThreadInfo, + +[id: string]: MinimallyEncodedResolvedThreadInfo, }, ): { chatMentionCandidatesObj: ChatMentionCandidatesObj, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const result: { [string]: { [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]: { threadInfo: currentResolvedThreadInfo, rawChatName: threadInfos[currentThreadID].uiName, }, }; } continue; } if (!result[currentThreadCommunity]) { result[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, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePath.id; } if ( lastThreadInTraversePath.type !== threadTypes.PERSONAL && lastThreadInTraversePath.type !== threadTypes.PRIVATE ) { 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, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePathParentCommunityThreadID; } } continue; } 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: { - +[id: string]: - | LegacyResolvedThreadInfo - | MinimallyEncodedResolvedThreadInfo, + +[id: string]: MinimallyEncodedResolvedThreadInfo, }, 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].threadInfo .uiName, rawChatName: chatMentionCandidatesObj[communityThreadID][threadID].rawChatName, }); } // 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, rawChatName } of searchIndexEntries) { const names = [uiName]; if (rawChatName) { typeof rawChatName === 'string' ? names.push(rawChatName) : names.push(getNameForThreadEntity(rawChatName)); } searchIndex.addEntry(id, names.join(' ')); } result[communityThreadID] = searchIndex; } return result; }, [chatMentionCandidatesObj]); } export { ChatMentionContextProvider, ChatMentionContext }; diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 6bd0f3cdb..9fd173339 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,409 +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 { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates, - LegacyResolvedThreadInfo, RelativeMemberInfo, } 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: ?(LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo), + threadInfo: ?MinimallyEncodedResolvedThreadInfo, content: string, hasAccessToChat: boolean, } { 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 7d12557f1..fc95db303 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,222 +1,221 @@ // @flow import * as React from 'react'; import { markdownUserMentionRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import type { MinimallyEncodedResolvedThreadInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidates, - LegacyResolvedThreadInfo, LegacyThreadInfo, RelativeMemberInfo, } from '../types/thread-types.js'; import { chatNameMaxLength, idSchemaRegex } 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: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, }; 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: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + threadInfo: MinimallyEncodedResolvedThreadInfo, ): 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, 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 = resolvedThreadMembers.filter(member => member.role); 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]); } 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, }); } 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: LegacyThreadInfo | ThreadInfo, parentThreadInfo: ?LegacyThreadInfo | ?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/shared/mention-utils.test.js b/lib/shared/mention-utils.test.js index 6bdf7ed0e..eb1976e96 100644 --- a/lib/shared/mention-utils.test.js +++ b/lib/shared/mention-utils.test.js @@ -1,67 +1,62 @@ // @flow import { encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, } from './mention-utils.js'; import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyResolvedThreadInfo } from '../types/thread-types.js'; describe('encodeChatMentionText', () => { it('should encode closing brackets', () => { expect(encodeChatMentionText('[[test]test')).toEqual('[[test\\]test'); expect(encodeChatMentionText('test]]test')).toEqual('test\\]\\]test'); expect(encodeChatMentionText('test]')).toEqual('test\\]'); expect(encodeChatMentionText('test ] ] ]] asd] d]d')).toEqual( 'test \\] \\] \\]\\] asd\\] d\\]d', ); }); }); describe('decodeChatMentionText', () => { it('should decode closing brackets', () => { expect(decodeChatMentionText('test\\]')).toEqual('test]'); expect(decodeChatMentionText('test\\]\\]')).toEqual('test]]'); expect(decodeChatMentionText('test \\] test')).toEqual('test ] test'); }); it('should not decode already decoded closing brackets', () => { expect(decodeChatMentionText('test]]')).toEqual('test]]'); expect(decodeChatMentionText('test]')).toEqual('test]'); expect(decodeChatMentionText('test ] test')).toEqual('test ] test'); }); }); describe('getRawChatMention', () => { it('should return raw chat mention', () => expect( getRawChatMention({ - ...(({}: any): - | LegacyResolvedThreadInfo - | MinimallyEncodedResolvedThreadInfo), + ...(({}: any): MinimallyEncodedResolvedThreadInfo), id: '256|123', uiName: 'thread-name', }), ).toEqual('@[[256|123:thread-name]]')); it('should return raw chat mention with encoded text', () => expect( getRawChatMention({ - ...(({}: any): - | LegacyResolvedThreadInfo - | MinimallyEncodedResolvedThreadInfo), + ...(({}: any): MinimallyEncodedResolvedThreadInfo), id: '256|123', uiName: 'thread-]name]]', }), ).toEqual('@[[256|123:thread-\\]name\\]\\]]]')); }); describe('renderChatMentionsWithAltText', () => { it('should render chat mentions with alternative text', () => expect( renderChatMentionsWithAltText( 'This is a test @[[256|123:thread-name]] @[[256|123:thread-\\]name2\\]\\]]]', ), ).toEqual('This is a test @thread-name @thread-]name2]]')); }); diff --git a/lib/types/filter-types.js b/lib/types/filter-types.js index a8a492e05..b4c7e03fa 100644 --- a/lib/types/filter-types.js +++ b/lib/types/filter-types.js @@ -1,47 +1,46 @@ // @flow import t, { type TUnion } from 'tcomb'; import type { MinimallyEncodedResolvedThreadInfo } from './minimally-encoded-thread-permissions-types.js'; -import type { LegacyResolvedThreadInfo } from './thread-types.js'; import { tID, tShape, tString } from '../utils/validation-utils.js'; export const calendarThreadFilterTypes = Object.freeze({ THREAD_LIST: 'threads', NOT_DELETED: 'not_deleted', }); export type CalendarThreadFilterType = $Values< typeof calendarThreadFilterTypes, >; export type CalendarThreadFilter = { +type: 'threads', +threadIDs: $ReadOnlyArray, }; export type NotDeletedFilter = { +type: 'not_deleted' }; export type CalendarFilter = NotDeletedFilter | CalendarThreadFilter; export const calendarFilterValidator: TUnion = t.union([ tShape({ type: tString('threads'), threadIDs: t.list(tID), }), tShape({ type: tString('not_deleted') }), ]); export const defaultCalendarFilters: $ReadOnlyArray = [ { type: calendarThreadFilterTypes.NOT_DELETED }, ]; export const updateCalendarThreadFilter = 'UPDATE_CALENDAR_THREAD_FILTER'; export const clearCalendarThreadFilter = 'CLEAR_CALENDAR_THREAD_FILTER'; export const setCalendarDeletedFilter = 'SET_CALENDAR_DELETED_FILTER'; export type SetCalendarDeletedFilterPayload = { +includeDeleted: boolean, }; export type FilterThreadInfo = { - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +numVisibleEntries: number, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index d47418661..352e5084e 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,503 +1,483 @@ // @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, RawThreadInfo, MinimallyEncodedRelativeMemberInfo, MinimallyEncodedResolvedThreadInfo, MinimallyEncodedRoleInfo, ThreadInfo, } 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 MixedRawThreadInfos = { +[id: string]: LegacyRawThreadInfo | RawThreadInfo, }; export type RawThreadInfos = { +[id: string]: RawThreadInfo, }; 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 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 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 LegacyThreadStore = { +threadInfos: MixedRawThreadInfos, }; 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: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: LegacyRawThreadInfo | 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: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +rawChatName: string | ThreadEntity, }; export type ChatMentionCandidates = { +[id: string]: ChatMentionCandidate, }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js index a79af2a48..0f2cfafb4 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,136 +1,126 @@ // @flow import * as React from 'react'; import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; import type { ThreadInfo, MinimallyEncodedResolvedThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; -import type { - LegacyResolvedThreadInfo, - LegacyThreadInfo, -} from '../types/thread-types.js'; +import type { LegacyThreadInfo } from '../types/thread-types.js'; type WritableCommunityDrawerItemData = { threadInfo: ThreadInfo, itemChildren: $ReadOnlyArray>, hasSubchannelsButton: boolean, labelStyle: T, }; export type CommunityDrawerItemData = $ReadOnly< WritableCommunityDrawerItemData, >; function createRecursiveDrawerItemsData( childThreadInfosMap: { +[id: string]: $ReadOnlyArray, }, communities: $ReadOnlyArray, labelStyles: $ReadOnlyArray, maxDepth: number, ): $ReadOnlyArray> { const result: $ReadOnlyArray< WritableCommunityDrawerItemData, > = communities.map(community => ({ threadInfo: community, itemChildren: [], labelStyle: labelStyles[0], hasSubchannelsButton: false, })); let queue = result.map(item => [item, 0]); for (let i = 0; i < queue.length; i++) { const [item, lvl] = queue[i]; const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? []; if (lvl < maxDepth) { item.itemChildren = itemChildThreadInfos .filter(childItem => communitySubthreads.includes(childItem.type)) .map(childItem => ({ threadInfo: childItem, itemChildren: [], labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)], hasSubchannelsButton: lvl + 1 === maxDepth && threadHasSubchannels(childItem, childThreadInfosMap), })); queue = queue.concat( item.itemChildren.map(childItem => [childItem, lvl + 1]), ); } } return result; } function threadHasSubchannels( threadInfo: ThreadInfo, childThreadInfosMap: { +[id: string]: $ReadOnlyArray, }, ): boolean { if (!childThreadInfosMap[threadInfo.id]?.length) { return false; } return childThreadInfosMap[threadInfo.id].some(thread => threadIsChannel(thread), ); } function useAppendCommunitySuffix( - communities: $ReadOnlyArray< - LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, - >, -): $ReadOnlyArray< - LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, -> { + communities: $ReadOnlyArray, +): $ReadOnlyArray { return React.useMemo(() => { - const result: ( - | LegacyResolvedThreadInfo - | MinimallyEncodedResolvedThreadInfo - )[] = []; + const result: MinimallyEncodedResolvedThreadInfo[] = []; const names = new Map(); for (const chat of communities) { let name = chat.uiName; const numberOfOccurrences = names.get(name); names.set(name, (numberOfOccurrences ?? 0) + 1); if (numberOfOccurrences) { name = `${name} (${numberOfOccurrences.toString()})`; } // Branching to appease `flow`. if (chat.minimallyEncoded) { result.push({ ...chat, uiName: name }); } else { result.push({ ...chat, uiName: name }); } } return result; }, [communities]); } function filterThreadIDsBelongingToCommunity( communityID: string, threadInfosObj: { +[id: string]: RawThreadInfo | LegacyThreadInfo | ThreadInfo, }, ): $ReadOnlySet { const threadInfos = values(threadInfosObj); const threadIDs = threadInfos .filter( thread => (thread.community === communityID || thread.id === communityID) && threadInFilterList(thread), ) .map(item => item.id); return new Set(threadIDs); } export { createRecursiveDrawerItemsData, useAppendCommunitySuffix, filterThreadIDsBelongingToCommunity, }; diff --git a/native/avatars/thread-avatar.react.js b/native/avatars/thread-avatar.react.js index 6cded6b7a..e0101711e 100644 --- a/native/avatars/thread-avatar.react.js +++ b/native/avatars/thread-avatar.react.js @@ -1,62 +1,58 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { ThreadInfo, MinimallyEncodedResolvedThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { - LegacyResolvedThreadInfo, - LegacyThreadInfo, -} from 'lib/types/thread-types.js'; +import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +threadInfo: | RawThreadInfo | LegacyThreadInfo | ThreadInfo - | LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +size: AvatarSize, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ; } export default ThreadAvatar; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 2f1d81949..a165401f5 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,297 +1,296 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, Text, TextInput as BaseTextInput, View, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import type { DeleteThreadInput } from 'lib/actions/thread-actions.js'; import { deleteThreadActionTypes, useDeleteThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { containedThreadInfos, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { getThreadsToDeleteText, identifyInvalidatedThreads, } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { LeaveThreadPayload, - LegacyResolvedThreadInfo, LegacyThreadInfo, } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { type NavAction, NavContext, } from '../../navigation/navigation-context.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { +threadInfo: LegacyThreadInfo | ThreadInfo, }; const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +shouldUseDeleteConfirmationAlert: boolean, +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (input: DeleteThreadInput) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render(): React.Node { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const { threadInfo } = this.props; return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; dispatchDeleteThreadAction = () => { void this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; submitDeletion = () => { if (!this.props.shouldUseDeleteConfirmationAlert) { this.dispatchDeleteThreadAction(); return; } Alert.alert( 'Warning', `${getThreadsToDeleteText( this.props.threadInfo, )} will also be permanently deleted.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Continue', onPress: this.dispatchDeleteThreadAction }, ], { cancelable: false }, ); }; async deleteThread(): Promise { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread({ threadID: threadInfo.id }); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } throw e; } } } const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const reduxContainedThreadInfos = useSelector( state => containedThreadInfos(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useDeleteThread(); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; const shouldUseDeleteConfirmationAlert = reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/chat/settings/thread-settings-avatar.react.js b/native/chat/settings/thread-settings-avatar.react.js index 2f792ec90..ae56508f4 100644 --- a/native/chat/settings/thread-settings-avatar.react.js +++ b/native/chat/settings/thread-settings-avatar.react.js @@ -1,40 +1,39 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyResolvedThreadInfo } from 'lib/types/thread-types.js'; import EditThreadAvatar from '../../avatars/edit-thread-avatar.react.js'; import { useStyles } from '../../themes/colors.js'; type Props = { - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +canChangeSettings: boolean, }; function ThreadSettingsAvatar(props: Props): React.Node { const { threadInfo, canChangeSettings } = props; const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { container: { alignItems: 'center', backgroundColor: 'panelForeground', flex: 1, paddingVertical: 16, }, }; const MemoizedThreadSettingsAvatar: React.ComponentType = React.memo(ThreadSettingsAvatar); export default MemoizedThreadSettingsAvatar; diff --git a/native/chat/settings/thread-settings-delete-thread.react.js b/native/chat/settings/thread-settings-delete-thread.react.js index ea82ebedc..7da906a15 100644 --- a/native/chat/settings/thread-settings-delete-thread.react.js +++ b/native/chat/settings/thread-settings-delete-thread.react.js @@ -1,65 +1,64 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyResolvedThreadInfo } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import Button from '../../components/button.react.js'; import { DeleteThreadRouteName } from '../../navigation/route-names.js'; import { useColors, useStyles } from '../../themes/colors.js'; import type { ViewStyle } from '../../types/styles.js'; type Props = { - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, }; function ThreadSettingsDeleteThread(props: Props): React.Node { const { navigate, threadInfo } = props; const onPress = React.useCallback(() => { navigate<'DeleteThread'>({ name: DeleteThreadRouteName, params: { threadInfo }, key: `${DeleteThreadRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); const colors = useColors(); const { panelIosHighlightUnderlay } = colors; const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; export default ThreadSettingsDeleteThread; diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index 80a2bc0ef..8b9e68b52 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,247 +1,246 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ChangeThreadSettingsPayload, - LegacyResolvedThreadInfo, UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { chatNameMaxLength } from 'lib/utils/validation-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SingleLine from '../../components/single-line.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; type BaseProps = { - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render(): React.Node { return ( Name {this.renderContent()} ); } renderButton(): React.Node { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( ); } return ; } renderContent(): React.Node { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} {this.renderButton()} ); } return ( {this.renderButton()} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; threadEditName(): string { return firstLine( this.props.threadInfo.name ? this.props.threadInfo.name : '', ); } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = firstLine(this.props.nameEditValue); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; void this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${action}:${threadID}:name`, }, ); void editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string): Promise { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const ConnectedThreadSettingsName: React.ComponentType = React.memo(function ConnectedThreadSettingsName(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, ), ); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); return ( ); }); export default ConnectedThreadSettingsName; diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js index 5128f5574..8b04d5698 100644 --- a/web/calendar/entry.react.js +++ b/web/calendar/entry.react.js @@ -1,506 +1,505 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { type PushModal, useModalContext, } from 'lib/components/modal-provider.react.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import { type EntryInfo, type CreateEntryInfo, type SaveEntryInfo, type SaveEntryResult, type SaveEntryPayload, type CreateEntryPayload, type DeleteEntryInfo, type DeleteEntryResult, type CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { LegacyResolvedThreadInfo } from 'lib/types/thread-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import css from './calendar.css'; import LoadingIndicator from '../loading-indicator.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { HistoryVector, DeleteVector } from '../vectors.react.js'; type BaseProps = { +innerRef: (key: string, me: Entry) => void, +entryInfo: EntryInfo, +focusOnFirstEntryNewerThan: (time: number) => void, +tabIndex: number, }; type Props = { ...BaseProps, - +threadInfo: LegacyResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +threadInfo: MinimallyEncodedResolvedThreadInfo, +loggedIn: boolean, +calendarQuery: () => CalendarQuery, +online: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, +pushModal: PushModal, +popModal: () => void, }; type State = { +focused: boolean, +loadingStatus: LoadingStatus, +text: string, }; class Entry extends React.PureComponent { textarea: ?HTMLTextAreaElement; creating: boolean; needsUpdateAfterCreation: boolean; needsDeleteAfterCreation: boolean; nextSaveAttemptIndex: number; mounted: boolean; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { focused: false, loadingStatus: 'inactive', text: props.entryInfo.text, }; this.creating = false; this.needsUpdateAfterCreation = false; this.needsDeleteAfterCreation = false; this.nextSaveAttemptIndex = 0; } guardedSetState(input: Partial) { if (this.mounted) { this.setState(input); } } componentDidMount() { this.mounted = true; this.props.innerRef(entryKey(this.props.entryInfo), this); this.updateHeight(); // Whenever a new Entry is created, focus on it if (!this.props.entryInfo.id) { this.focus(); } } componentDidUpdate(prevProps: Props) { if ( !this.state.focused && this.props.entryInfo.text !== this.state.text && this.props.entryInfo.text !== prevProps.entryInfo.text ) { this.setState({ text: this.props.entryInfo.text }); this.currentlySaving = null; } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } } focus() { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.focus(); } onMouseDown: (event: SyntheticEvent) => void = event => { if (this.state.focused && event.target !== this.textarea) { // Don't lose focus when some non-textarea part is clicked event.preventDefault(); } }; componentWillUnmount() { this.mounted = false; } updateHeight: () => void = () => { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.style.height = 'auto'; this.textarea.style.height = this.textarea.scrollHeight + 'px'; }; render(): React.Node { let actionLinks = null; if (this.state.focused) { let historyButton = null; if (this.props.entryInfo.id) { historyButton = ( History ); } const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`; actionLinks = (
Delete {historyButton} {this.props.threadInfo.uiName}
); } const darkColor = colorIsDark(this.props.threadInfo.color); const entryClasses = classNames({ [css.entry]: true, [css.darkEntry]: darkColor, [css.focusedEntry]: this.state.focused, }); const style = { backgroundColor: `#${this.props.threadInfo.color}` }; const loadingIndicatorColor = darkColor ? 'white' : 'black'; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return (