diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index e1ce0266c..9a583216c 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,375 +1,378 @@ // @flow import invariant from 'invariant'; import { markdownUserMentionRegex, decodeChatMentionText, } from './mention-utils.js'; +import type { MinimallyEncodedRelativeMemberInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { RelativeMemberInfo, ResolvedThreadInfo, ChatMentionCandidates, } 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, }; } function createMemberMapForUserMentions( - members: $ReadOnlyArray, + members: $ReadOnlyArray< + RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + >, ): $ReadOnlyMap { const membersMap = new Map(); members.forEach(member => { if (member.role && member.username) { membersMap.set(member.username.toLowerCase(), member.id); } }); 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 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`, ); } export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, spoilerRegex, matchBlockQuote, parseBlockQuote, jsonMatch, jsonPrint, matchList, parseList, createMemberMapForUserMentions, matchUserMentions, parseUserMentions, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, parseChatMention, }; diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js index f7b5e8520..780c02788 100644 --- a/web/avatars/edit-thread-avatar-menu.react.js +++ b/web/avatars/edit-thread-avatar-menu.react.js @@ -1,119 +1,127 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import type { + MinimallyEncodedRawThreadInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { useUploadAvatarMedia } from './avatar-hooks.react.js'; import css from './edit-avatar-menu.css'; import ThreadEmojiAvatarSelectionModal from './thread-emoji-avatar-selection-modal.react.js'; import MenuItem from '../components/menu-item.react.js'; import Menu from '../components/menu.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; const editIcon = (
); type Props = { - +threadInfo: RawThreadInfo | ThreadInfo, + +threadInfo: + | RawThreadInfo + | ThreadInfo + | MinimallyEncodedThreadInfo + | MinimallyEncodedRawThreadInfo, }; function EditThreadAvatarMenu(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar } = editThreadAvatarContext; const removeThreadAvatar = React.useCallback( () => baseSetThreadAvatar(threadInfo.id, { type: 'remove' }), [baseSetThreadAvatar, threadInfo.id], ); const removeMenuItem = React.useMemo( () => ( ), [removeThreadAvatar], ); const imageInputRef = React.useRef(); const onImageMenuItemClicked = React.useCallback( () => imageInputRef.current?.click(), [], ); const uploadAvatarMedia = useUploadAvatarMedia(); const onImageSelected = React.useCallback( async event => { const uploadResult = await uploadAvatarMedia(event.target.files[0]); baseSetThreadAvatar(threadInfo.id, uploadResult); }, [baseSetThreadAvatar, threadInfo.id, uploadAvatarMedia], ); const imageMenuItem = React.useMemo( () => ( ), [onImageMenuItemClicked], ); const { pushModal } = useModalContext(); const openEmojiSelectionModal = React.useCallback( () => pushModal(), [pushModal, threadInfo], ); const emojiMenuItem = React.useMemo( () => ( ), [openEmojiSelectionModal], ); const menuItems = React.useMemo(() => { const items = [emojiMenuItem, imageMenuItem]; if (threadInfo.avatar) { items.push(removeMenuItem); } return items; }, [emojiMenuItem, imageMenuItem, removeMenuItem, threadInfo.avatar]); return (
{menuItems}
); } export default EditThreadAvatarMenu; diff --git a/web/avatars/edit-thread-avatar.react.js b/web/avatars/edit-thread-avatar.react.js index 85f184a52..6f590fbe2 100644 --- a/web/avatars/edit-thread-avatar.react.js +++ b/web/avatars/edit-thread-avatar.react.js @@ -1,48 +1,56 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; +import type { + MinimallyEncodedRawThreadInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import EditThreadAvatarMenu from './edit-thread-avatar-menu.react.js'; import css from './edit-thread-avatar.css'; import ThreadAvatar from './thread-avatar.react.js'; type Props = { - +threadInfo: RawThreadInfo | ThreadInfo, + +threadInfo: + | RawThreadInfo + | ThreadInfo + | MinimallyEncodedRawThreadInfo + | MinimallyEncodedThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const { threadInfo } = props; const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); let editThreadAvatarMenu; if (canEditThreadAvatar && !threadAvatarSaveInProgress) { editThreadAvatarMenu = ; } return (
{editThreadAvatarMenu}
); } export default EditThreadAvatar; diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js index d4302820d..1636d9b56 100644 --- a/web/avatars/thread-avatar.react.js +++ b/web/avatars/thread-avatar.react.js @@ -1,56 +1,64 @@ // @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 { + MinimallyEncodedRawThreadInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { - +threadInfo: RawThreadInfo | ThreadInfo, + +threadInfo: + | RawThreadInfo + | ThreadInfo + | MinimallyEncodedRawThreadInfo + | MinimallyEncodedThreadInfo, +size: AvatarSize, +showSpinner?: boolean, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size, showSpinner } = 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/web/avatars/thread-emoji-avatar-selection-modal.react.js b/web/avatars/thread-emoji-avatar-selection-modal.react.js index 9e31bb1bf..ccd84d34d 100644 --- a/web/avatars/thread-emoji-avatar-selection-modal.react.js +++ b/web/avatars/thread-emoji-avatar-selection-modal.react.js @@ -1,53 +1,61 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { getDefaultAvatar, useAvatarForThread, } from 'lib/shared/avatar-utils.js'; import type { ClientAvatar, ClientEmojiAvatar, } from 'lib/types/avatar-types.js'; +import type { + MinimallyEncodedRawThreadInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import EmojiAvatarSelectionModal from './emoji-avatar-selection-modal.react.js'; type Props = { - +threadInfo: ThreadInfo | RawThreadInfo, + +threadInfo: + | ThreadInfo + | RawThreadInfo + | MinimallyEncodedThreadInfo + | MinimallyEncodedRawThreadInfo, }; function ThreadEmojiAvatarSelectionModal(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar, threadAvatarSaveInProgress } = editThreadAvatarContext; const currentThreadAvatar: ClientAvatar = useAvatarForThread(threadInfo); const defaultThreadAvatar: ClientEmojiAvatar = getDefaultAvatar( threadInfo.id, threadInfo.color, ); const setEmojiAvatar = React.useCallback( (pendingEmojiAvatar: ClientEmojiAvatar): Promise => baseSetThreadAvatar(threadInfo.id, pendingEmojiAvatar), [baseSetThreadAvatar, threadInfo.id], ); return ( ); } export default ThreadEmojiAvatarSelectionModal; diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js index 73993e2ba..95883a335 100644 --- a/web/calendar/day.react.js +++ b/web/calendar/day.react.js @@ -1,258 +1,259 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _some from 'lodash/fp/some.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions.js'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { EntryInfo } from 'lib/types/entry-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { dateString, dateFromString } from 'lib/utils/date-utils.js'; import css from './calendar.css'; import type { InnerEntry } from './entry.react.js'; import Entry from './entry.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import ThreadPickerModal from '../modals/threads/thread-picker-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { htmlTargetFromEvent } from '../vector-utils.js'; import { AddVector, HistoryVector } from '../vectors.react.js'; type BaseProps = { +dayString: string, +entryInfos: $ReadOnlyArray, +startingTabIndex: number, }; type Props = { ...BaseProps, - +onScreenThreadInfos: $ReadOnlyArray, + +onScreenThreadInfos: $ReadOnlyArray, +viewerID: ?string, +loggedIn: boolean, +nextLocalID: number, +dispatch: Dispatch, +pushModal: PushModal, +popModal: () => void, }; type State = { +hovered: boolean, }; class Day extends React.PureComponent { state: State = { hovered: false, }; entryContainer: ?HTMLDivElement; entryContainerSpacer: ?HTMLDivElement; actionLinks: ?HTMLDivElement; entries: Map = new Map(); componentDidUpdate(prevProps: Props) { if (this.props.entryInfos.length > prevProps.entryInfos.length) { invariant(this.entryContainer, 'entryContainer ref not set'); this.entryContainer.scrollTop = this.entryContainer.scrollHeight; } } render() { const now = new Date(); const isToday = dateString(now) === this.props.dayString; const tdClasses = classNames(css.day, { [css.currentDay]: isToday }); let actionLinks = null; const hovered = this.state.hovered; if (hovered) { const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`; actionLinks = ( ); } const entries = this.props.entryInfos .filter(entryInfo => _some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos), ) .map((entryInfo, i) => { const key = entryKey(entryInfo); return ( ); }); const entryContainerClasses = classNames(css.entryContainer, { [css.focusedEntryContainer]: hovered, }); const date = dateFromString(this.props.dayString); return (

