diff --git a/.vscode/settings.json b/.vscode/settings.json index 3fb8fa0aeba02a50491d9dbcfc9a1c0723a43b06..fae5881f7419cc42d649821bc24cfcc1e480b741 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,7 +16,8 @@ "typescript.tsdk": "./node_modules/typescript/lib", "cSpell.words": [ "autotranslate", - "fname", + "fname", + "Gazzodown", "katex", "listbox", "livechat", @@ -25,6 +26,6 @@ "photoswipe", "searchbox", "tmid", - "tshow" + "tshow" ] } diff --git a/apps/meteor/app/ui-message/client/ActionManager.js b/apps/meteor/app/ui-message/client/ActionManager.js index a3387693955b18e640ec6c3e66b560d7bfa47943..2b5c4926163f27be876c52520c4b85aaebf6d851 100644 --- a/apps/meteor/app/ui-message/client/ActionManager.js +++ b/apps/meteor/app/ui-message/client/ActionManager.js @@ -11,7 +11,7 @@ import { APIClient, t } from '../../utils/client'; import * as banners from '../../../client/lib/banners'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { imperativeModal } from '../../../client/lib/imperativeModal'; -import ConnectedModalBlock from '../../../client/views/blocks/ConnectedModalBlock'; +import UiKitModal from '../../../client/views/modal/uikit/UiKitModal'; const events = new Emitter(); @@ -90,7 +90,7 @@ const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) if ([UIKitInteractionTypes.MODAL_OPEN].includes(type)) { const instance = imperativeModal.open({ - component: ConnectedModalBlock, + component: UiKitModal, props: { triggerId, viewId, diff --git a/apps/meteor/client/components/message/content/UiKitSurface.tsx b/apps/meteor/client/components/message/content/UiKitSurface.tsx index 48d49ca221705d8d01aa6b2cd39d671db49a9993..6dfc921bee8e56b2acdd358d77022af887f571cd 100644 --- a/apps/meteor/client/components/message/content/UiKitSurface.tsx +++ b/apps/meteor/client/components/message/content/UiKitSurface.tsx @@ -2,7 +2,7 @@ import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { MessageBlock } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { UiKitComponent, UiKitMessage, kitContext, messageParser } from '@rocket.chat/fuselage-ui-kit'; +import { UiKitComponent, UiKitMessage, kitContext } from '@rocket.chat/fuselage-ui-kit'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; import type { ContextType, ReactElement } from 'react'; import React from 'react'; @@ -17,7 +17,6 @@ import { useVideoConfSetPreferences, } from '../../../contexts/VideoConfContext'; import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; -import ParsedText from './uikit/ParsedText'; let patched = false; const patchMessageParser = () => { @@ -26,18 +25,6 @@ const patchMessageParser = () => { } patched = true; - - // TODO: move this to fuselage-ui-kit itself - messageParser.text = ({ text, type }) => { - if (type !== 'mrkdwn') { - return <>{text}</>; - } - - return <ParsedText text={text} />; - }; - - // TODO: move this to fuselage-ui-kit itself - messageParser.mrkdwn = ({ text }) => <ParsedText text={text} />; }; type UiKitSurfaceProps = { diff --git a/apps/meteor/client/components/message/content/uikit/ParsedText.tsx b/apps/meteor/client/components/message/content/uikit/ParsedText.tsx deleted file mode 100644 index e14a586e11e42d7d074a9c6aa90ed35ea0e7b767..0000000000000000000000000000000000000000 --- a/apps/meteor/client/components/message/content/uikit/ParsedText.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import type { Options } from '@rocket.chat/message-parser'; -import { parse } from '@rocket.chat/message-parser'; -import React, { memo, useMemo } from 'react'; - -import GazzodownText from '../../../GazzodownText'; - -type ParsedTextProps = { - text: string; - mentions?: { - type: 'user' | 'team'; - _id: string; - username?: string; - name?: string; - }[]; - channels?: Pick<IRoom, '_id' | 'name'>[]; - searchText?: string; -}; - -const ParsedText = ({ text, mentions, channels, searchText }: ParsedTextProps) => { - const tokens = useMemo(() => { - if (!text) { - return undefined; - } - - const parseOptions: Options = { - emoticons: true, - }; - - return parse(text, parseOptions); - }, [text]); - - if (!tokens) { - return null; - } - - return <GazzodownText tokens={tokens} mentions={mentions} channels={channels} searchText={searchText} />; -}; - -export default memo(ParsedText); diff --git a/apps/meteor/client/lib/normalizeThreadMessage.tsx b/apps/meteor/client/lib/normalizeThreadMessage.tsx index af8a3921c8ee5edaada2b8894902afb8f6224412..70be7cd49b2e62f34e6ae6e69aa19d8904081da0 100644 --- a/apps/meteor/client/lib/normalizeThreadMessage.tsx +++ b/apps/meteor/client/lib/normalizeThreadMessage.tsx @@ -1,15 +1,23 @@ import type { IMessage } from '@rocket.chat/core-typings'; +import { parse } from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; import React from 'react'; import { filterMarkdown } from '../../app/markdown/lib/markdown'; -import ParsedText from '../components/message/content/uikit/ParsedText'; +import GazzodownText from '../components/GazzodownText'; export function normalizeThreadMessage({ ...message }: Readonly<Pick<IMessage, 'msg' | 'mentions' | 'attachments'>>): ReactElement | null { if (message.msg) { message.msg = filterMarkdown(message.msg); delete message.mentions; - return <ParsedText text={message.msg} />; + + const tokens = message.msg ? parse(message.msg, { emoticons: true }) : undefined; + + if (!tokens) { + return null; + } + + return <GazzodownText tokens={tokens} />; } if (message.attachments) { diff --git a/apps/meteor/client/views/blocks/ConnectedModalBlock.js b/apps/meteor/client/views/blocks/ConnectedModalBlock.js deleted file mode 100644 index 03c4f4b786ba9f3496788aae78102048c2826f3e..0000000000000000000000000000000000000000 --- a/apps/meteor/client/views/blocks/ConnectedModalBlock.js +++ /dev/null @@ -1,196 +0,0 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { kitContext } from '@rocket.chat/fuselage-ui-kit'; -import React, { useEffect, useReducer, useState } from 'react'; - -import * as ActionManager from '../../../app/ui-message/client/ActionManager'; -import ModalBlock from './ModalBlock'; -import './textParsers'; - -const useActionManagerState = (initialState) => { - const [state, setState] = useState(initialState); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ type, ...data }) => { - if (type === 'errors') { - const { errors } = data; - setState((state) => ({ ...state, errors })); - return; - } - - setState(data); - }; - - ActionManager.on(viewId, handleUpdate); - - return () => { - ActionManager.off(viewId, handleUpdate); - }; - }, [viewId]); - - return state; -}; - -const useValues = (view) => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback(() => { - const filterInputFields = ({ element, elements = [] }) => { - if (element && element.initialValue) { - return true; - } - - if (elements.length && elements.map((element) => ({ element })).filter(filterInputFields).length) { - return true; - } - }; - - const mapElementToState = ({ element, blockId, elements = [] }) => { - if (elements.length) { - return elements - .map((element) => ({ element, blockId })) - .filter(filterInputFields) - .map(mapElementToState); - } - return [element.actionId, { value: element.initialValue, blockId }]; - }; - - return view.blocks - .filter(filterInputFields) - .map(mapElementToState) - .reduce((obj, el) => { - if (Array.isArray(el[0])) { - return { ...obj, ...Object.fromEntries(el) }; - } - - const [key, value] = el; - return { ...obj, [key]: value }; - }, {}); - }); - - return useReducer(reducer, null, initializer); -}; - -function ConnectedModalBlock(props) { - const state = useActionManagerState(props); - - const { appId, viewId, mid: _mid, errors, view } = state; - - const [values, updateValues] = useValues(view); - - const groupStateByBlockId = (obj) => - Object.entries(obj).reduce((obj, [key, { blockId, value }]) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; - return obj; - }, {}); - - const prevent = (e) => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; - - const debouncedBlockAction = useDebouncedCallback((actionId, appId, value, blockId, mid) => { - ActionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - mid, - }); - }, 700); - - const context = { - action: ({ actionId, appId, value, blockId, mid = _mid, dispatchActionConfig }) => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes('on_character_entered')) { - debouncedBlockAction(actionId, appId, value, blockId, mid); - } else { - ActionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - mid, - }); - } - }, - - state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { - updateValues({ - actionId, - payload: { - blockId, - value, - }, - }); - }, - ...state, - values, - }; - - const handleSubmit = useMutableCallback((e) => { - prevent(e); - ActionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }, - }); - }); - - const handleCancel = useMutableCallback((e) => { - prevent(e); - return ActionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); - }); - - const handleClose = useMutableCallback((e) => { - prevent(e); - return ActionManager.triggerCancel({ - appId, - viewId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); - }); - - return ( - <kitContext.Provider value={context}> - <ModalBlock view={view} errors={errors} appId={appId} onSubmit={handleSubmit} onCancel={handleCancel} onClose={handleClose} /> - </kitContext.Provider> - ); -} - -export default ConnectedModalBlock; diff --git a/apps/meteor/client/views/blocks/textParsers.js b/apps/meteor/client/views/blocks/textParsers.js deleted file mode 100644 index f75f076759956aba67f97d291537d5b2a0149f4a..0000000000000000000000000000000000000000 --- a/apps/meteor/client/views/blocks/textParsers.js +++ /dev/null @@ -1,10 +0,0 @@ -import { modalParser } from '@rocket.chat/fuselage-ui-kit'; -import React from 'react'; - -import ParsedText from '../../components/message/content/uikit/ParsedText'; - -// TODO: move this to fuselage-ui-kit itself -modalParser.plainText = ({ text } = {}) => text; - -// TODO: move this to fuselage-ui-kit itself -modalParser.mrkdwn = ({ text }) => <ParsedText text={text} />; diff --git a/apps/meteor/client/views/blocks/ModalBlock.js b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx similarity index 55% rename from apps/meteor/client/views/blocks/ModalBlock.js rename to apps/meteor/client/views/modal/uikit/ModalBlock.tsx index 9c1cf0a5cc93698528c79825b57bbcdb7f4c224c..fa34cb11ad2a2f5d7a6cf0ac4486933ddac93d5e 100644 --- a/apps/meteor/client/views/blocks/ModalBlock.js +++ b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx @@ -1,12 +1,14 @@ +import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; import { Modal, AnimatedVisibility, Button, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; +import type { FormEventHandler, ReactElement } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { FocusScope } from 'react-aria'; -import { getURL } from '../../../app/utils/lib/getURL'; +import { getURL } from '../../../../app/utils/lib/getURL'; import { getButtonStyle } from './getButtonStyle'; -import './textParsers'; const focusableElementsString = ` a[href]:not([tabindex="-1"]), @@ -34,79 +36,101 @@ const focusableElementsStringInvalid = ` [tabindex]:not([tabindex="-1"]):invalid, [contenteditable]:invalid`; -function ModalBlock({ view, errors, appId, onSubmit, onClose, onCancel }) { +type ModalBlockParams = { + view: IUIKitSurface & { showIcon?: boolean }; + errors: any; + appId: string; + onSubmit: FormEventHandler<HTMLElement>; + onClose: () => void; + onCancel: FormEventHandler<HTMLElement>; +}; + +const isFocusable = (element: Element | null): element is HTMLElement => + element !== null && 'focus' in element && typeof element.focus === 'function'; + +const KeyboardCode = new Map<string, number>([ + ['ENTER', 13], + ['ESC', 27], + ['TAB', 9], +]); + +const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { const id = `modal_id_${useUniqueId()}`; - const ref = useRef(); + const ref = useRef<HTMLElement>(null); - // Auto focus useEffect(() => { if (!ref.current) { return; } if (errors && Object.keys(errors).length) { - const element = ref.current.querySelector(focusableElementsStringInvalid); - element && element.focus(); + const element = ref.current.querySelector<HTMLElement>(focusableElementsStringInvalid); + element?.focus(); } else { - const element = ref.current.querySelector(focusableElementsString); - element && element.focus(); + const element = ref.current.querySelector<HTMLElement>(focusableElementsString); + element?.focus(); } }, [errors]); - // save focus to restore after close + const previousFocus = useMemo(() => document.activeElement, []); - // restore the focus after the component unmount - useEffect(() => () => previousFocus && previousFocus.focus(), [previousFocus]); - // Handle Tab, Shift + Tab, Enter and Escape - const handleKeyDown = useCallback( - (event) => { - if (event.keyCode === 13) { - // ENTER - if (event?.target?.nodeName !== 'TEXTAREA') { - return onSubmit(event); - } - } - if (event.keyCode === 27) { - // ESC - event.stopPropagation(); - event.preventDefault(); - onClose(); - return false; + useEffect( + () => () => { + if (previousFocus && isFocusable(previousFocus)) { + return previousFocus.focus(); } + }, + [previousFocus], + ); - if (event.keyCode === 9) { - // TAB - const elements = Array.from(ref.current.querySelectorAll(focusableElementsString)); - const [first] = elements; - const last = elements.pop(); + const handleKeyDown = useCallback( + (event) => { + switch (event.keyCode) { + case KeyboardCode.get('ENTER'): + if (event?.target?.nodeName !== 'TEXTAREA') { + return onSubmit(event); + } + return; + case KeyboardCode.get('ESC'): + event.stopPropagation(); + event.preventDefault(); + onClose(); + return; + case KeyboardCode.get('TAB'): + if (!ref.current) { + return; + } + const elements = Array.from(ref.current.querySelectorAll(focusableElementsString)) as HTMLElement[]; + const [first] = elements; + const last = elements.pop(); - if (!ref.current.contains(document.activeElement)) { - return first.focus(); - } + if (!ref.current.contains(document.activeElement)) { + return first.focus(); + } - if (event.shiftKey) { - if (!first || first === document.activeElement) { - last.focus(); + if (event.shiftKey) { + if (!first || first === document.activeElement) { + last?.focus(); + event.stopPropagation(); + event.preventDefault(); + } + return; + } + + if (!last || last === document.activeElement) { + first.focus(); event.stopPropagation(); event.preventDefault(); } - return; - } - - if (!last || last === document.activeElement) { - first.focus(); - event.stopPropagation(); - event.preventDefault(); - } } }, [onClose, onSubmit], ); - // Clean the events + useEffect(() => { - const element = document.querySelector('#modal-root'); - const container = element.querySelector('.rcx-modal__content'); - const close = (e) => { + const element = document.querySelector('#modal-root') as HTMLElement; + const container = element.querySelector('.rcx-modal__content') as HTMLElement; + const close = (e: Event) => { if (e.target !== element) { return; } @@ -116,17 +140,21 @@ function ModalBlock({ view, errors, appId, onSubmit, onClose, onCancel }) { return false; }; - const ignoreIfnotContains = (e) => { - if (!container.contains(e.target)) { + const ignoreIfNotContains = (e: Event) => { + if (e.target !== element) { + return; + } + + if (!container.contains(e.target as HTMLElement)) { return; } return handleKeyDown(e); }; - document.addEventListener('keydown', ignoreIfnotContains); + document.addEventListener('keydown', ignoreIfNotContains); element.addEventListener('click', close); return () => { - document.removeEventListener('keydown', ignoreIfnotContains); + document.removeEventListener('keydown', ignoreIfNotContains); element.removeEventListener('click', close); }; }, [handleKeyDown, onClose]); @@ -142,7 +170,7 @@ function ModalBlock({ view, errors, appId, onSubmit, onClose, onCancel }) { </Modal.Header> <Modal.Content> <Box is='form' method='post' action='#' onSubmit={onSubmit}> - <UiKitComponent render={UiKitModal} blocks={view.blocks} /> + <UiKitComponent render={UiKitModal} blocks={view.blocks as LayoutBlock[]} /> </Box> </Modal.Content> <Modal.Footer> @@ -163,7 +191,6 @@ function ModalBlock({ view, errors, appId, onSubmit, onClose, onCancel }) { </FocusScope> </AnimatedVisibility> ); -} +}; export default ModalBlock; -export { modalParser }; diff --git a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6bdefce4ff9e345e85a02e67c6f79e800ff1fc07 --- /dev/null +++ b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx @@ -0,0 +1,141 @@ +import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { kitContext } from '@rocket.chat/fuselage-ui-kit'; +import { MarkupInteractionContext } from '@rocket.chat/gazzodown'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; +import type { ContextType, ReactElement, ReactEventHandler } from 'react'; +import React from 'react'; + +import * as ActionManager from '../../../../app/ui-message/client/ActionManager'; +import { detectEmoji } from '../../../lib/utils/detectEmoji'; +import ModalBlock from './ModalBlock'; +import type { ActionManagerState } from './hooks/useActionManagerState'; +import { useActionManagerState } from './hooks/useActionManagerState'; +import { useValues } from './hooks/useValues'; + +const UiKitModal = (props: ActionManagerState): ReactElement => { + const state = useActionManagerState(props); + + const { appId, viewId, mid: _mid, errors, view } = state; + + const [values, updateValues] = useValues(view.blocks as LayoutBlock[]); + + const groupStateByBlockId = (values: { value: unknown; blockId: string }[]) => + Object.entries(values).reduce<any>((obj, [key, { blockId, value }]) => { + obj[blockId] = obj[blockId] || {}; + obj[blockId][key] = value; + + return obj; + }, {}); + + const prevent: ReactEventHandler = (e) => { + if (e) { + (e.nativeEvent || e).stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + } + }; + + const debouncedBlockAction = useDebouncedCallback((actionId, appId, value, blockId, mid) => { + ActionManager.triggerBlockAction({ + container: { + type: UIKitIncomingInteractionContainerType.VIEW, + id: viewId, + }, + actionId, + appId, + value, + blockId, + mid, + }); + }, 700); + + // TODO: this structure is atrociously wrong; we should revisit this + const context: ContextType<typeof kitContext> = { + // @ts-expect-error Property 'mid' does not exist on type 'ActionParams'. + action: ({ actionId, appId, value, blockId, mid = _mid, dispatchActionConfig }) => { + if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes('on_character_entered')) { + debouncedBlockAction(actionId, appId, value, blockId, mid); + } else { + ActionManager.triggerBlockAction({ + container: { + type: UIKitIncomingInteractionContainerType.VIEW, + id: viewId, + }, + actionId, + appId, + value, + blockId, + mid, + }); + } + }, + + state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { + updateValues({ + actionId, + payload: { + blockId, + value, + }, + }); + }, + ...state, + values, + }; + + const handleSubmit = useMutableCallback((e) => { + prevent(e); + ActionManager.triggerSubmitView({ + viewId, + appId, + payload: { + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + }, + }); + }); + + const handleCancel = useMutableCallback((e) => { + prevent(e); + ActionManager.triggerCancel({ + viewId, + appId, + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + }); + }); + + const handleClose = useMutableCallback(() => { + ActionManager.triggerCancel({ + viewId, + appId, + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + isCleared: true, + }); + }); + + return ( + <kitContext.Provider value={context}> + <MarkupInteractionContext.Provider + value={{ + detectEmoji, + }} + > + <ModalBlock view={view} errors={errors} appId={appId} onSubmit={handleSubmit} onCancel={handleCancel} onClose={handleClose} /> + </MarkupInteractionContext.Provider> + </kitContext.Provider> + ); +}; + +export default UiKitModal; diff --git a/apps/meteor/client/views/blocks/getButtonStyle.ts b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts similarity index 88% rename from apps/meteor/client/views/blocks/getButtonStyle.ts rename to apps/meteor/client/views/modal/uikit/getButtonStyle.ts index ce98fda0305002a94e8d182073284d3ed793caf4..4a78cb5e250ab12278c9a3f2473936d46a31a671 100644 --- a/apps/meteor/client/views/blocks/getButtonStyle.ts +++ b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts @@ -1,5 +1,6 @@ import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +// TODO: Move to fuselage-ui-kit export const getButtonStyle = (view: IUIKitSurface): { danger: boolean } | { primary: boolean } => { return view.submit?.style === 'danger' ? { danger: true } : { primary: true }; }; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts b/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f6ee7bb26d2bcaac06a9609e0c17c770c24c4fb --- /dev/null +++ b/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts @@ -0,0 +1,38 @@ +import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import { useEffect, useState } from 'react'; + +import * as ActionManager from '../../../../../app/ui-message/client/ActionManager'; + +export type ActionManagerState = { + viewId: string; + type: 'errors' | string; + appId: string; + mid: string; + errors: Record<string, string>; + view: IUIKitSurface; +}; + +export const useActionManagerState = (initialState: ActionManagerState) => { + const [state, setState] = useState(initialState); + + const { viewId } = state; + + useEffect(() => { + const handleUpdate = ({ type, errors, ...data }: ActionManagerState) => { + if (type === 'errors') { + setState((state) => ({ ...state, errors, type })); + return; + } + + setState({ ...data, type, errors }); + }; + + ActionManager.on(viewId, handleUpdate); + + return () => { + ActionManager.off(viewId, handleUpdate); + }; + }, [viewId]); + + return state; +}; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts b/apps/meteor/client/views/modal/uikit/hooks/useValues.ts new file mode 100644 index 0000000000000000000000000000000000000000..34a8eb0c5ae2d5fda977d8e918720fe8adbcf7da --- /dev/null +++ b/apps/meteor/client/views/modal/uikit/hooks/useValues.ts @@ -0,0 +1,48 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; +import { useReducer } from 'react'; + +type LayoutBlockWithElement = Extract<LayoutBlock, { element: unknown }>; +type LayoutBlockWithElements = Extract<LayoutBlock, { elements: readonly unknown[] }>; +type ElementFromLayoutBlock = LayoutBlockWithElement['element'] | LayoutBlockWithElements['elements'][number]; + +const hasElementInBlock = (block: LayoutBlock): block is LayoutBlockWithElement => 'element' in block; +const hasElementsInBlock = (block: LayoutBlock): block is LayoutBlockWithElements => 'elements' in block; +const hasInitialValueAndActionId = ( + element: ElementFromLayoutBlock, +): element is Extract<ElementFromLayoutBlock, { actionId: string }> & { initialValue: unknown } => + 'initialValue' in element && 'actionId' in element && typeof element.actionId === 'string' && !!element?.initialValue; + +const extractValue = (element: ElementFromLayoutBlock, obj: Record<string, { value: unknown; blockId?: string }>, blockId?: string) => { + if (hasInitialValueAndActionId(element)) { + obj[element.actionId] = { value: element.initialValue, blockId }; + } +}; + +const reduceBlocks = (obj: Record<string, { value: unknown; blockId?: string }>, block: LayoutBlock) => { + if (hasElementInBlock(block)) { + extractValue(block.element, obj, block.blockId); + } + if (hasElementsInBlock(block)) { + for (const element of block.elements) { + extractValue(element, obj, block.blockId); + } + } + + return obj; +}; + +export const useValues = (blocks: LayoutBlock[]) => { + const reducer = useMutableCallback((values, { actionId, payload }) => ({ + ...values, + [actionId]: payload, + })); + + const initializer = useMutableCallback((blocks: LayoutBlock[]) => { + const obj: Record<string, { value: unknown; blockId?: string }> = {}; + + return blocks.reduce(reduceBlocks, obj); + }); + + return useReducer(reducer, blocks, initializer); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Apps/Apps.tsx b/apps/meteor/client/views/room/contextualBar/Apps/Apps.tsx index bf1fcc37c4cbd1a15f1ac73c7a6e6fe628bf6cf1..01bbcda2419173356203dbe161ceb3fe6632209e 100644 --- a/apps/meteor/client/views/room/contextualBar/Apps/Apps.tsx +++ b/apps/meteor/client/views/room/contextualBar/Apps/Apps.tsx @@ -1,13 +1,12 @@ import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; import { ButtonGroup, Button, Box, Avatar } from '@rocket.chat/fuselage'; -import { UiKitComponent, UiKitModal } from '@rocket.chat/fuselage-ui-kit'; +import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; import type { LayoutBlock } from '@rocket.chat/ui-kit'; import React from 'react'; import { getURL } from '../../../../../app/utils/lib/getURL'; import VerticalBar from '../../../../components/VerticalBar'; -import { modalParser } from '../../../blocks/ModalBlock'; -import { getButtonStyle } from '../../../blocks/getButtonStyle'; +import { getButtonStyle } from '../../../modal/uikit/getButtonStyle'; type AppsProps = { view: IUIKitSurface; diff --git a/packages/fuselage-ui-kit/src/surfaces/FuselageModalSurfaceRenderer.tsx b/packages/fuselage-ui-kit/src/surfaces/FuselageModalSurfaceRenderer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4236d0b625c5aab24d9d234c45ec673adbe23663 --- /dev/null +++ b/packages/fuselage-ui-kit/src/surfaces/FuselageModalSurfaceRenderer.tsx @@ -0,0 +1,34 @@ +import { Markup } from '@rocket.chat/gazzodown'; +import type { Markdown, PlainText, TextObject } from '@rocket.chat/ui-kit'; +import { parse } from '@rocket.chat/message-parser'; +import type { JSXElementConstructor, ReactElement } from 'react'; +import { Fragment } from 'react'; + +import type { FuselageSurfaceRendererProps } from './FuselageSurfaceRenderer'; +import { FuselageSurfaceRenderer } from './FuselageSurfaceRenderer'; + +export class FuselageModalSurfaceRenderer extends FuselageSurfaceRenderer { + public constructor(allowedBlocks?: FuselageSurfaceRendererProps) { + super(allowedBlocks); + } + + public plainText = ({ text = '' }: PlainText) => <Fragment>{text}</Fragment>; + + public text({ + text, + type, + }: TextObject): ReactElement< + any, + string | JSXElementConstructor<any> + > | null { + if (type !== 'mrkdwn') { + return this.plainText({ text, type }); + } + + return this.mrkdwn({ text, type }); + } + + public mrkdwn({ text = '' }: Markdown): ReactElement | null { + return text ? <Markup tokens={parse(text, { emoticons: false })} /> : null; + } +} diff --git a/packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx b/packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx index 9760d8ae55c8a32d87b6a3de60a9d74bd1e40d13..8a5109972731ec7fcd963a83737082632b94151a 100644 --- a/packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx +++ b/packages/fuselage-ui-kit/src/surfaces/FuselageSurfaceRenderer.tsx @@ -18,7 +18,7 @@ import OverflowElement from '../elements/OverflowElement'; import PlainTextInputElement from '../elements/PlainTextInputElement'; import StaticSelectElement from '../elements/StaticSelectElement'; -type FuselageSurfaceRendererProps = ConstructorParameters< +export type FuselageSurfaceRendererProps = ConstructorParameters< typeof UiKit.SurfaceRenderer >[0]; diff --git a/packages/fuselage-ui-kit/src/surfaces/index.ts b/packages/fuselage-ui-kit/src/surfaces/index.ts index 49eff1a923a5c34375fb3a0a8d41eecb1ec815f0..25114be862d0f5fa99786bfdc251bb9695bc060d 100644 --- a/packages/fuselage-ui-kit/src/surfaces/index.ts +++ b/packages/fuselage-ui-kit/src/surfaces/index.ts @@ -4,11 +4,12 @@ import MessageSurface from './MessageSurface'; import ModalSurface from './ModalSurface'; import { createSurfaceRenderer } from './createSurfaceRenderer'; import { FuselageMessageSurfaceRenderer } from './MessageSurfaceRenderer'; +import { FuselageModalSurfaceRenderer } from './FuselageModalSurfaceRenderer'; // export const attachmentParser = new FuselageSurfaceRenderer(); export const bannerParser = new FuselageSurfaceRenderer(); export const messageParser = new FuselageMessageSurfaceRenderer(); -export const modalParser = new FuselageSurfaceRenderer(); +export const modalParser = new FuselageModalSurfaceRenderer(); // export const UiKitAttachment = createSurfaceRenderer(AttachmentSurface, attachmentParser); export const UiKitBanner = createSurfaceRenderer(BannerSurface, bannerParser);