{date.getDate()}

{entries}
{actionLinks} ); } actionLinksRef = (actionLinks: ?HTMLDivElement) => { this.actionLinks = actionLinks; }; entryContainerRef = (entryContainer: ?HTMLDivElement) => { this.entryContainer = entryContainer; }; entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => { this.entryContainerSpacer = entryContainerSpacer; }; entryRef = (key: string, entry: InnerEntry) => { this.entries.set(key, entry); }; onMouseEnter = () => { this.setState({ hovered: true }); }; onMouseLeave = () => { this.setState({ hovered: false }); }; onClick = (event: SyntheticEvent) => { const target = htmlTargetFromEvent(event); invariant( this.entryContainer instanceof HTMLDivElement, "entryContainer isn't div", ); invariant( this.entryContainerSpacer instanceof HTMLDivElement, "entryContainerSpacer isn't div", ); if ( target === this.entryContainer || target === this.entryContainerSpacer || (this.actionLinks && target === this.actionLinks) ) { this.onAddEntry(event); } }; onAddEntry = (event: SyntheticEvent<*>) => { event.preventDefault(); invariant( this.props.onScreenThreadInfos.length > 0, "onAddEntry shouldn't be clicked if no onScreenThreadInfos", ); if (this.props.onScreenThreadInfos.length === 1) { this.createNewEntry(this.props.onScreenThreadInfos[0].id); } else if (this.props.onScreenThreadInfos.length > 1) { this.props.pushModal( , ); } }; createNewEntry = (threadID: string) => { if (!this.props.loggedIn) { this.props.pushModal(); return; } const viewerID = this.props.viewerID; invariant(viewerID, 'should have viewerID in order to create thread'); this.props.dispatch({ type: createLocalEntryActionType, payload: createLocalEntry( threadID, this.props.nextLocalID, this.props.dayString, viewerID, ), }); }; onHistory = (event: SyntheticEvent) => { event.preventDefault(); this.props.pushModal( , ); }; focusOnFirstEntryNewerThan = (time: number) => { const entryInfo = this.props.entryInfos.find( candidate => candidate.creationTime > time, ); if (entryInfo) { const entry = this.entries.get(entryKey(entryInfo)); invariant(entry, 'entry for entryinfo should be defined'); entry.focus(); } }; } const ConnectedDay: React.ComponentType = React.memo( function ConnectedDay(props) { const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector); const viewerID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector( state => !!(state.currentUserInfo && !state.currentUserInfo.anonymous && true), ); const nextLocalID = useSelector(state => state.nextLocalID); const dispatch = useDispatch(); const { pushModal, popModal } = useModalContext(); return ( ); }, ); export default ConnectedDay; diff --git a/web/chat/chat-thread-list-item-menu.react.js b/web/chat/chat-thread-list-item-menu.react.js index 2d212fadb..4d60d98c9 100644 --- a/web/chat/chat-thread-list-item-menu.react.js +++ b/web/chat/chat-thread-list-item-menu.react.js @@ -1,76 +1,77 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-thread-list-item-menu.css'; import Button from '../components/button.react.js'; import { useThreadIsActive } from '../selectors/thread-selectors.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +mostRecentNonLocalMessage: ?string, +renderStyle?: 'chat' | 'thread', }; function ChatThreadListItemMenu(props: Props): React.Node { const { renderStyle = 'chat', threadInfo, mostRecentNonLocalMessage } = props; const active = useThreadIsActive(threadInfo.id); const [menuVisible, setMenuVisible] = React.useState(false); const toggleMenu = React.useCallback( event => { event.stopPropagation(); setMenuVisible(!menuVisible); }, [menuVisible], ); const hideMenu = React.useCallback(() => { setMenuVisible(false); }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, hideMenu, ); const onToggleUnreadStatusClicked = React.useCallback( event => { event.stopPropagation(); toggleUnreadStatus(); }, [toggleUnreadStatus], ); const toggleUnreadStatusButtonText = `Mark as ${ threadInfo.currentUser.unread ? 'read' : 'unread' }`; const menuIconSize = renderStyle === 'chat' ? 24 : 20; const menuCls = classNames(css.menu, { [css.menuSidebar]: renderStyle === 'thread', }); const btnCls = classNames(css.menuContent, { [css.menuContentVisible]: menuVisible, [css.active]: active, }); return (
); } export default ChatThreadListItemMenu; diff --git a/web/chat/chat-thread-list-see-more-sidebars.react.js b/web/chat/chat-thread-list-see-more-sidebars.react.js index 7c02a3e11..60aa98548 100644 --- a/web/chat/chat-thread-list-see-more-sidebars.react.js +++ b/web/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,50 +1,51 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { IoIosMore } from 'react-icons/io/index.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-thread-list.css'; import SidebarsModal from '../modals/threads/sidebars/sidebars-modal.react.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +unread: boolean, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { unread, threadInfo } = props; const { pushModal, popModal } = useModalContext(); const onClick = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo.id], ); return (
See more...
); } export default ChatThreadListSeeMoreSidebars; diff --git a/web/chat/inline-engagement.react.js b/web/chat/inline-engagement.react.js index efb1cf75b..061c7af63 100644 --- a/web/chat/inline-engagement.react.js +++ b/web/chat/inline-engagement.react.js @@ -1,114 +1,115 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { getInlineEngagementSidebarText } from 'lib/shared/inline-engagement-utils.js'; import type { MessageInfo } from 'lib/types/message-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './inline-engagement.css'; import ReactionPill from './reaction-pill.react.js'; import CommIcon from '../CommIcon.react.js'; import { useOnClickThread } from '../selectors/thread-selectors.js'; type Props = { +messageInfo: MessageInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +sidebarThreadInfo: ?ThreadInfo, +reactions: ReactionInfo, +positioning: 'left' | 'center' | 'right', +label?: ?string, }; function InlineEngagement(props: Props): React.Node { const { messageInfo, threadInfo, sidebarThreadInfo, reactions, positioning, label, } = props; const { popModal } = useModalContext(); const isLeft = positioning === 'left'; const labelClasses = classNames({ [css.messageLabel]: true, [css.messageLabelLeft]: isLeft, [css.messageLabelRight]: !isLeft, }); const editedLabel = React.useMemo(() => { if (!label) { return null; } return (
{label}
); }, [label, labelClasses]); const onClickSidebarInner = useOnClickThread(sidebarThreadInfo); const onClickSidebar = React.useCallback( (event: SyntheticEvent) => { popModal(); onClickSidebarInner(event); }, [popModal, onClickSidebarInner], ); const repliesText = getInlineEngagementSidebarText(sidebarThreadInfo); const sidebarItem = React.useMemo(() => { if (!sidebarThreadInfo || !repliesText) { return null; } return ( {repliesText} ); }, [sidebarThreadInfo, repliesText, onClickSidebar]); const reactionsList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => ( )); }, [reactions, messageInfo.id, threadInfo.id]); const containerClasses = classNames([ css.inlineEngagementContainer, { [css.leftContainer]: positioning === 'left', [css.centerContainer]: positioning === 'center', [css.rightContainer]: positioning === 'right', }, ]); return (
{editedLabel} {sidebarItem} {reactionsList}
); } export default InlineEngagement; diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js index 5f51b375a..fcd2ee769 100644 --- a/web/chat/message-tooltip.react.js +++ b/web/chat/message-tooltip.react.js @@ -1,231 +1,232 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import classNames from 'classnames'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { useNextLocalID } from 'lib/shared/message-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './chat-constants.js'; import css from './message-tooltip.css'; import { useSendReaction, getEmojiKeyboardPosition, } from './reaction-message-utils.js'; import { useTooltipContext } from './tooltip-provider.js'; import type { MessageTooltipAction, TooltipSize, TooltipPositionStyle, } from '../utils/tooltip-utils.js'; type MessageTooltipProps = { +actions: $ReadOnlyArray, +messageTimestamp: string, +tooltipPositionStyle: TooltipPositionStyle, +tooltipSize: TooltipSize, +item: ChatMessageInfoItem, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { actions, messageTimestamp, tooltipPositionStyle, tooltipSize, item, threadInfo, } = props; const { messageInfo, reactions } = item; const { alignment = 'left' } = tooltipPositionStyle; const [activeTooltipLabel, setActiveTooltipLabel] = React.useState(); const { shouldRenderEmojiKeyboard } = useTooltipContext(); // emoji-mart actually doesn't render its contents until a useEffect runs: // https://github.com/missive/emoji-mart/blob/d29728f7b4e295e46f9b64aa80335aa4a3c15b8e/packages/emoji-mart-react/react.tsx#L13-L19 // We need to measure the width/height of the picker, but because of this we // need to do the measurement in our own useEffect, in order to guarantee it // runs after emoji-mart's useEffect. To do this, we have to define two pieces // of React state: // - emojiKeyboardNode, which will get set by the emoji keyboard's ref and // will trigger our useEffect // - emojiKeyboardRenderedNode, which will get set in that useEffect and will // trigger the rerendering of this component with the correct height/width const [emojiKeyboardNode, setEmojiKeyboardNode] = React.useState(null); const [emojiKeyboardRenderedNode, setEmojiKeyboardRenderedNode] = React.useState(null); React.useEffect(() => { if (emojiKeyboardNode) { // It would be more simple to just call getEmojiKeyboardPosition // immediately here, but some quirk of emoji-mart causes the width of the // node to be 0 here. If instead we wait until the next render of this // component to check the width, it ends up being set correctly. setEmojiKeyboardRenderedNode(emojiKeyboardNode); } }, [emojiKeyboardNode]); const messageActionButtonsContainerClassName = classNames( css.messageActionContainer, css.messageActionButtons, ); const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []); const tooltipButtons = React.useMemo(() => { if (!actions || actions.length === 0) { return null; } const buttons = actions.map(({ label, onClick, actionButtonContent }) => { const onMouseEnter = () => { setActiveTooltipLabel(label); }; const onMouseLeave = () => setActiveTooltipLabel(oldLabel => label === oldLabel ? null : oldLabel, ); return (
{actionButtonContent}
); }); return (
{buttons}
); }, [ actions, messageActionButtonsContainerClassName, messageTooltipButtonStyle, ]); const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []); const messageTooltipTopLabelStyle = React.useMemo( () => ({ height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`, }), [], ); const tooltipLabel = React.useMemo(() => { if (!activeTooltipLabel) { return null; } return (
{activeTooltipLabel}
); }, [activeTooltipLabel, messageTooltipLabelStyle]); const tooltipTimestamp = React.useMemo(() => { if (!messageTimestamp) { return null; } return (
{messageTimestamp}
); }, [messageTimestamp, messageTooltipLabelStyle]); const emojiKeyboardPosition = React.useMemo( () => getEmojiKeyboardPosition( emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize, ), [emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize], ); const emojiKeyboardPositionStyle = React.useMemo(() => { if (!emojiKeyboardPosition) { return null; } return { bottom: emojiKeyboardPosition.bottom, left: emojiKeyboardPosition.left, }; }, [emojiKeyboardPosition]); const localID = useNextLocalID(); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const onEmojiSelect = React.useCallback( emoji => { const reactionInput = emoji.native; sendReaction(reactionInput); }, [sendReaction], ); const emojiKeyboard = React.useMemo(() => { if (!shouldRenderEmojiKeyboard) { return null; } return (
); }, [emojiKeyboardPositionStyle, onEmojiSelect, shouldRenderEmojiKeyboard]); const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []); const containerClassName = classNames({ [css.messageTooltipContainer]: true, [css.leftTooltipAlign]: alignment === 'left', [css.centerTooltipAlign]: alignment === 'center', [css.rightTooltipAlign]: alignment === 'right', }); return ( <> {emojiKeyboard}
{tooltipLabel}
{tooltipButtons} {tooltipTimestamp}
); } export default MessageTooltip; diff --git a/web/chat/relationship-prompt/relationship-prompt.js b/web/chat/relationship-prompt/relationship-prompt.js index 57b502a98..a08c336d7 100644 --- a/web/chat/relationship-prompt/relationship-prompt.js +++ b/web/chat/relationship-prompt/relationship-prompt.js @@ -1,110 +1,111 @@ // @flow import { faUserMinus, faUserPlus, faUserShield, faUserSlash, } from '@fortawesome/free-solid-svg-icons'; import * as React from 'react'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import RelationshipPromptButtonContainer from './relationship-prompt-button-container.js'; import RelationshipPromptButton from './relationship-prompt-button.js'; import { buttonThemes } from '../../components/button.react.js'; -type Props = { +threadInfo: ThreadInfo }; +type Props = { +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo }; function RelationshipPrompt(props: Props) { const { threadInfo } = props; const { otherUserInfo, callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, } = useRelationshipPrompt(threadInfo); if (!otherUserInfo?.username) { return null; } const relationshipStatus = otherUserInfo.relationshipStatus; if (relationshipStatus === userRelationshipStatus.FRIEND) { return null; } else if (relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER) { return ( ); } else if ( relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } else if (relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED) { return ( ); } else if (relationshipStatus === userRelationshipStatus.REQUEST_SENT) { return ( ); } else { return ( ); } } const MemoizedRelationshipPrompt: React.ComponentType = React.memo(RelationshipPrompt); export default MemoizedRelationshipPrompt; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 570c91d03..d7a8aa958 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,163 +1,164 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { entityTextToReact, useENSNamesForEntityText, } from 'lib/utils/entity-text.js'; import InlineEngagement from './inline-engagement.react.js'; import css from './robotext-message.css'; import Markdown from '../markdown/markdown.react.js'; import { linkRules } from '../markdown/rules.react.js'; import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { useMessageTooltip } from '../utils/tooltip-action-utils.js'; import { tooltipPositions } from '../utils/tooltip-utils.js'; const availableTooltipPositionsForRobotext = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, ]; type Props = { +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, }; function RobotextMessage(props: Props): React.Node { let inlineEngagement; const { item, threadInfo } = props; const { threadCreatedFromMessage, reactions } = item; if (threadCreatedFromMessage || Object.keys(reactions).length > 0) { inlineEngagement = (
); } const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); invariant( robotextWithENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderUser: ({ userID, usernameText }) => ( ), // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, threadID]); const { onMouseEnter, onMouseLeave } = useMessageTooltip({ item, threadInfo, availablePositions: availableTooltipPositionsForRobotext, }); return (
{textParts}
{inlineEngagement}
); } type BaseInnerThreadEntityProps = { +id: string, +name: string, }; type InnerThreadEntityProps = { ...BaseInnerThreadEntityProps, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +dispatch: Dispatch, }; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); type UserEntityProps = { +userID: string, +usernameText: string, }; function UserEntity(props: UserEntityProps) { const { userID, usernameText } = props; const pushUserProfileModal = usePushUserProfileModal(userID); return {usernameText}; } function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const MemoizedRobotextMessage: React.ComponentType = React.memo(RobotextMessage); export default MemoizedRobotextMessage; diff --git a/web/invite-links/manage/edit-link-modal.react.js b/web/invite-links/manage/edit-link-modal.react.js index 246adc5af..869b41705 100644 --- a/web/invite-links/manage/edit-link-modal.react.js +++ b/web/invite-links/manage/edit-link-modal.react.js @@ -1,118 +1,119 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { inviteLinkURL } from 'lib/facts/links.js'; import { useInviteLinksActions } from 'lib/hooks/invite-links.js'; import { defaultErrorMessage, inviteLinkErrorMessages, } from 'lib/shared/invite-links.js'; import type { InviteLink } from 'lib/types/link-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './manage-invite-links-modal.css'; import Button from '../../components/button.react.js'; import Input from '../../modals/input.react.js'; import Modal from '../../modals/modal.react.js'; type Props = { +inviteLink: ?InviteLink, +enterViewMode: () => mixed, +enterDisableMode: () => mixed, - +community: ThreadInfo, + +community: ThreadInfo | MinimallyEncodedThreadInfo, }; const disableButtonColor = { color: 'var(--error-primary)', borderColor: 'var(--error-primary)', }; function EditLinkModal(props: Props): React.Node { const { inviteLink, enterViewMode, enterDisableMode, community } = props; const { popModal } = useModalContext(); const { error, isLoading, name, setName, createOrUpdateInviteLink } = useInviteLinksActions(community.id, inviteLink); const onChangeName = React.useCallback( (event: SyntheticEvent) => { setName(event.currentTarget.value); }, [setName], ); let errorComponent = null; if (error) { errorComponent = (
{inviteLinkErrorMessages[error] ?? defaultErrorMessage}
); } let disableLinkComponent = null; if (inviteLink) { disableLinkComponent = ( <>
You may also disable the community public link
); } return (

Invite links make it easy for your friends to join your community. Anybody who knows your community’s invite link will be able to join it.

Note that if you change your public link’s URL, other communities will be able to claim the old URL.


Invite URL
{inviteLinkURL('')}
{errorComponent}
{disableLinkComponent}
); } export default EditLinkModal; diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index aca72b4cd..55b4fa3d9 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,248 +1,254 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; import { chatMentionRegex } from 'lib/shared/mention-utils.js'; +import type { + MinimallyEncodedRelativeMemberInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelativeMemberInfo, ThreadInfo, ChatMentionCandidates, } from 'lib/types/thread-types.js'; import MarkdownChatMention from './markdown-chat-mention.react.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; import MarkdownUserMention from './markdown-user-mention.react.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +useDarkStyle: boolean, }; const linkRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, useDarkStyle, }; }); const markdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const linkMarkdownRules = linkRules(useDarkStyle); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote(SharedMarkdown.blockQuoteRegex), parse: SharedMarkdown.parseBlockQuote, }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, useDarkStyle, }; }); function useTextMessageRulesFunc( - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, chatMentionCandidates: ChatMentionCandidates, ): boolean => MarkdownRules { const { members } = threadInfo; return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(members, chatMentionCandidates, useDarkStyle), ), [chatMentionCandidates, members], ); } function textMessageRules( - members: $ReadOnlyArray, + members: $ReadOnlyArray< + RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + >, chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, ): MarkdownRules { const baseRules = markdownRules(useDarkStyle); const membersMap = SharedMarkdown.createMemberMapForUserMentions(members); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, userMention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchUserMentions(membersMap), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseUserMentions(membersMap, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, chatMention: { ...SimpleMarkdown.defaultRules.strong, match: SimpleMarkdown.inlineRegex(chatMentionRegex), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseChatMention(chatMentionCandidates, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, }, }; } let defaultTextMessageRules = null; function getDefaultTextMessageRules( overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, ): MarkdownRules { if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { return textMessageRules([], overrideDefaultChatMentionCandidates, false); } if (!defaultTextMessageRules) { defaultTextMessageRules = textMessageRules([], {}, false); } return defaultTextMessageRules; } export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules }; diff --git a/web/modals/search/message-search-utils.react.js b/web/modals/search/message-search-utils.react.js index 5498e3480..00b54fa5a 100644 --- a/web/modals/search/message-search-utils.react.js +++ b/web/modals/search/message-search-utils.react.js @@ -1,51 +1,52 @@ // @flow import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, modifyItemForResultScreen, } from 'lib/shared/message-utils.js'; import { filterChatMessageInfosForSearch } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useSelector } from '../../redux/redux-utils.js'; function useParseSearchResults( - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, searchResults: $ReadOnlyArray, ): $ReadOnlyArray { const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo( () => filterChatMessageInfosForSearch( chatMessageInfos, translatedSearchResults, ) ?? [], [chatMessageInfos, translatedSearchResults], ); return React.useMemo( () => filteredChatMessageInfos.map(item => modifyItemForResultScreen(item)), [filteredChatMessageInfos], ); } export { useParseSearchResults }; diff --git a/web/modals/threads/gallery/thread-settings-media-gallery.react.js b/web/modals/threads/gallery/thread-settings-media-gallery.react.js index 6db52ff84..64c319e5c 100644 --- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js +++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js @@ -1,190 +1,191 @@ // @flow import * as React from 'react'; import { useFetchThreadMedia } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { encryptedMediaBlobURI, encryptedVideoThumbnailBlobURI, } from 'lib/media/media-utils.js'; import type { Media } from 'lib/types/media-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import GalleryItem from './thread-settings-media-gallery-item.react.js'; import css from './thread-settings-media-gallery.css'; import Tabs from '../../../components/tabs.react.js'; import MultimediaModal from '../../../media/multimedia-modal.react.js'; import Modal from '../../modal.react.js'; type MediaGalleryTab = 'All' | 'Images' | 'Videos'; type ThreadSettingsMediaGalleryModalProps = { +onClose: () => void, - +parentThreadInfo: ThreadInfo, + +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +limit: number, +activeTab: MediaGalleryTab, }; function ThreadSettingsMediaGalleryModal( props: ThreadSettingsMediaGalleryModalProps, ): React.Node { const { pushModal } = useModalContext(); const { onClose, parentThreadInfo, limit, activeTab } = props; const { id: threadID } = parentThreadInfo; const modalName = 'Media'; const callFetchThreadMedia = useFetchThreadMedia(); const [mediaInfos, setMediaInfos] = React.useState([]); const [tab, setTab] = React.useState(activeTab); React.useEffect(() => { const fetchData = async () => { const result = await callFetchThreadMedia({ threadID, limit, offset: 0, }); setMediaInfos(result.media); }; fetchData(); }, [callFetchThreadMedia, threadID, limit]); const onClick = React.useCallback( (media: Media) => { const thumbHash = media.thumbnailThumbHash ?? media.thumbHash; let mediaInfo = { thumbHash, dimensions: media.dimensions, }; if (media.type === 'photo' || media.type === 'video') { const { uri, thumbnailURI } = media; mediaInfo = { ...mediaInfo, type: media.type, uri, thumbnailURI, }; } else { const { encryptionKey, thumbnailEncryptionKey } = media; const thumbnailBlobURI = media.type === 'encrypted_video' ? encryptedVideoThumbnailBlobURI(media) : null; mediaInfo = { ...mediaInfo, type: media.type, blobURI: encryptedMediaBlobURI(media), encryptionKey, thumbnailBlobURI, thumbnailEncryptionKey, }; } pushModal(); }, [pushModal], ); const mediaGalleryItems = React.useMemo(() => { let filteredMediaInfos = mediaInfos; if (tab === 'Images') { filteredMediaInfos = mediaInfos.filter( mediaInfo => mediaInfo.type === 'photo' || mediaInfo.type === 'encrypted_photo', ); } else if (tab === 'Videos') { filteredMediaInfos = mediaInfos.filter( mediaInfo => mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video', ); } return filteredMediaInfos.map((media, i) => { let imageSource; if (media.type === 'photo') { imageSource = { kind: 'plain', uri: media.uri, thumbHash: media.thumbHash, }; } else if (media.type === 'video') { imageSource = { kind: 'plain', uri: media.thumbnailURI, thumbHash: media.thumbnailThumbHash, }; } else if (media.type === 'encrypted_photo') { imageSource = { kind: 'encrypted', blobURI: encryptedMediaBlobURI(media), encryptionKey: media.encryptionKey, thumbHash: media.thumbHash, }; } else { imageSource = { kind: 'encrypted', blobURI: encryptedVideoThumbnailBlobURI(media), encryptionKey: media.thumbnailEncryptionKey, thumbHash: media.thumbnailThumbHash, }; } return ( onClick(media)} imageSource={imageSource} imageCSSClass={css.media} imageContainerCSSClass={css.mediaContainer} /> ); }); }, [tab, mediaInfos, onClick]); const handleScroll = React.useCallback( async event => { const container = event.target; // Load more data when the user is within 1000 pixels of the end const buffer = 1000; if ( container.scrollHeight - container.scrollTop > container.clientHeight + buffer ) { return; } const result = await callFetchThreadMedia({ threadID, limit, offset: mediaInfos.length, }); setMediaInfos([...mediaInfos, ...result.media]); }, [callFetchThreadMedia, threadID, limit, mediaInfos], ); return (
{mediaGalleryItems}
{mediaGalleryItems}
{mediaGalleryItems}
); } export default ThreadSettingsMediaGalleryModal; diff --git a/web/modals/threads/members/change-member-role-modal.react.js b/web/modals/threads/members/change-member-role-modal.react.js index aeee7020a..a907ee2b5 100644 --- a/web/modals/threads/members/change-member-role-modal.react.js +++ b/web/modals/threads/members/change-member-role-modal.react.js @@ -1,153 +1,154 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useChangeThreadMemberRoles, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js'; import { roleIsAdminRole } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { values } from 'lib/utils/objects.js'; import css from './change-member-role-modal.css'; import UserAvatar from '../../../avatars/user-avatar.react.js'; import Button, { buttonThemes } from '../../../components/button.react.js'; import Dropdown from '../../../components/dropdown.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; import Modal from '../../modal.react.js'; import UnsavedChangesModal from '../../unsaved-changes-modal.react.js'; type ChangeMemberRoleModalProps = { +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; function ChangeMemberRoleModal(props: ChangeMemberRoleModalProps): React.Node { const { memberInfo, threadInfo } = props; const { pushModal, popModal } = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadMemberRoles = useChangeThreadMemberRoles(); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(threadInfo.id), ); const roleOptions = React.useMemo( () => values(threadInfo.roles).map(role => ({ id: role.id, name: role.name, })), [threadInfo.roles], ); const initialSelectedRole = memberInfo.role; invariant(initialSelectedRole, "Member's role must be defined"); const [selectedRole, setSelectedRole] = React.useState(initialSelectedRole); const onCloseModal = React.useCallback(() => { if (selectedRole === initialSelectedRole) { popModal(); return; } pushModal(); }, [initialSelectedRole, popModal, pushModal, selectedRole]); const disabledRoleChangeMessage = React.useMemo(() => { const memberIsAdmin = roleIsAdminRole( threadInfo.roles[initialSelectedRole], ); if (!otherUsersButNoOtherAdminsValue || !memberIsAdmin) { return null; } return (
There must be at least one admin at any given time in a community.
); }, [initialSelectedRole, otherUsersButNoOtherAdminsValue, threadInfo.roles]); const onSave = React.useCallback(() => { if (selectedRole === initialSelectedRole) { popModal(); return; } const createChangeThreadMemberRolesPromise = () => callChangeThreadMemberRoles({ threadID: threadInfo.id, memberIDs: [memberInfo.id], newRole: selectedRole, }); dispatchActionPromise( changeThreadMemberRolesActionTypes, createChangeThreadMemberRolesPromise(), ); popModal(); }, [ callChangeThreadMemberRoles, dispatchActionPromise, initialSelectedRole, memberInfo.id, popModal, selectedRole, threadInfo.id, ]); return (
Members can only be assigned to one role at a time. Changing a member’s role will replace their previously assigned role.
{memberInfo.username}
{disabledRoleChangeMessage}
); } export default ChangeMemberRoleModal; diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js index a9fba2b99..97210c7fb 100644 --- a/web/modals/threads/members/member.react.js +++ b/web/modals/threads/members/member.react.js @@ -1,138 +1,139 @@ // @flow import * as React from 'react'; import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { removeMemberFromThread, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type RelativeMemberInfo, type ThreadInfo, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import ChangeMemberRoleModal from './change-member-role-modal.react.js'; import css from './members-modal.css'; import UserAvatar from '../../../avatars/user-avatar.react.js'; import CommIcon from '../../../CommIcon.react.js'; import Label from '../../../components/label.react.js'; import MenuItem from '../../../components/menu-item.react.js'; import Menu from '../../../components/menu.react.js'; import { usePushUserProfileModal } from '../../user-profile/user-profile-utils.js'; const commIconComponent = ; type Props = { +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +setOpenMenu: SetState, }; function ThreadMember(props: Props): React.Node { const { memberInfo, threadInfo, setOpenMenu } = props; const { pushModal } = useModalContext(); const userName = stringForUser(memberInfo); const roles = useRolesFromCommunityThreadInfo(threadInfo, [memberInfo]); const roleName = roles.get(memberInfo.id)?.name; const onMenuChange = React.useCallback( menuOpen => { if (menuOpen) { setOpenMenu(() => memberInfo.id); } else { setOpenMenu(menu => (menu === memberInfo.id ? null : menu)); } }, [memberInfo.id, setOpenMenu], ); const dispatchActionPromise = useDispatchActionPromise(); const boundRemoveUsersFromThread = useRemoveUsersFromThread(); const onClickRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo], ); const onClickChangeRole = React.useCallback(() => { pushModal( , ); }, [memberInfo, pushModal, threadInfo]); const menuItems = React.useMemo( () => getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => { if (action === 'change_role') { return ( ); } if (action === 'remove_user') { return ( ); } return null; }), [memberInfo, onClickRemoveUser, onClickChangeRole, threadInfo], ); const userSettingsIcon = React.useMemo( () => , [], ); const label = React.useMemo( () => , [roleName], ); const pushUserProfileModal = usePushUserProfileModal(memberInfo.id); return (
{userName} {label}
{menuItems}
); } export default ThreadMember; diff --git a/web/modals/threads/members/members-list.react.js b/web/modals/threads/members/members-list.react.js index e18ec50a0..760c122dd 100644 --- a/web/modals/threads/members/members-list.react.js +++ b/web/modals/threads/members/members-list.react.js @@ -1,81 +1,82 @@ // @flow import classNames from 'classnames'; import _groupBy from 'lodash/fp/groupBy.js'; import _toPairs from 'lodash/fp/toPairs.js'; import * as React from 'react'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { stringForUser } from 'lib/shared/user-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import ThreadMember from './member.react.js'; import css from './members-modal.css'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +threadMembers: $ReadOnlyArray, }; function ThreadMembersList(props: Props): React.Node { const { threadMembers, threadInfo } = props; const [openMenu, setOpenMenu] = React.useState(null); const hasMembers = threadMembers.length > 0; const threadMembersWithENSNames = useENSNames(threadMembers); const groupedByFirstLetterMembers = React.useMemo( () => _groupBy(member => stringForUser(member)[0].toLowerCase())( threadMembersWithENSNames, ), [threadMembersWithENSNames], ); const groupedMembersList = React.useMemo( () => _toPairs(groupedByFirstLetterMembers) .sort((a, b) => a[0].localeCompare(b[0])) .map(([letter, users]) => { const userList = users .sort((a, b) => stringForUser(a).localeCompare(stringForUser(b))) .map((user: RelativeMemberInfo) => ( )); const letterHeader = (
{letter.toUpperCase()}
); return ( {letterHeader} {userList} ); }), [groupedByFirstLetterMembers, threadInfo], ); let content = groupedMembersList; if (!hasMembers) { content = (
No matching users were found in the chat!
); } const membersListClasses = classNames(css.membersList, { [css.noScroll]: !!openMenu, }); return
{content}
; } export default ThreadMembersList; diff --git a/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js b/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js index 8429734f4..ef2f544c6 100644 --- a/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js +++ b/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js @@ -1,54 +1,55 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { getThreadsToDeleteText } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types'; import css from './thread-settings-delete-confirmation-modal.css'; import Button from '../../../components/button.react.js'; import Modal from '../../modal.react.js'; type BaseProps = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +onConfirmation: () => mixed, }; function ThreadDeleteConfirmationModal({ threadInfo, onConfirmation, }: BaseProps): React.Node { const { popModal } = useModalContext(); const threadsToDeleteText = React.useMemo( () => getThreadsToDeleteText(threadInfo), [threadInfo], ); return (

{threadsToDeleteText} will also be permanently deleted. Are you sure you want to continue?

); } export default ThreadDeleteConfirmationModal; diff --git a/web/modals/threads/settings/thread-settings-delete-tab.react.js b/web/modals/threads/settings/thread-settings-delete-tab.react.js index 6ab3a1f23..fbbf3bbd7 100644 --- a/web/modals/threads/settings/thread-settings-delete-tab.react.js +++ b/web/modals/threads/settings/thread-settings-delete-tab.react.js @@ -1,131 +1,132 @@ // @flow import * as React from 'react'; import { deleteThreadActionTypes, useDeleteThread, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { containedThreadInfos } from 'lib/selectors/thread-selectors.js'; import { type SetState } from 'lib/types/hook-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import SubmitSection from './submit-section.react.js'; import ThreadDeleteConfirmationModal from './thread-settings-delete-confirmation-modal.react.js'; import css from './thread-settings-delete-tab.css'; import { buttonThemes } from '../../../components/button.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; type ThreadSettingsDeleteTabProps = { +threadSettingsOperationInProgress: boolean, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsDeleteTab( props: ThreadSettingsDeleteTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, setErrorMessage, errorMessage, } = props; const modalContext = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useDeleteThread(); const containedThreads = useSelector( state => containedThreadInfos(state)[threadInfo.id], ); const shouldUseDeleteConfirmationModal = React.useMemo( () => containedThreads?.length > 0, [containedThreads?.length], ); const popThreadDeleteConfirmationModal = React.useCallback(() => { if (shouldUseDeleteConfirmationModal) { modalContext.popModal(); } }, [modalContext, shouldUseDeleteConfirmationModal]); const deleteThreadAction = React.useCallback(async () => { try { setErrorMessage(''); const response = await callDeleteThread({ threadID: threadInfo.id }); popThreadDeleteConfirmationModal(); modalContext.popModal(); return response; } catch (e) { popThreadDeleteConfirmationModal(); setErrorMessage( e.message === 'invalid_credentials' ? 'permission not granted' : 'unknown error', ); throw e; } }, [ callDeleteThread, modalContext, popThreadDeleteConfirmationModal, setErrorMessage, threadInfo.id, ]); const dispatchDeleteThreadAction = React.useCallback(() => { dispatchActionPromise(deleteThreadActionTypes, deleteThreadAction()); }, [dispatchActionPromise, deleteThreadAction]); const onDelete = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (shouldUseDeleteConfirmationModal) { modalContext.pushModal( , ); } else { dispatchDeleteThreadAction(); } }, [ dispatchDeleteThreadAction, modalContext, shouldUseDeleteConfirmationModal, threadInfo, ], ); return (

Your chat will be permanently deleted. There is no way to reverse this.

Delete
); } export default ThreadSettingsDeleteTab; diff --git a/web/modals/threads/settings/thread-settings-general-tab.react.js b/web/modals/threads/settings/thread-settings-general-tab.react.js index bce0a2c44..63785fd5f 100644 --- a/web/modals/threads/settings/thread-settings-general-tab.react.js +++ b/web/modals/threads/settings/thread-settings-general-tab.react.js @@ -1,199 +1,200 @@ // @flow import * as React from 'react'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import { type SetState } from 'lib/types/hook-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ThreadChanges } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { chatNameMaxLength } from 'lib/utils/validation-utils.js'; import SubmitSection from './submit-section.react.js'; import css from './thread-settings-general-tab.css'; import EditThreadAvatar from '../../../avatars/edit-thread-avatar.react.js'; import LoadingIndicator from '../../../loading-indicator.react.js'; import Input from '../../input.react.js'; import ColorSelector from '../color-selector.react.js'; type ThreadSettingsGeneralTabProps = { +threadSettingsOperationInProgress: boolean, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +threadNamePlaceholder: string, +queuedChanges: ThreadChanges, +setQueuedChanges: SetState, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsGeneralTab( props: ThreadSettingsGeneralTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, threadNamePlaceholder, queuedChanges, setQueuedChanges, setErrorMessage, errorMessage, } = props; const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const nameInputRef = React.useRef(); React.useEffect(() => { nameInputRef.current?.focus(); }, [threadSettingsOperationInProgress]); const changeQueued: boolean = React.useMemo( () => Object.values(queuedChanges).some(v => v !== null && v !== undefined), [queuedChanges], ); const onChangeName = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; const newName = firstLine(target.value); setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, name: newName !== threadInfo.name ? newName : undefined, }), ); }, [setQueuedChanges, threadInfo.name], ); const onChangeDescription = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, description: target.value !== threadInfo.description ? target.value : undefined, }), ); }, [setQueuedChanges, threadInfo.description], ); const onChangeColor = React.useCallback( (color: string) => { setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, color: !tinycolor.equals(color, threadInfo.color) ? color : undefined, }), ); }, [setQueuedChanges, threadInfo.color], ); const changeThreadSettingsAction = React.useCallback(async () => { try { setErrorMessage(''); return await callChangeThreadSettings({ threadID: threadInfo.id, changes: queuedChanges, }); } catch (e) { setErrorMessage('unknown_error'); throw e; } finally { setQueuedChanges(Object.freeze({})); } }, [ callChangeThreadSettings, queuedChanges, setErrorMessage, setQueuedChanges, threadInfo.id, ]); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatchActionPromise( changeThreadSettingsActionTypes, changeThreadSettingsAction(), ); }, [changeThreadSettingsAction, dispatchActionPromise], ); const threadNameInputDisabled = !threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const saveButtonContent = React.useMemo(() => { if (threadSettingsOperationInProgress) { return ; } return 'Save'; }, [threadSettingsOperationInProgress]); return (
Chat name
Description