diff --git a/.changeset/change-code-block-highlighting-to-arborium.md b/.changeset/change-code-block-highlighting-to-arborium.md new file mode 100644 index 000000000..1071ef3dc --- /dev/null +++ b/.changeset/change-code-block-highlighting-to-arborium.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Improve code blocks with faster, more accurate syntax highlighting, broader language support, and separate light and dark theme options. diff --git a/package.json b/package.json index 0f8bd3df2..cab220bb1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "author": "7w1", "license": "AGPL-3.0-only", "dependencies": { + "@arborium/arborium": "^2.16.0", "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", @@ -75,13 +76,11 @@ "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "^1.16.1", "pdfjs-dist": "^5.4.624", - "prismjs": "^1.30.0", "react": "^18.3.1", "react-aria": "^3.46.0", "react-blurhash": "^0.3.0", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", - "react-error-boundary": "^4.1.2", "react-google-recaptcha": "^2.1.0", "react-i18next": "^16.5.4", "react-range": "^1.10.0", @@ -110,7 +109,6 @@ "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", "@types/node": "24.10.13", - "@types/prismjs": "^1.26.6", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@types/react-google-recaptcha": "^2.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5a3fb8a2..1a5589d58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: .: dependencies: + '@arborium/arborium': + specifier: ^2.16.0 + version: 2.16.0 '@atlaskit/pragmatic-drag-and-drop': specifier: ^1.7.7 version: 1.7.9 @@ -150,9 +153,6 @@ importers: pdfjs-dist: specifier: ^5.4.624 version: 5.5.207 - prismjs: - specifier: ^1.30.0 - version: 1.30.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -168,9 +168,6 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - react-error-boundary: - specifier: ^4.1.2 - version: 4.1.2(react@18.3.1) react-google-recaptcha: specifier: ^2.1.0 version: 2.1.0(react@18.3.1) @@ -250,9 +247,6 @@ importers: '@types/node': specifier: 24.10.13 version: 24.10.13 - '@types/prismjs': - specifier: ^1.26.6 - version: 1.26.6 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -343,6 +337,9 @@ packages: peerDependencies: ajv: '>=8' + '@arborium/arborium@2.16.0': + resolution: {integrity: sha512-9rz2J9Hx+nMq1qon65SbmE+XZvwr/oDqYPinj+BnYOzef7lGwzn1GtYhuB1Cz8jTZ84wIaBt+B8nTQEGYniqNg==} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2813,9 +2810,6 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - '@types/prismjs@1.26.6': - resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} - '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -4766,10 +4760,6 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -4818,11 +4808,6 @@ packages: peerDependencies: react: ^18.3.1 - react-error-boundary@4.1.2: - resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} - peerDependencies: - react: '>=16.13.1' - react-google-recaptcha@2.1.0: resolution: {integrity: sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==} peerDependencies: @@ -5699,6 +5684,8 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@arborium/arborium@2.16.0': {} + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -8469,8 +8456,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/prismjs@1.26.6': {} - '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -10691,8 +10676,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - prismjs@1.30.0: {} - progress@2.0.3: {} prop-types@15.8.1: @@ -10778,11 +10761,6 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-error-boundary@4.1.2(react@18.3.1): - dependencies: - '@babel/runtime': 7.28.6 - react: 18.3.1 - react-google-recaptcha@2.1.0(react@18.3.1): dependencies: prop-types: 15.8.1 diff --git a/src/app/components/RoomNotificationSwitcher.test.tsx b/src/app/components/RoomNotificationSwitcher.test.tsx new file mode 100644 index 000000000..ef4938210 --- /dev/null +++ b/src/app/components/RoomNotificationSwitcher.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { RoomNotificationMode } from '$hooks/useRoomsNotificationPreferences'; + +import { RoomNotificationModeSwitcher } from './RoomNotificationSwitcher'; + +const { mockSetMode, modeStateStatus } = vi.hoisted(() => ({ + mockSetMode: vi.fn(), + modeStateStatus: { current: 'idle' as 'idle' | 'loading' }, +})); + +vi.mock('$hooks/useRoomsNotificationPreferences', async () => { + const actual = await vi.importActual( + '$hooks/useRoomsNotificationPreferences' + ); + + return { + ...actual, + useSetRoomNotificationPreference: () => ({ + modeState: { status: modeStateStatus.current }, + setMode: mockSetMode, + }), + }; +}); + +afterEach(() => { + mockSetMode.mockClear(); +}); + +describe('RoomNotificationModeSwitcher', () => { + it('renders the shared selector trigger and real option content', () => { + modeStateStatus.current = 'idle'; + + render( + + {(openMenu, opened, changing) => ( + + )} + + ); + + expect(screen.getByRole('button', { name: 'closed idle' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'closed idle' })); + + expect(screen.getByRole('button', { name: 'open idle' })).toBeInTheDocument(); + expect(screen.getByText('Follows your global notification rules')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Mention & Keywords' })); + + expect(mockSetMode).toHaveBeenCalledOnce(); + expect(mockSetMode).toHaveBeenCalledWith( + RoomNotificationMode.SpecialMessages, + RoomNotificationMode.Unset + ); + }); + + it('disables interaction while the room mode is changing', () => { + modeStateStatus.current = 'loading'; + + render( + + {(openMenu, opened, changing) => ( + + )} + + ); + + const trigger = screen.getByRole('button', { name: 'closed changing' }); + + expect(trigger).toBeDisabled(); + fireEvent.click(trigger); + expect(screen.queryByRole('button', { name: 'open changing' })).not.toBeInTheDocument(); + + expect(mockSetMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/RoomNotificationSwitcher.tsx b/src/app/components/RoomNotificationSwitcher.tsx index 0b5c9e383..2bab6de16 100644 --- a/src/app/components/RoomNotificationSwitcher.tsx +++ b/src/app/components/RoomNotificationSwitcher.tsx @@ -1,7 +1,6 @@ -import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text, toRem } from 'folds'; -import { MouseEventHandler, ReactNode, useMemo, useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import { stopPropagation } from '$utils/keyboard'; +import { Box, Icon, Text } from 'folds'; +import { type MouseEventHandler, ReactNode } from 'react'; +import { SettingMenuSelector, type SettingMenuOption } from '$components/setting-menu-selector'; import { getRoomNotificationModeIcon, RoomNotificationMode, @@ -9,27 +8,24 @@ import { } from '$hooks/useRoomsNotificationPreferences'; import { AsyncStatus } from '$hooks/useAsyncCallback'; -const useRoomNotificationModes = (): RoomNotificationMode[] => - useMemo( - () => [ - RoomNotificationMode.Unset, - RoomNotificationMode.AllMessages, - RoomNotificationMode.SpecialMessages, - RoomNotificationMode.Mute, - ], - [] - ); +const ROOM_NOTIFICATION_MODE_LABELS: Record = { + [RoomNotificationMode.Unset]: 'Default', + [RoomNotificationMode.AllMessages]: 'All Messages', + [RoomNotificationMode.SpecialMessages]: 'Mention & Keywords', + [RoomNotificationMode.Mute]: 'Mute', +}; -const useRoomNotificationModeStr = (): Record => - useMemo( - () => ({ - [RoomNotificationMode.Unset]: 'Default', - [RoomNotificationMode.AllMessages]: 'All Messages', - [RoomNotificationMode.SpecialMessages]: 'Mention & Keywords', - [RoomNotificationMode.Mute]: 'Mute', - }), - [] - ); +const ROOM_NOTIFICATION_MODE_OPTIONS: SettingMenuOption[] = [ + RoomNotificationMode.Unset, + RoomNotificationMode.AllMessages, + RoomNotificationMode.SpecialMessages, + RoomNotificationMode.Mute, +].map((mode) => ({ + value: mode, + label: ROOM_NOTIFICATION_MODE_LABELS[mode], + description: + mode === RoomNotificationMode.Unset ? 'Follows your global notification rules' : undefined, +})); type NotificationModeSwitcherProps = { roomId: string; @@ -45,84 +41,36 @@ export function RoomNotificationModeSwitcher({ value = RoomNotificationMode.Unset, children, }: NotificationModeSwitcherProps) { - const modes = useRoomNotificationModes(); - const modeToStr = useRoomNotificationModeStr(); - const { modeState, setMode } = useSetRoomNotificationPreference(roomId); const changing = modeState.status === AsyncStatus.Loading; - const [menuCords, setMenuCords] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleClose = () => { - setMenuCords(undefined); - }; - - const handleSelect = (mode: RoomNotificationMode) => { - if (changing) return; - setMode(mode, value); - handleClose(); - }; - return ( - setMode(mode, value)} + loading={changing} offset={5} position="Right" align="Start" - content={ - - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} + renderTrigger={({ openMenu, opened }) => children(openMenu, opened, changing)} + renderOption={({ option, selected }) => ( + - - - {modes.map((mode) => ( - handleSelect(mode)} - before={ - - } - > - - - {mode === value ? {modeToStr[mode]} : modeToStr[mode]} - - {mode === RoomNotificationMode.Unset && ( - - Follows your global notification rules - - )} - - - ))} - - - - } - > - {children(handleOpenMenu, !!menuCords, changing)} - + + + {selected ? {option.label} : option.label} + {option.description && ( + + {option.description} + + )} + + + )} + /> ); } diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.css.ts b/src/app/components/code-highlight/CodeHighlightRenderer.css.ts new file mode 100644 index 000000000..7f7aabf9d --- /dev/null +++ b/src/app/components/code-highlight/CodeHighlightRenderer.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const CodeHighlightCode = style({ + display: 'block', + whiteSpace: 'inherit', + overflowWrap: 'inherit', + wordBreak: 'inherit', +}); diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx new file mode 100644 index 000000000..6a333b662 --- /dev/null +++ b/src/app/components/code-highlight/CodeHighlightRenderer.test.tsx @@ -0,0 +1,151 @@ +import { render, waitFor } from '@testing-library/react'; +import { flushSync } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CodeHighlightRenderer } from '.'; +import * as css from './CodeHighlightRenderer.css'; + +const { highlightCode, useArboriumThemeStatus } = vi.hoisted(() => ({ + highlightCode: vi.fn(), + useArboriumThemeStatus: vi.fn(), +})); + +vi.mock('$plugins/arborium', () => ({ + highlightCode, + useArboriumThemeStatus, +})); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('CodeHighlightRenderer', () => { + it('renders highlighted HTML when Arborium succeeds and theme is ready', async () => { + highlightCode.mockResolvedValue({ + mode: 'highlighted', + html: 'const value = 1;', + language: 'typescript', + }); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + expect(code).toHaveClass('code'); + expect(code).toHaveClass(css.CodeHighlightCode); + + await waitFor(() => { + expect(code?.innerHTML).toContain('const'); + }); + + expect(code?.innerHTML).toContain('const'); + expect(highlightCode).toHaveBeenCalledWith({ + code: 'const value = 1;', + language: 'ts', + allowDetect: true, + }); + }); + + it('renders plain text when theme is not ready', async () => { + highlightCode.mockResolvedValue({ + mode: 'highlighted', + html: 'const value = 1;', + language: 'typescript', + }); + useArboriumThemeStatus.mockReturnValue({ ready: false }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + + await waitFor(() => { + expect(code).toHaveTextContent('const value = 1;'); + }); + + expect(code).toHaveClass(css.CodeHighlightCode); + expect(code?.innerHTML).toBe('const value = 1;'); + }); + + it('renders plain text when Arborium returns plain mode', async () => { + highlightCode.mockResolvedValue({ + mode: 'plain', + html: 'const value = 1;', + language: 'ts', + }); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const { container } = render( + + ); + + const code = container.querySelector('code'); + + await waitFor(() => { + expect(code).toHaveTextContent('const value = 1;'); + }); + + expect(code?.innerHTML).toBe('const value = 1;'); + }); + + it('renders plain new code immediately while a new highlight request is pending', async () => { + const firstHighlight = deferred<{ + mode: 'highlighted'; + html: string; + language: string; + }>(); + const secondHighlight = deferred<{ + mode: 'highlighted'; + html: string; + language: string; + }>(); + + highlightCode + .mockReturnValueOnce(firstHighlight.promise) + .mockReturnValueOnce(secondHighlight.promise); + useArboriumThemeStatus.mockReturnValue({ ready: true }); + + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + + flushSync(() => { + root.render(); + }); + + firstHighlight.resolve({ + mode: 'highlighted', + html: 'const alpha = 1;', + language: 'typescript', + }); + + await waitFor(() => { + expect(host.querySelector('code')?.innerHTML).toContain('alpha'); + }); + + flushSync(() => { + root.render(); + }); + + expect(host.querySelector('code')?.innerHTML).toBe('const beta = 2;'); + expect(host.querySelector('code')).not.toContainHTML('alpha'); + + root.unmount(); + host.remove(); + }); +}); diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx new file mode 100644 index 000000000..3e66e74c5 --- /dev/null +++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; + +import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium'; +import * as css from './CodeHighlightRenderer.css'; + +type CodeHighlightRendererProps = { + code: string; + language?: string; + allowDetect?: boolean; + className?: string; +}; + +type RenderState = { + key: string; + result: HighlightResult; +}; + +const createRequestKey = (code: string, language?: string, allowDetect = false) => + JSON.stringify([code, language ?? null, allowDetect]); + +const createPlainResult = (code: string, language?: string): HighlightResult => { + const result: HighlightResult = { + mode: 'plain', + html: code, + }; + + if (language !== undefined) { + result.language = language; + } + + return result; +}; + +export function CodeHighlightRenderer({ + code, + language, + allowDetect = false, + className, +}: CodeHighlightRendererProps) { + const { ready } = useArboriumThemeStatus(); + const requestKey = createRequestKey(code, language, allowDetect); + const [state, setState] = useState(() => ({ + key: requestKey, + result: createPlainResult(code, language), + })); + + useEffect(() => { + let cancelled = false; + + setState({ + key: requestKey, + result: createPlainResult(code, language), + }); + + highlightCode({ code, language, allowDetect }).then((next) => { + if (!cancelled) { + setState({ + key: requestKey, + result: next, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [code, language, allowDetect, requestKey]); + + const currentResult = state.key === requestKey ? state.result : createPlainResult(code, language); + const codeClassName = [css.CodeHighlightCode, className].filter(Boolean).join(' '); + + if (!ready || currentResult.mode === 'plain') { + return {code}; + } + + return ( + // eslint-disable-next-line react/no-danger + + ); +} diff --git a/src/app/components/code-highlight/index.ts b/src/app/components/code-highlight/index.ts new file mode 100644 index 000000000..7ab7bb6ba --- /dev/null +++ b/src/app/components/code-highlight/index.ts @@ -0,0 +1,3 @@ +export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from '$plugins/arborium'; +export { highlightCode, useArboriumThemeStatus } from '$plugins/arborium'; +export { CodeHighlightRenderer } from './CodeHighlightRenderer'; diff --git a/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx b/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx new file mode 100644 index 000000000..60fd7453b --- /dev/null +++ b/src/app/components/setting-menu-selector/SettingMenuSelector.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SettingMenuSelector, type SettingMenuOption } from './SettingMenuSelector'; + +describe('SettingMenuSelector', () => { + it('renders the selected label, opens the menu, and selects an option', () => { + const onSelect = vi.fn(); + const options: SettingMenuOption<'light' | 'dark'>[] = [ + { value: 'light', label: 'Light', description: 'Plain theme' }, + { value: 'dark', label: 'Dark', description: 'High contrast' }, + ]; + + render(); + + expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dark' })); + + expect(screen.getByText('Plain theme')).toBeInTheDocument(); + expect(screen.getByText('High contrast')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Light')); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith('light'); + expect(screen.queryByText('Plain theme')).not.toBeInTheDocument(); + }); + + it('disables the trigger while loading', () => { + const onSelect = vi.fn(); + + render( + + ); + + expect(screen.getByRole('button', { name: 'Dark' })).toBeDisabled(); + }); + + it('supports custom trigger and option rendering', () => { + const onSelect = vi.fn(); + const options: SettingMenuOption<'one' | 'two'>[] = [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ]; + + render( + ( + + )} + renderOption={({ option, selected }) => {option.label}} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Pick One' })); + fireEvent.click(screen.getByText('Two')); + + expect(onSelect).toHaveBeenCalledWith('two'); + }); +}); diff --git a/src/app/components/setting-menu-selector/SettingMenuSelector.tsx b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx new file mode 100644 index 000000000..3e39dd243 --- /dev/null +++ b/src/app/components/setting-menu-selector/SettingMenuSelector.tsx @@ -0,0 +1,202 @@ +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, +} from 'folds'; +import { + type ComponentPropsWithoutRef, + type MouseEventHandler, + type ReactNode, + useState, +} from 'react'; + +import { stopPropagation } from '$utils/keyboard'; + +export type SettingMenuOption = { + value: T; + label: string; + description?: string; + icon?: ReactNode; + disabled?: boolean; +}; + +type MenuPosition = ComponentPropsWithoutRef['position']; +type MenuAlign = ComponentPropsWithoutRef['align']; + +export type SettingMenuRenderTriggerArgs = { + value: T; + selectedOption: SettingMenuOption; + opened: boolean; + loading: boolean; + disabled: boolean; + openMenu: MouseEventHandler; +}; + +export type SettingMenuRenderOptionArgs = { + option: SettingMenuOption; + selected: boolean; + select: () => void; +}; + +export type SettingMenuSelectorProps = { + value: T; + options: SettingMenuOption[]; + onSelect: (value: T) => void; + disabled?: boolean; + loading?: boolean; + position?: MenuPosition; + align?: MenuAlign; + offset?: number; + renderTrigger?: (args: SettingMenuRenderTriggerArgs) => ReactNode; + renderOption?: (args: SettingMenuRenderOptionArgs) => ReactNode; +}; + +export function SettingMenuSelector({ + value, + options, + onSelect, + disabled = false, + loading = false, + position = 'Bottom', + align = 'End', + offset = 5, + renderTrigger, + renderOption, +}: SettingMenuSelectorProps) { + const [menuCords, setMenuCords] = useState(); + const selectedOption = options.find((option) => option.value === value) ?? options[0]; + const selectedLabel = selectedOption?.label ?? value; + const isDisabled = disabled || loading; + + const handleOpenMenu: MouseEventHandler = (evt) => { + if (isDisabled) return; + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleCloseMenu = () => { + setMenuCords(undefined); + }; + + const handleSelect = (nextValue: T) => { + handleCloseMenu(); + onSelect(nextValue); + }; + + const trigger = renderTrigger ? ( + renderTrigger({ + value, + selectedOption: selectedOption ?? { value, label: selectedLabel }, + opened: !!menuCords, + loading, + disabled: isDisabled, + openMenu: handleOpenMenu, + }) + ) : ( + + ); + + return ( + <> + {trigger} + document.body, + onDeactivate: handleCloseMenu, + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {options.map((option) => { + const selected = option.value === value; + const select = () => { + if (option.disabled) return; + handleSelect(option.value); + }; + + if (renderOption) { + return ( + + {renderOption({ option, selected, select })} + + ); + } + + return ( + + + + {option.label} + {option.description && ( + + {option.description} + + )} + + + + ); + })} + + + + } + /> + + ); +} diff --git a/src/app/components/setting-menu-selector/index.ts b/src/app/components/setting-menu-selector/index.ts new file mode 100644 index 000000000..6aaeff0de --- /dev/null +++ b/src/app/components/setting-menu-selector/index.ts @@ -0,0 +1 @@ +export * from './SettingMenuSelector'; diff --git a/src/app/components/text-viewer/TextViewer.css.ts b/src/app/components/text-viewer/TextViewer.css.ts index 83ee6058b..23c779d59 100644 --- a/src/app/components/text-viewer/TextViewer.css.ts +++ b/src/app/components/text-viewer/TextViewer.css.ts @@ -32,6 +32,7 @@ export const TextViewerPre = style([ DefaultReset, { whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', wordBreak: 'break-word', }, ]); diff --git a/src/app/components/text-viewer/TextViewer.test.tsx b/src/app/components/text-viewer/TextViewer.test.tsx new file mode 100644 index 000000000..2112b0f14 --- /dev/null +++ b/src/app/components/text-viewer/TextViewer.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TextViewer } from './TextViewer'; +import * as css from './TextViewer.css'; + +const { copyToClipboard, CodeHighlightRenderer } = vi.hoisted(() => ({ + copyToClipboard: vi.fn(), + CodeHighlightRenderer: vi.fn(({ code, language, allowDetect }) => ( + + {code} + + )), +})); + +vi.mock('$utils/dom', () => ({ + copyToClipboard, +})); + +vi.mock('$components/code-highlight', () => ({ + CodeHighlightRenderer, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('TextViewer', () => { + it('uses the shared code highlight renderer and keeps Copy All working', async () => { + const user = userEvent.setup(); + + render( + + ); + + expect(CodeHighlightRenderer).toHaveBeenCalled(); + expect(CodeHighlightRenderer.mock.calls[0]?.[0]).toMatchObject({ + code: 'line 1\nline 2', + language: 'txt', + allowDetect: true, + }); + + await user.click(screen.getByText('Copy All')); + + expect(copyToClipboard).toHaveBeenCalledWith('line 1\nline 2'); + expect(screen.getByTestId('highlight')).toHaveTextContent('line 1 line 2'); + expect(screen.getByTestId('highlight').closest('pre')).toHaveClass(css.TextViewerPre); + }); +}); diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index 8c6957ec8..c2d06937c 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -1,12 +1,10 @@ -import { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react'; +import { ComponentProps, HTMLAttributes, forwardRef } from 'react'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds'; -import { ErrorBoundary } from 'react-error-boundary'; +import { CodeHighlightRenderer } from '$components/code-highlight'; import { copyToClipboard } from '$utils/dom'; import * as css from './TextViewer.css'; -const ReactPrism = lazy(() => import('$plugins/react-prism/ReactPrism')); - type TextViewerContentProps = { text: string; langName: string; @@ -21,11 +19,7 @@ export const TextViewerContent = forwardRef - {text}}> - {text}}> - {(codeRef) => {text}} - - + ) ); diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx new file mode 100644 index 000000000..ba621537d --- /dev/null +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -0,0 +1,200 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Appearance } from './Themes'; + +type SettingsShape = { + themeId?: string; + useSystemTheme: boolean; + lightThemeId?: string; + darkThemeId?: string; + useSystemArboriumTheme: boolean; + arboriumThemeId?: string; + arboriumLightTheme?: string; + arboriumDarkTheme?: string; + saturationLevel: number; + underlineLinks: boolean; + reducedMotion: boolean; + autoplayGifs: boolean; + autoplayStickers: boolean; + autoplayEmojis: boolean; + twitterEmoji: boolean; + showEasterEggs: boolean; + subspaceHierarchyLimit: number; + pageZoom: number; +}; + +let currentSettings: SettingsShape; +const setters = new Map>(); + +const getSetter = (key: string) => { + if (!setters.has(key)) { + setters.set(key, vi.fn()); + } + + return setters.get(key)!; +}; + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: keyof SettingsShape) => [currentSettings[key], getSetter(key)], +})); + +vi.mock('$hooks/useTheme', async () => { + const actual = await vi.importActual('$hooks/useTheme'); + + return { + ...actual, + useSystemThemeKind: () => actual.ThemeKind.Light, + }; +}); + +beforeEach(() => { + setters.clear(); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + currentSettings = { + themeId: 'silver-theme', + useSystemTheme: true, + lightThemeId: 'cinny-light-theme', + darkThemeId: 'black-theme', + useSystemArboriumTheme: true, + arboriumThemeId: 'dracula', + arboriumLightTheme: 'github-light', + arboriumDarkTheme: 'dracula', + saturationLevel: 100, + underlineLinks: false, + reducedMotion: false, + autoplayGifs: true, + autoplayStickers: true, + autoplayEmojis: true, + twitterEmoji: true, + showEasterEggs: true, + subspaceHierarchyLimit: 3, + pageZoom: 100, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +const clickLatestButton = (name: string) => { + const nodes = screen.getAllByText(name); + fireEvent.click(nodes.at(-1)!); +}; + +const getFirstEnabledButton = (name: string) => + screen.getAllByRole('button', { name }).find((node) => !node.hasAttribute('disabled')); + +describe('Appearance settings', () => { + it('renders Theme, Code Block Theme, and Visual Tweaks as separate sections', () => { + render(); + + const themeHeading = screen.getByText('Theme'); + const codeBlockThemeHeading = screen.getByText('Code Block Theme'); + const visualTweaksHeading = screen.getByText('Visual Tweaks'); + + expect(themeHeading.compareDocumentPosition(codeBlockThemeHeading)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(codeBlockThemeHeading.compareDocumentPosition(visualTweaksHeading)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(screen.getByRole('button', { name: 'Silver' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cinny Light' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Black' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Dracula' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: 'Dracula' }).at(-1)).toBeDisabled(); + }); + + it('updates the manual app and code block theme settings when system theme is disabled', () => { + currentSettings = { + ...currentSettings, + useSystemTheme: false, + useSystemArboriumTheme: false, + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Silver' })); + clickLatestButton('Dark'); + + fireEvent.click(screen.getByRole('button', { name: 'Dracula' })); + clickLatestButton('Ayu Light'); + + expect(getSetter('themeId')).toHaveBeenCalledWith('dark-theme'); + expect(getSetter('arboriumThemeId')).toHaveBeenCalledWith('ayu-light'); + }); + + it('updates the system theme settings when the chip selectors change', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cinny Light' })); + clickLatestButton('Silver'); + + fireEvent.click(screen.getByRole('button', { name: 'Black' })); + clickLatestButton('Dark'); + + expect(getSetter('lightThemeId')).toHaveBeenCalledWith('silver-theme'); + expect(getSetter('darkThemeId')).toHaveBeenCalledWith('dark-theme'); + }); + + it('updates the system code block theme settings when the chip selectors change', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'GitHub Light' })); + clickLatestButton('Ayu Light'); + + fireEvent.click(getFirstEnabledButton('Dracula')!); + clickLatestButton('One Dark'); + + expect(getSetter('arboriumLightTheme')).toHaveBeenCalledWith('ayu-light'); + expect(getSetter('arboriumDarkTheme')).toHaveBeenCalledWith('one-dark'); + }); + + it('falls back to light theme ids when the stored app theme ids are invalid', () => { + currentSettings = { + ...currentSettings, + useSystemTheme: false, + themeId: 'not-a-theme', + }; + + render(); + + expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument(); + }); + + it('falls back to the active code block system theme when the stored manual theme id is invalid', () => { + currentSettings = { + ...currentSettings, + useSystemArboriumTheme: false, + arboriumThemeId: 'not-a-theme', + }; + + render(); + + expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); + }); + + it('falls back to the default light and dark theme ids for invalid system theme values', () => { + currentSettings = { + ...currentSettings, + themeId: 'silver-theme', + lightThemeId: 'not-a-light-theme', + darkThemeId: 'not-a-dark-theme', + }; + + render(); + + expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument(); + }); +}); diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 0ec660aa0..707bab42c 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -1,123 +1,90 @@ -import { ChangeEventHandler, KeyboardEventHandler, MouseEventHandler, useState } from 'react'; -import { - as, - Box, - Button, - Chip, - config, - Icon, - Icons, - Input, - Menu, - MenuItem, - PopOut, - RectCords, - Switch, - Text, - toRem, -} from 'folds'; +import { ChangeEventHandler, KeyboardEventHandler, type MouseEventHandler, useState } from 'react'; +import { Box, Chip, config, Icon, Icons, Input, Switch, Text, toRem } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; -import FocusTrap from 'focus-trap-react'; + +import { SettingMenuSelector } from '$components/setting-menu-selector'; import { SequenceCard } from '$components/sequence-card'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; import { SettingTile } from '$components/setting-tile'; +import { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeLabel, + getArboriumThemeOptions, +} from '$plugins/arborium'; import { DarkTheme, LightTheme, Theme, ThemeKind, + useActiveTheme, useSystemThemeKind, useThemeNames, useThemes, } from '$hooks/useTheme'; -import { stopPropagation } from '$utils/keyboard'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { SequenceCardStyle } from '$features/settings/styles.css'; -type ThemeSelectorProps = { - themeNames: Record; - themes: Theme[]; - selected: Theme; - onSelect: (theme: Theme) => void; -}; -export const ThemeSelector = as<'div', ThemeSelectorProps>( - ({ themeNames, themes, selected, onSelect, ...props }, ref) => ( - - - {themes.map((theme) => ( - onSelect(theme)} - > - {themeNames[theme.id] ?? theme.id} - - ))} - - - ) -); +function makeThemeOptions(themes: Theme[], themeNames: Record) { + return themes.map((theme) => ({ + value: theme.id, + label: themeNames[theme.id] ?? theme.id, + })); +} + +function makeArboriumThemeOptions(kind?: 'light' | 'dark') { + const themes = kind + ? getArboriumThemeOptions(kind) + : [...getArboriumThemeOptions('light'), ...getArboriumThemeOptions('dark')]; + + return themes.map((theme) => ({ + value: theme.id, + label: getArboriumThemeLabel(theme.id), + })); +} + +function ThemeTrigger({ + selectedLabel, + onClick, + active, + disabled, +}: { + selectedLabel: string; + onClick: MouseEventHandler; + active: boolean; + disabled?: boolean; +}) { + return ( + } + onClick={onClick} + disabled={disabled} + > + {selectedLabel} + + ); +} function SelectTheme({ disabled }: Readonly<{ disabled?: boolean }>) { const themes = useThemes(); const themeNames = useThemeNames(); const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); - const [menuCords, setMenuCords] = useState(); - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; - const handleThemeMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleThemeSelect = (theme: Theme) => { - setThemeId(theme.id); - setMenuCords(undefined); - }; + const themeOptions = makeThemeOptions(themes, themeNames); + const selectedThemeId = + themeOptions.find((theme) => theme.value === themeId)?.value ?? LightTheme.id; return ( - <> - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - } - /> - + ); } @@ -130,114 +97,179 @@ function SystemThemePreferences() { const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light); const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark); + const lightThemeOptions = makeThemeOptions(lightThemes, themeNames); + const darkThemeOptions = makeThemeOptions(darkThemes, themeNames); - const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme; - const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme; + const selectedLightThemeId = + lightThemeOptions.find((theme) => theme.value === lightThemeId)?.value ?? LightTheme.id; + const selectedDarkThemeId = + darkThemeOptions.find((theme) => theme.value === darkThemeId)?.value ?? DarkTheme.id; - const [ltCords, setLTCords] = useState(); - const [dtCords, setDTCords] = useState(); + return ( + + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + + ); +} - const handleLightThemeMenu: MouseEventHandler = (evt) => { - setLTCords(evt.currentTarget.getBoundingClientRect()); - }; - const handleDarkThemeMenu: MouseEventHandler = (evt) => { - setDTCords(evt.currentTarget.getBoundingClientRect()); - }; +function SelectCodeBlockTheme({ disabled }: Readonly<{ disabled?: boolean }>) { + const activeTheme = useActiveTheme(); + const [arboriumThemeId, setArboriumThemeId] = useSetting(settingsAtom, 'arboriumThemeId'); + const [arboriumLightTheme] = useSetting(settingsAtom, 'arboriumLightTheme'); + const [arboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); + + const arboriumThemeOptions = makeArboriumThemeOptions(); + const selectedSystemThemeId = + activeTheme.kind === ThemeKind.Dark + ? (makeArboriumThemeOptions('dark').find((theme) => theme.value === arboriumDarkTheme) + ?.value ?? DEFAULT_ARBORIUM_DARK_THEME) + : (makeArboriumThemeOptions('light').find((theme) => theme.value === arboriumLightTheme) + ?.value ?? DEFAULT_ARBORIUM_LIGHT_THEME); + const selectedArboriumThemeId = + arboriumThemeOptions.find((theme) => theme.value === arboriumThemeId)?.value ?? + selectedSystemThemeId; - const handleLightThemeSelect = (theme: Theme) => { - setLightThemeId(theme.id); - setLTCords(undefined); - }; + return ( + + ); +} - const handleDarkThemeSelect = (theme: Theme) => { - setDarkThemeId(theme.id); - setDTCords(undefined); - }; +function CodeBlockSystemThemePreferences() { + const activeTheme = useActiveTheme(); + const [arboriumLightTheme, setArboriumLightTheme] = useSetting( + settingsAtom, + 'arboriumLightTheme' + ); + const [arboriumDarkTheme, setArboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); + + const arboriumLightThemeOptions = makeArboriumThemeOptions('light'); + const arboriumDarkThemeOptions = makeArboriumThemeOptions('dark'); + const selectedArboriumLightTheme = + arboriumLightThemeOptions.find((theme) => theme.value === arboriumLightTheme)?.value ?? + DEFAULT_ARBORIUM_LIGHT_THEME; + const selectedArboriumDarkTheme = + arboriumDarkThemeOptions.find((theme) => theme.value === arboriumDarkTheme)?.value ?? + DEFAULT_ARBORIUM_DARK_THEME; return ( } - onClick={handleLightThemeMenu} - > - {themeNames[selectedLightTheme.id] ?? selectedLightTheme.id} - - } - /> - setLTCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - + ( + + )} + /> } /> } - onClick={handleDarkThemeMenu} - > - {themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id} - + ( + + )} + /> } /> - setDTCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - + ); +} + +function CodeBlockThemeSettings() { + const [useSystemArboriumTheme, setUseSystemArboriumTheme] = useSetting( + settingsAtom, + 'useSystemArboriumTheme' + ); + + return ( + + Code Block Theme + + + - - } - /> + } + /> + {useSystemArboriumTheme && } + + + + } + /> + ); } @@ -432,6 +464,7 @@ function PageZoomInput() { /> ); } + export function Appearance() { const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs'); @@ -439,6 +472,7 @@ export function Appearance() { return ( + Visual Tweaks diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx index 5ee60a178..c66ddea30 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -11,16 +11,19 @@ import { useAccountData } from '$hooks/useAccountData'; import { AccountDataEvent } from '$types/matrix/accountData'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; import { PushRuleData, usePushRule } from '$hooks/usePushRule'; import { getNotificationModeActions, NotificationMode, + useNotificationActionsMode, useNotificationModeActions, } from '$hooks/useNotificationMode'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { NotificationLevelsHint } from './NotificationLevelsHint'; +import { notificationModeSelectorOptions } from './notificationModeOptions'; const getAllMessageDefaultRule = ( ruleId: RuleId, @@ -67,16 +70,25 @@ function AllMessagesModeSwitcher({ const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne); const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData; const getModeActions = useNotificationModeActions(); - - const handleChange = useCallback( - async (mode: NotificationMode) => { - const actions = getModeActions(mode); - await mx.setPushRuleActions('global', kind, ruleId, actions); - }, - [mx, getModeActions, kind, ruleId] + const selectedMode = useNotificationActionsMode(pushRule.actions); + const [changeState, change] = useAsyncCallback( + useCallback( + async (mode: NotificationMode) => { + const actions = getModeActions(mode); + await mx.setPushRuleActions('global', kind, ruleId, actions); + }, + [mx, getModeActions, kind, ruleId] + ) ); - return ; + return ( + + ); } export function AllMessagesNotifications() { diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index ff235271f..4c7ab55f0 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -5,17 +5,19 @@ import { useAccountData } from '$hooks/useAccountData'; import { AccountDataEvent } from '$types/matrix/accountData'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getNotificationModeActions, NotificationMode, NotificationModeOptions, + useNotificationActionsMode, useNotificationModeActions, } from '$hooks/useNotificationMode'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { NotificationLevelsHint } from './NotificationLevelsHint'; +import { notificationModeSelectorOptions } from './notificationModeOptions'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, @@ -129,21 +131,30 @@ function KeywordModeSwitcher({ pushRule }: PushRulesProps) { const mx = useMatrixClient(); const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS); - - const handleChange = useCallback( - async (mode: NotificationMode) => { - const actions = getModeActions(mode); - await mx.setPushRuleActions( - 'global', - PushRuleKind.ContentSpecific, - pushRule.rule_id, - actions - ); - }, - [mx, getModeActions, pushRule] + const selectedMode = useNotificationActionsMode(pushRule.actions); + const [changeState, change] = useAsyncCallback( + useCallback( + async (mode: NotificationMode) => { + const actions = getModeActions(mode); + await mx.setPushRuleActions( + 'global', + PushRuleKind.ContentSpecific, + pushRule.rule_id, + actions + ); + }, + [mx, getModeActions, pushRule] + ) ); - return ; + return ( + + ); } export function KeywordMessagesNotifications() { diff --git a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx b/src/app/features/settings/notifications/NotificationModeSwitcher.tsx deleted file mode 100644 index 608bd5078..000000000 --- a/src/app/features/settings/notifications/NotificationModeSwitcher.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - Box, - Button, - config, - Icon, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Spinner, - Text, -} from 'folds'; -import { IPushRule } from '$types/matrix-sdk'; -import { MouseEventHandler, useMemo, useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import { NotificationMode, useNotificationActionsMode } from '$hooks/useNotificationMode'; -import { stopPropagation } from '$utils/keyboard'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; - -export const useNotificationModes = (): NotificationMode[] => - useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []); - -const useNotificationModeStr = (): Record => - useMemo( - () => ({ - [NotificationMode.OFF]: 'Disable', - [NotificationMode.Notify]: 'Notify Silent', - [NotificationMode.NotifyLoud]: 'Notify Loud', - }), - [] - ); - -type NotificationModeSwitcherProps = { - pushRule: IPushRule; - onChange: (mode: NotificationMode) => Promise; -}; -export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) { - const modes = useNotificationModes(); - const modeToStr = useNotificationModeStr(); - const selectedMode = useNotificationActionsMode(pushRule.actions); - const [changeState, change] = useAsyncCallback(onChange); - const changing = changeState.status === AsyncStatus.Loading; - - const [menuCords, setMenuCords] = useState(); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSelect = (mode: NotificationMode) => { - setMenuCords(undefined); - change(mode); - }; - - return ( - <> - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - {modes.map((mode) => ( - handleSelect(mode)} - > - - {modeToStr[mode]} - - - ))} - - - - } - /> - - ); -} diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx index ac8d736f3..261ea97c2 100644 --- a/src/app/features/settings/notifications/SpecialMessages.tsx +++ b/src/app/features/settings/notifications/SpecialMessages.tsx @@ -5,6 +5,7 @@ import { useAccountData } from '$hooks/useAccountData'; import { AccountDataEvent } from '$types/matrix/accountData'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; import { getMxIdLocalPart } from '$utils/matrix'; @@ -13,11 +14,13 @@ import { getNotificationModeActions, NotificationMode, NotificationModeOptions, + useNotificationActionsMode, useNotificationModeActions, } from '$hooks/useNotificationMode'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { NotificationLevelsHint } from './NotificationLevelsHint'; +import { notificationModeSelectorOptions } from './notificationModeOptions'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, @@ -100,16 +103,25 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData; const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS); - - const handleChange = useCallback( - async (mode: NotificationMode) => { - const actions = getModeActions(mode); - await mx.setPushRuleActions('global', kind, ruleId, actions); - }, - [mx, getModeActions, kind, ruleId] + const selectedMode = useNotificationActionsMode(pushRule.actions); + const [changeState, change] = useAsyncCallback( + useCallback( + async (mode: NotificationMode) => { + const actions = getModeActions(mode); + await mx.setPushRuleActions('global', kind, ruleId, actions); + }, + [mx, getModeActions, kind, ruleId] + ) ); - return ; + return ( + + ); } export function SpecialMessagesNotifications() { diff --git a/src/app/features/settings/notifications/notificationModeOptions.test.ts b/src/app/features/settings/notifications/notificationModeOptions.test.ts new file mode 100644 index 000000000..b4e935d27 --- /dev/null +++ b/src/app/features/settings/notifications/notificationModeOptions.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { NotificationMode } from '$hooks/useNotificationMode'; + +import { notificationModeSelectorOptions } from './notificationModeOptions'; + +describe('notificationModeSelectorOptions', () => { + it('returns the notification modes in display order with stable labels', () => { + expect(notificationModeSelectorOptions).toEqual([ + { value: NotificationMode.NotifyLoud, label: 'Notify Loud' }, + { value: NotificationMode.Notify, label: 'Notify Silent' }, + { value: NotificationMode.OFF, label: 'Disable' }, + ]); + }); +}); diff --git a/src/app/features/settings/notifications/notificationModeOptions.ts b/src/app/features/settings/notifications/notificationModeOptions.ts new file mode 100644 index 000000000..b2fc74b62 --- /dev/null +++ b/src/app/features/settings/notifications/notificationModeOptions.ts @@ -0,0 +1,8 @@ +import { type SettingMenuOption } from '$components/setting-menu-selector'; +import { NotificationMode } from '$hooks/useNotificationMode'; + +export const notificationModeSelectorOptions: SettingMenuOption[] = [ + { value: NotificationMode.NotifyLoud, label: 'Notify Loud' }, + { value: NotificationMode.Notify, label: 'Notify Silent' }, + { value: NotificationMode.OFF, label: 'Disable' }, +]; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 735d03301..3492fd185 100755 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -30,62 +30,62 @@ export type Theme = { export const LightTheme: Theme = { id: 'light-theme', kind: ThemeKind.Light, - classNames: ['light-theme', lightTheme, onLightFontWeight, 'prism-light'], + classNames: ['light-theme', lightTheme, onLightFontWeight], }; export const SilverTheme: Theme = { id: 'silver-theme', kind: ThemeKind.Light, - classNames: ['silver-theme', silverTheme, onLightFontWeight, 'prism-light'], + classNames: ['silver-theme', silverTheme, onLightFontWeight], }; export const CinnyLightTheme: Theme = { id: 'cinny-light-theme', kind: ThemeKind.Light, - classNames: ['cinny-light-theme', cinnyLightTheme, onLightFontWeight, 'prism-light'], + classNames: ['cinny-light-theme', cinnyLightTheme, onLightFontWeight], }; export const CinnySilverTheme: Theme = { id: 'cinny-silver-theme', kind: ThemeKind.Light, - classNames: ['cinny-silver-theme', cinnySilverTheme, onLightFontWeight, 'prism-light'], + classNames: ['cinny-silver-theme', cinnySilverTheme, onLightFontWeight], }; export const DarkTheme: Theme = { id: 'dark-theme', kind: ThemeKind.Dark, - classNames: ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['dark-theme', darkTheme, onDarkFontWeight], }; export const ButterTheme: Theme = { id: 'butter-theme', kind: ThemeKind.Dark, - classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['butter-theme', butterTheme, onDarkFontWeight], }; export const RosePineTheme: Theme = { id: 'rose-pine-theme', kind: ThemeKind.Dark, - classNames: ['rose-pine-theme', rosePineTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['rose-pine-theme', rosePineTheme, onDarkFontWeight], }; export const GruvdarkTheme: Theme = { id: 'gruvdark-theme', kind: ThemeKind.Dark, - classNames: ['gruvdark-theme', gruvdarkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['gruvdark-theme', gruvdarkTheme, onDarkFontWeight], }; export const CinnyDarkTheme: Theme = { id: 'cinny-dark-theme', kind: ThemeKind.Dark, - classNames: ['cinny-dark-theme', cinnyDarkTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['cinny-dark-theme', cinnyDarkTheme, onDarkFontWeight], }; export const AccordTheme: Theme = { id: 'accord-theme', kind: ThemeKind.Dark, - classNames: ['accord-theme', accordTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['accord-theme', accordTheme, onDarkFontWeight], }; export const BlackTheme: Theme = { id: 'black-theme', kind: ThemeKind.Dark, - classNames: ['black-theme', blackTheme, onDarkFontWeight, 'prism-dark'], + classNames: ['black-theme', blackTheme, onDarkFontWeight], }; export const useThemes = (): Theme[] => { diff --git a/src/app/pages/ThemeManager.test.tsx b/src/app/pages/ThemeManager.test.tsx new file mode 100644 index 000000000..81cc4e991 --- /dev/null +++ b/src/app/pages/ThemeManager.test.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +import { ThemeKind, type Theme } from '$hooks/useTheme'; +import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; + +const settings = { + saturationLevel: 100, + underlineLinks: false, + reducedMotion: false, +}; + +let systemThemeKind = ThemeKind.Light; +let activeTheme: Theme = { + id: 'test-light', + kind: ThemeKind.Light, + classNames: ['test-light-theme'], +}; + +type ThemeContextProviderProps = { + value: Theme; + children: ReactNode; +}; + +type ArboriumThemeBridgeProps = { + kind: ThemeKind; + children?: ReactNode; +}; + +vi.mock('$hooks/useTheme', () => ({ + ThemeKind: { + Light: 'light', + Dark: 'dark', + }, + DarkTheme: { + classNames: ['test-dark-theme'], + }, + LightTheme: { + classNames: ['test-light-theme'], + }, + ThemeContextProvider: ({ value, children }: ThemeContextProviderProps) => + value.kind === ThemeKind.Dark ? <>{children} : <>{children}, + useActiveTheme: () => activeTheme, + useSystemThemeKind: () => systemThemeKind, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: keyof typeof settings) => [settings[key]], +})); + +vi.mock('$state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('$plugins/arborium', () => ({ + ArboriumThemeBridge: ({ kind, children }: ArboriumThemeBridgeProps) => + kind === ThemeKind.Dark ? <>{children} : <>{children}, +})); + +beforeEach(() => { + systemThemeKind = ThemeKind.Light; + activeTheme = { + id: 'test-light', + kind: ThemeKind.Light, + classNames: ['test-light-theme'], + }; + settings.saturationLevel = 100; + settings.underlineLinks = false; + settings.reducedMotion = false; + document.body.className = ''; + document.body.style.filter = ''; +}); + +afterEach(() => { + document.body.className = ''; + document.body.style.filter = ''; +}); + +describe('ThemeManager', () => { + it('applies the system theme classes for unauthenticated routes', () => { + systemThemeKind = ThemeKind.Dark; + + render(); + + expect(document.body).toHaveClass('test-dark-theme'); + expect(document.body).not.toHaveClass('test-light-theme'); + }); + + it('applies the active theme classes for authenticated routes', () => { + activeTheme = { + id: 'test-dark', + kind: ThemeKind.Dark, + classNames: ['test-dark-theme'], + }; + + render( + +
child
+
+ ); + + expect(document.body).toHaveClass('test-dark-theme'); + expect(document.body).not.toHaveClass('test-light-theme'); + }); +}); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 90a7e5783..d47b73cbf 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -8,6 +8,7 @@ import { useActiveTheme, useSystemThemeKind, } from '$hooks/useTheme'; +import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -25,7 +26,7 @@ export function UnAuthRouteThemeManager() { } }, [systemThemeKind]); - return null; + return ; } export function AuthRouteThemeManager({ children }: { children: ReactNode }) { @@ -60,5 +61,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, saturation, underlineLinks, reducedMotion]); - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx new file mode 100644 index 000000000..a10540afa --- /dev/null +++ b/src/app/plugins/arborium/ArboriumThemeBridge.test.tsx @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { createStore, Provider } from 'jotai'; +import { pluginVersion } from '@arborium/arborium'; + +import { ThemeKind } from '$hooks/useTheme'; +import { getSettings, settingsAtom } from '$state/settings'; + +import { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge'; +import { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, +} from './themes'; + +function StatusProbe() { + const { ready } = useArboriumThemeStatus(); + + return
{ready ? 'ready' : 'loading'}
; +} + +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/base-rustdoc.css`; + +function renderWithSettings(kind: ThemeKind, settings = getSettings()) { + const store = createStore(); + store.set(settingsAtom, settings); + + return render( + + + + + + ); +} + +afterEach(() => { + document.getElementById('arborium-base')?.remove(); + document.getElementById('arborium-theme')?.remove(); +}); + +describe('ArboriumThemeBridge', () => { + it('injects the base stylesheet once and swaps the theme stylesheet from dark to light in system mode', () => { + const store = createStore(); + store.set(settingsAtom, getSettings()); + + const { rerender } = render( + + + + + + ); + + const baseLink = document.getElementById('arborium-base'); + const themeLink = document.getElementById('arborium-theme'); + + expect(baseLink).toBeInstanceOf(HTMLLinkElement); + expect(themeLink).toBeInstanceOf(HTMLLinkElement); + expect(baseLink).toHaveAttribute('href', baseHref); + expect(themeLink).toHaveAttribute('href', getArboriumThemeHref(DEFAULT_ARBORIUM_DARK_THEME)); + expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); + expect(document.head.querySelectorAll('#arborium-theme')).toHaveLength(1); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + + act(() => { + baseLink?.dispatchEvent(new Event('load')); + themeLink?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('arborium-status')).toHaveTextContent('ready'); + + rerender( + + + + + + ); + + const nextBaseLink = document.getElementById('arborium-base'); + const nextThemeLink = document.getElementById('arborium-theme'); + + expect(nextBaseLink).toBe(baseLink); + expect(nextThemeLink).toBe(themeLink); + expect(document.head.querySelectorAll('#arborium-base')).toHaveLength(1); + expect(nextBaseLink).toHaveAttribute('href', baseHref); + expect(nextThemeLink).toHaveAttribute( + 'href', + getArboriumThemeHref(DEFAULT_ARBORIUM_LIGHT_THEME) + ); + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + }); + + it('uses the configured Arborium theme ids from settings in system mode', () => { + const settings = { + ...getSettings(), + useSystemArboriumTheme: true, + arboriumLightTheme: 'ayu-light', + arboriumDarkTheme: 'dracula', + }; + + renderWithSettings(ThemeKind.Dark, settings); + + const themeLink = document.getElementById('arborium-theme'); + expect(themeLink).toHaveAttribute('href', getArboriumThemeHref('dracula')); + }); + + it('uses the configured manual Arborium theme id when system mode is disabled', () => { + const settings = { + ...getSettings(), + useSystemArboriumTheme: false, + arboriumThemeId: 'dracula', + arboriumLightTheme: 'ayu-light', + arboriumDarkTheme: 'one-dark', + }; + const store = createStore(); + store.set(settingsAtom, settings); + + const { rerender } = render( + + + + + + ); + + expect(document.getElementById('arborium-theme')).toHaveAttribute( + 'href', + getArboriumThemeHref('dracula') + ); + + rerender( + + + + + + ); + + expect(document.getElementById('arborium-theme')).toHaveAttribute( + 'href', + getArboriumThemeHref('dracula') + ); + }); + + it('keeps readiness false when the theme stylesheet errors', () => { + renderWithSettings(ThemeKind.Dark); + + const baseLink = document.getElementById('arborium-base'); + const themeLink = document.getElementById('arborium-theme'); + + act(() => { + baseLink?.dispatchEvent(new Event('load')); + themeLink?.dispatchEvent(new Event('error')); + }); + + expect(screen.getByTestId('arborium-status')).toHaveTextContent('loading'); + }); +}); diff --git a/src/app/plugins/arborium/ArboriumThemeBridge.tsx b/src/app/plugins/arborium/ArboriumThemeBridge.tsx new file mode 100644 index 000000000..5b193fab1 --- /dev/null +++ b/src/app/plugins/arborium/ArboriumThemeBridge.tsx @@ -0,0 +1,146 @@ +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; + +import { ThemeKind } from '$hooks/useTheme'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; + +import { + ARBORIUM_CDN_VERSION, + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + isArboriumThemeId, +} from './themes'; + +type ArboriumThemeStatus = { + ready: boolean; +}; + +const ArboriumThemeStatusContext = createContext(null); + +export const useArboriumThemeStatus = (): ArboriumThemeStatus => { + const status = useContext(ArboriumThemeStatusContext); + if (!status) { + throw new Error('No Arborium theme status provided!'); + } + + return status; +}; + +type ArboriumThemeBridgeProps = { + kind: ThemeKind; + children?: ReactNode; +}; + +const baseHref = `https://cdn.jsdelivr.net/npm/@arborium/arborium@${ARBORIUM_CDN_VERSION}/dist/themes/base-rustdoc.css`; + +const baseLinkId = 'arborium-base'; +const themeLinkId = 'arborium-theme'; + +const getOrCreateLink = (id: string): HTMLLinkElement => { + const existingLink = document.getElementById(id); + if (existingLink instanceof HTMLLinkElement) { + return existingLink; + } + + const link = document.createElement('link'); + link.setAttribute('id', id); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + document.head.append(link); + return link; +}; + +const setLinkHref = (link: HTMLLinkElement, href: string) => { + if (link.getAttribute('href') !== href) { + link.setAttribute('href', href); + } +}; + +const markLinkLoaded = (link: HTMLLinkElement) => { + link.setAttribute('data-arborium-loaded', 'true'); +}; + +const clearLinkLoaded = (link: HTMLLinkElement) => { + link.removeAttribute('data-arborium-loaded'); +}; + +export function ArboriumThemeBridge({ kind, children }: ArboriumThemeBridgeProps) { + const [useSystemArboriumTheme] = useSetting(settingsAtom, 'useSystemArboriumTheme'); + const [arboriumThemeId] = useSetting(settingsAtom, 'arboriumThemeId'); + const [arboriumLightTheme] = useSetting(settingsAtom, 'arboriumLightTheme'); + const [arboriumDarkTheme] = useSetting(settingsAtom, 'arboriumDarkTheme'); + const [baseReady, setBaseReady] = useState(false); + const [themeReady, setThemeReady] = useState(false); + const selectedSystemThemeId = kind === ThemeKind.Dark ? arboriumDarkTheme : arboriumLightTheme; + let resolvedSystemThemeId = DEFAULT_ARBORIUM_LIGHT_THEME; + if (kind === ThemeKind.Dark) { + resolvedSystemThemeId = DEFAULT_ARBORIUM_DARK_THEME; + } + if (selectedSystemThemeId && isArboriumThemeId(selectedSystemThemeId)) { + resolvedSystemThemeId = selectedSystemThemeId; + } + const themeId = + !useSystemArboriumTheme && arboriumThemeId && isArboriumThemeId(arboriumThemeId) + ? arboriumThemeId + : resolvedSystemThemeId; + const themeHref = getArboriumThemeHref(themeId); + + useEffect(() => { + const baseLink = getOrCreateLink(baseLinkId); + setLinkHref(baseLink, baseHref); + setBaseReady(baseLink.dataset.arboriumLoaded === 'true'); + + const handleBaseLoad = () => { + markLinkLoaded(baseLink); + setBaseReady(true); + }; + const handleBaseError = () => { + clearLinkLoaded(baseLink); + setBaseReady(false); + }; + + baseLink.addEventListener('load', handleBaseLoad); + baseLink.addEventListener('error', handleBaseError); + + return () => { + baseLink.removeEventListener('load', handleBaseLoad); + baseLink.removeEventListener('error', handleBaseError); + }; + }, []); + + useEffect(() => { + const themeLink = getOrCreateLink(themeLinkId); + const hrefChanged = themeLink.getAttribute('href') !== themeHref; + setLinkHref(themeLink, themeHref); + if (hrefChanged) { + clearLinkLoaded(themeLink); + } + setThemeReady(!hrefChanged && themeLink.dataset.arboriumLoaded === 'true'); + + const handleThemeLoad = () => { + markLinkLoaded(themeLink); + setThemeReady(true); + }; + const handleThemeError = () => { + clearLinkLoaded(themeLink); + setThemeReady(false); + }; + + themeLink.addEventListener('load', handleThemeLoad); + themeLink.addEventListener('error', handleThemeError); + + return () => { + themeLink.removeEventListener('load', handleThemeLoad); + themeLink.removeEventListener('error', handleThemeError); + }; + }, [themeHref]); + + const status = useMemo(() => ({ ready: baseReady && themeReady }), [baseReady, themeReady]); + + return ( + + {children} + + ); +} diff --git a/src/app/plugins/arborium/index.ts b/src/app/plugins/arborium/index.ts new file mode 100644 index 000000000..9a76a33c1 --- /dev/null +++ b/src/app/plugins/arborium/index.ts @@ -0,0 +1,10 @@ +export type { HighlightCodeDeps, HighlightCodeInput, HighlightResult } from './runtime'; +export { highlightCode } from './runtime'; +export { ArboriumThemeBridge, useArboriumThemeStatus } from './ArboriumThemeBridge'; +export { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + getArboriumThemeLabel, + getArboriumThemeOptions, +} from './themes'; diff --git a/src/app/plugins/arborium/runtime.test.ts b/src/app/plugins/arborium/runtime.test.ts new file mode 100644 index 000000000..3c6d89ef9 --- /dev/null +++ b/src/app/plugins/arborium/runtime.test.ts @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { HighlightResult } from '.'; + +type ArboriumModule = typeof import('@arborium/arborium'); + +afterEach(() => { + vi.resetModules(); +}); + +describe('highlightCode', () => { + it('normalizes explicit aliases before highlighting', async () => { + const normalizeLanguage = vi.fn((language: string) => + language === 'ts' ? 'typescript' : language + ); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: 'const value = 1;', + language: 'ts', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '
const value = 1;
', + language: 'typescript', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('ts'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('typescript', 'const value = 1;'); + }); + + it('maps jsx to tsx when Arborium supports tsx', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + availableLanguages: ['tsx', 'html'], + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '
', + language: 'jsx', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '
', + language: 'tsx', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('tsx'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('tsx', '
'); + }); + + it('maps markup to html when Arborium supports html', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn( + async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + availableLanguages: ['tsx', 'html'], + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '

hello

', + language: 'markup', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '

hello

', + language: 'html', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('html'); + expect(detectLanguage).not.toHaveBeenCalled(); + expect(highlight).toHaveBeenCalledWith('html', '

hello

'); + }); + + it.each(['txt', 'plaintext', 'plain', 'text', 'log', 'csv', 'makefile', 'make'])( + 'returns plain fallback for %s when Arborium reports it unavailable', + async (language) => { + const normalizeLanguage = vi.fn((nextLanguage: string) => { + if (nextLanguage === 'txt' || nextLanguage === 'plaintext' || nextLanguage === 'plain') { + return 'text'; + } + if (nextLanguage === 'makefile') { + return 'make'; + } + return nextLanguage; + }); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => '
');
+      const isLanguageAvailable = vi.fn(
+        async (nextLanguage: string) => !['text', 'log', 'csv', 'make'].includes(nextLanguage)
+      );
+      const module = {
+        normalizeLanguage,
+        detectLanguage,
+        highlight,
+        isLanguageAvailable,
+      } as unknown as ArboriumModule;
+      const loadModule = vi.fn(async () => module);
+
+      const { highlightCode } = await import('.');
+
+      const result: HighlightResult = await highlightCode(
+        {
+          code: 'hello, world',
+          language,
+          allowDetect: false,
+        },
+        { loadModule }
+      );
+
+      expect(result).toEqual({
+        mode: 'plain',
+        html: 'hello, world',
+        language,
+      });
+      expect(loadModule).toHaveBeenCalledOnce();
+      expect(normalizeLanguage).toHaveBeenCalledWith(language);
+      expect(highlight).not.toHaveBeenCalled();
+    }
+  );
+
+  it('does not detect a language when allowDetect is false', async () => {
+    const normalizeLanguage = vi.fn((language: string) => language);
+    const detectLanguage = vi.fn(() => 'javascript');
+    const highlight = vi.fn(async () => '
');
+    const module = {
+      normalizeLanguage,
+      detectLanguage,
+      highlight,
+    } as unknown as ArboriumModule;
+    const loadModule = vi.fn(async () => module);
+
+    const { highlightCode } = await import('.');
+
+    const result: HighlightResult = await highlightCode(
+      {
+        code: 'hello',
+        allowDetect: false,
+      },
+      { loadModule }
+    );
+
+    expect(result).toEqual({
+      mode: 'plain',
+      html: '<b>hello</b>',
+    });
+    expect(result.language).toBeUndefined();
+    expect(normalizeLanguage).not.toHaveBeenCalled();
+    expect(detectLanguage).not.toHaveBeenCalled();
+    expect(highlight).not.toHaveBeenCalled();
+  });
+
+  it('detects a language only when allowDetect is true', async () => {
+    const normalizeLanguage = vi.fn((language: string) =>
+      language === 'js' ? 'javascript' : language
+    );
+    const detectLanguage = vi.fn(() => 'js');
+    const highlight = vi.fn(
+      async (language: string, code: string) => `
${code}
` + ); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: 'const value = 1;', + allowDetect: true, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'highlighted', + html: '
const value = 1;
', + language: 'javascript', + }); + expect(normalizeLanguage).toHaveBeenCalledWith('js'); + expect(detectLanguage).toHaveBeenCalledWith('const value = 1;'); + expect(highlight).toHaveBeenCalledWith('javascript', 'const value = 1;'); + }); + + it('returns plain escaped code when Arborium fails to load', async () => { + const loadModule = vi.fn(async () => { + throw new Error('boom'); + }); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: 'hi', + language: 'typescript', + allowDetect: false, + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: '<span class="x">hi</span>', + language: 'typescript', + }); + }); + + it('treats escaped Arborium output as plain fallback', async () => { + const normalizeLanguage = vi.fn((language: string) => language); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => '<span>'); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '', + language: 'typescript', + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: '<span>', + language: 'typescript', + }); + expect(highlight).toHaveBeenCalledWith('typescript', ''); + }); + + it('returns plain escaped code with the resolved language when highlighting fails', async () => { + const normalizeLanguage = vi.fn((language: string) => + language === 'ts' ? 'typescript' : language + ); + const detectLanguage = vi.fn(() => null); + const highlight = vi.fn(async () => { + throw new Error('bad highlight'); + }); + const module = { + normalizeLanguage, + detectLanguage, + highlight, + } as unknown as ArboriumModule; + const loadModule = vi.fn(async () => module); + + const { highlightCode } = await import('.'); + + const result: HighlightResult = await highlightCode( + { + code: '', + language: 'ts', + }, + { loadModule } + ); + + expect(result).toEqual({ + mode: 'plain', + html: '<span>', + language: 'typescript', + }); + expect(highlight).toHaveBeenCalledWith('typescript', ''); + }); +}); diff --git a/src/app/plugins/arborium/runtime.ts b/src/app/plugins/arborium/runtime.ts new file mode 100644 index 000000000..30d22031d --- /dev/null +++ b/src/app/plugins/arborium/runtime.ts @@ -0,0 +1,167 @@ +type ArboriumModule = typeof import('@arborium/arborium'); +type ArboriumModuleWithAvailability = ArboriumModule & { + availableLanguages?: string[]; + isLanguageAvailable?: (language: string) => boolean | Promise; +}; + +const LANGUAGE_COMPATIBILITY: Record = { + jsx: 'tsx', + markup: 'html', +}; + +export interface HighlightCodeInput { + code: string; + language?: string | null; + allowDetect?: boolean; +} + +export type HighlightResult = + | { mode: 'plain'; html: string; language?: string } + | { mode: 'highlighted'; html: string; language: string }; + +export interface HighlightCodeDeps { + loadModule?: () => Promise; +} + +let arboriumModulePromise: Promise | null = null; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function plainResult(code: string, language?: string): HighlightResult { + const result: HighlightResult = { + mode: 'plain', + html: escapeHtml(code), + }; + + if (language !== undefined) { + result.language = language; + } + + return result; +} + +function resolveCompatibleLanguage(language: string): string { + return LANGUAGE_COMPATIBILITY[language.toLowerCase()] ?? language; +} + +async function isLanguageAvailable( + arborium: ArboriumModuleWithAvailability, + language: string +): Promise { + if (Array.isArray(arborium.availableLanguages)) { + return arborium.availableLanguages.includes(language); + } + + if (typeof arborium.isLanguageAvailable === 'function') { + try { + return await arborium.isLanguageAvailable(language); + } catch { + return false; + } + } + + return true; +} + +async function loadArborium( + loadModule?: () => Promise +): Promise { + if (loadModule) { + try { + return await loadModule(); + } catch { + return null; + } + } + + if (!arboriumModulePromise) { + arboriumModulePromise = import('@arborium/arborium').catch(() => null); + } + + return arboriumModulePromise; +} + +export async function highlightCode( + { code, language, allowDetect = false }: HighlightCodeInput, + deps?: HighlightCodeDeps +): Promise { + const { loadModule } = deps ?? {}; + if (language) { + const compatibleLanguage = resolveCompatibleLanguage(language); + const arborium = await loadArborium(loadModule); + if (!arborium) { + return plainResult(code, language); + } + + let resolvedLanguage: string; + try { + resolvedLanguage = arborium.normalizeLanguage(compatibleLanguage); + } catch { + return plainResult(code, language); + } + + if (!(await isLanguageAvailable(arborium, resolvedLanguage))) { + return plainResult(code, language); + } + + try { + const html = await arborium.highlight(resolvedLanguage, code); + if (html === escapeHtml(code)) { + return plainResult(code, resolvedLanguage); + } + return { + mode: 'highlighted', + html, + language: resolvedLanguage, + }; + } catch { + return plainResult(code, resolvedLanguage); + } + } + + const arborium = await loadArborium(loadModule); + if (!arborium) { + return plainResult(code, language ?? undefined); + } + + let resolvedLanguage: string | null = null; + + if (allowDetect) { + try { + const detectedLanguage = arborium.detectLanguage(code); + if (detectedLanguage) { + resolvedLanguage = arborium.normalizeLanguage(detectedLanguage); + } + } catch { + return plainResult(code, language ?? undefined); + } + } + + if (!resolvedLanguage) { + return plainResult(code, language ?? undefined); + } + + if (!(await isLanguageAvailable(arborium, resolvedLanguage))) { + return plainResult(code, language ?? undefined); + } + + try { + const html = await arborium.highlight(resolvedLanguage, code); + if (html === escapeHtml(code)) { + return plainResult(code, resolvedLanguage); + } + return { + mode: 'highlighted', + html, + language: resolvedLanguage, + }; + } catch { + return plainResult(code, resolvedLanguage); + } +} diff --git a/src/app/plugins/arborium/themes.test.ts b/src/app/plugins/arborium/themes.test.ts new file mode 100644 index 000000000..7f1ba1e0c --- /dev/null +++ b/src/app/plugins/arborium/themes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; + +import { pluginVersion } from '@arborium/arborium'; + +import { + DEFAULT_ARBORIUM_DARK_THEME, + DEFAULT_ARBORIUM_LIGHT_THEME, + getArboriumThemeHref, + getArboriumThemeLabel, + getArboriumThemeOptions, +} from './themes'; + +const arboriumThemesDir = dirname( + createRequire(import.meta.url).resolve('@arborium/arborium/themes/base.css') +); + +describe('Arborium theme registry', () => { + it('exposes the default light and dark themes', () => { + expect(DEFAULT_ARBORIUM_LIGHT_THEME).toBe('github-light'); + expect(DEFAULT_ARBORIUM_DARK_THEME).toBe('dracula'); + }); + + it('groups themes by kind and keeps the defaults available', () => { + const lightThemes = getArboriumThemeOptions('light'); + const darkThemes = getArboriumThemeOptions('dark'); + + expect(lightThemes.map((theme) => theme.id)).toContain(DEFAULT_ARBORIUM_LIGHT_THEME); + expect(darkThemes.map((theme) => theme.id)).toContain(DEFAULT_ARBORIUM_DARK_THEME); + }); + + it('builds the CDN href for a theme id', () => { + expect(getArboriumThemeHref('github-light')).toBe( + `https://cdn.jsdelivr.net/npm/@arborium/arborium@${pluginVersion}/dist/themes/github-light.css` + ); + }); + + it('returns a readable label for a supported theme', () => { + expect(getArboriumThemeLabel('github-light')).toBe('GitHub Light'); + }); + + it('matches the installed Arborium theme css files', async () => { + const installedThemes = (await readdir(arboriumThemesDir)) + .filter((fileName) => fileName.endsWith('.css')) + .map((fileName) => fileName.replace(/\.css$/, '')) + .filter((themeId) => themeId !== 'base' && themeId !== 'base-rustdoc') + .sort(); + + const exportedThemeIds = [ + ...getArboriumThemeOptions('light').map((theme) => theme.id), + ...getArboriumThemeOptions('dark').map((theme) => theme.id), + ].sort(); + + expect(exportedThemeIds).toEqual(installedThemes); + }); +}); diff --git a/src/app/plugins/arborium/themes.ts b/src/app/plugins/arborium/themes.ts new file mode 100644 index 000000000..ae250ff9b --- /dev/null +++ b/src/app/plugins/arborium/themes.ts @@ -0,0 +1,67 @@ +export type ArboriumThemeKind = 'light' | 'dark'; + +export const ARBORIUM_CDN_VERSION = '2.16.0'; + +const ARBORIUM_THEME_DEFINITIONS = [ + { id: 'alabaster', label: 'Alabaster', kind: 'light' }, + { id: 'ayu-light', label: 'Ayu Light', kind: 'light' }, + { id: 'catppuccin-latte', label: 'Catppuccin Latte', kind: 'light' }, + { id: 'dayfox', label: 'Dayfox', kind: 'light' }, + { id: 'desert256', label: 'Desert 256', kind: 'light' }, + { id: 'github-light', label: 'GitHub Light', kind: 'light' }, + { id: 'gruvbox-light', label: 'Gruvbox Light', kind: 'light' }, + { id: 'light-owl', label: 'Light Owl', kind: 'light' }, + { id: 'lucius-light', label: 'Lucius Light', kind: 'light' }, + { id: 'melange-light', label: 'Melange Light', kind: 'light' }, + { id: 'solarized-light', label: 'Solarized Light', kind: 'light' }, + { id: 'rustdoc-light', label: 'Rustdoc Light', kind: 'light' }, + { id: 'github-dark', label: 'GitHub Dark', kind: 'dark' }, + { id: 'one-dark', label: 'One Dark', kind: 'dark' }, + { id: 'nord', label: 'Nord', kind: 'dark' }, + { id: 'dracula', label: 'Dracula', kind: 'dark' }, + { id: 'tokyo-night', label: 'Tokyo Night', kind: 'dark' }, + { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', kind: 'dark' }, + { id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', kind: 'dark' }, + { id: 'catppuccin-frappe', label: 'Catppuccin Frappe', kind: 'dark' }, + { id: 'rose-pine-moon', label: 'Rose Pine Moon', kind: 'dark' }, + { id: 'gruvbox-dark', label: 'Gruvbox Dark', kind: 'dark' }, + { id: 'ayu-dark', label: 'Ayu Dark', kind: 'dark' }, + { id: 'kanagawa-dragon', label: 'Kanagawa Dragon', kind: 'dark' }, + { id: 'solarized-dark', label: 'Solarized Dark', kind: 'dark' }, + { id: 'melange-dark', label: 'Melange Dark', kind: 'dark' }, + { id: 'monokai', label: 'Monokai', kind: 'dark' }, + { id: 'zenburn', label: 'Zenburn', kind: 'dark' }, + { id: 'cobalt2', label: 'Cobalt2', kind: 'dark' }, + { id: 'ef-melissa-dark', label: 'Ef Melissa Dark', kind: 'dark' }, + { id: 'rustdoc-dark', label: 'Rustdoc Dark', kind: 'dark' }, + { id: 'rustdoc-ayu', label: 'Rustdoc Ayu', kind: 'dark' }, +] as const; + +type ArboriumThemeDefinition = (typeof ARBORIUM_THEME_DEFINITIONS)[number]; + +export type ArboriumThemeId = ArboriumThemeDefinition['id']; + +export type ArboriumTheme = { + id: ArboriumThemeId; + label: string; + kind: ArboriumThemeKind; +}; + +export const DEFAULT_ARBORIUM_LIGHT_THEME: ArboriumThemeId = 'github-light'; +export const DEFAULT_ARBORIUM_DARK_THEME: ArboriumThemeId = 'dracula'; + +const ARBORIUM_THEMES: ArboriumTheme[] = [...ARBORIUM_THEME_DEFINITIONS]; + +const ARBORIUM_THEME_IDS = new Set(ARBORIUM_THEMES.map((theme) => theme.id)); + +export const getArboriumThemeOptions = (kind: ArboriumThemeKind): ArboriumTheme[] => + ARBORIUM_THEMES.filter((theme) => theme.kind === kind); + +export const isArboriumThemeId = (themeId: string): themeId is ArboriumThemeId => + ARBORIUM_THEME_IDS.has(themeId as ArboriumThemeId); + +export const getArboriumThemeLabel = (themeId: ArboriumThemeId): string => + ARBORIUM_THEMES.find((theme) => theme.id === themeId)?.label ?? themeId; + +export const getArboriumThemeHref = (themeId: ArboriumThemeId): string => + `https://cdn.jsdelivr.net/npm/@arborium/arborium@${ARBORIUM_CDN_VERSION}/dist/themes/${themeId}.css`; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index a5015c1b2..3a36ffe36 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -1,9 +1,36 @@ import { render, screen } from '@testing-library/react'; import parse from 'html-react-parser'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as css from '$styles/CustomHtml.css'; import { sanitizeCustomHtml } from '$utils/sanitize'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser'; +const { CodeHighlightRenderer } = vi.hoisted(() => ({ + CodeHighlightRenderer: vi.fn( + ({ + code, + language, + allowDetect, + }: { + code: string; + language?: string; + allowDetect?: boolean; + }) => ( + + {code} + + ) + ), +})); + +vi.mock('$components/code-highlight', () => ({ + CodeHighlightRenderer, +})); + function createMatrixClient() { return { getRoom: () => undefined, @@ -27,11 +54,75 @@ function renderParsedHtml( return render(
{parse(sanitize ? sanitizeCustomHtml(html) : html, parserOptions)}
); } -describe('getReactCustomHtmlParser', () => { - afterEach(() => { - vi.restoreAllMocks(); +const renderMessage = (html: string) => renderParsedHtml(html, { sanitize: false }); + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('getReactCustomHtmlParser code blocks', () => { + it('renders the Arborium renderer inside the existing code block shell for explicit data-lang metadata', () => { + const { container } = renderMessage( + `
\n  fn main() {\nlet value = 1;\nlet next = 2;\nlet third = 3;\nlet fourth = 4;\nlet fifth = 5;\nlet sixth = 6;\nlet seventh = 7;\nlet eighth = 8;\nlet ninth = 9;\nlet tenth = 10;\nlet eleventh = 11;\nlet twelfth = 12;\nlet thirteenth = 13;\nlet fourteenth = 14;\nlet fifteenth = 15;\n}\n
` + ); + + expect(screen.getByText('rust')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + + const arboriumCode = container.querySelector('[data-testid="arborium-code"]'); + expect(arboriumCode).toBeInTheDocument(); + expect(arboriumCode).toHaveTextContent('fn main()'); + expect(arboriumCode).toHaveAttribute('data-language', 'rust'); + expect(arboriumCode).toHaveAttribute('data-allow-detect', 'false'); + expect(container.querySelector('#code-block-content')).toHaveClass(css.CodeBlockInternal); + expect(CodeHighlightRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + code: expect.stringContaining('let fifteenth = 15;'), + language: 'rust', + allowDetect: false, + }), + expect.anything() + ); + }); + + it('preserves nested code children instead of routing them through Arborium', () => { + const { container } = renderMessage(`
\n  alpha
beta
\n
`); + + expect(screen.getByText('Code')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + expect(CodeHighlightRenderer).not.toHaveBeenCalled(); + expect(container.querySelector('br')).toBeInTheDocument(); + expect(container.querySelector('code')).toHaveTextContent('alphabeta'); }); + it('uses data-lang on the pre element when the nested code element has no metadata', () => { + renderMessage(`
fn main() {}
`); + + expect(screen.getByText('rust')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + + const shell = screen.getByTestId('arborium-code'); + expect(shell).toHaveTextContent('fn main() {}'); + expect(shell).toHaveAttribute('data-language', 'rust'); + }); + + it('falls back to the language class when no explicit data-lang metadata is present', () => { + renderMessage( + `
const value = 1;\nconsole.log(value);
` + ); + + expect(screen.getByText('ts')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + + const shell = screen.getByTestId('arborium-code'); + expect(shell).toHaveTextContent('const value = 1;'); + expect(shell).toHaveAttribute('data-language', 'ts'); + }); +}); + +describe('getReactCustomHtmlParser', () => { it('translates Matrix color data attributes into rendered styles', () => { renderParsedHtml('colored'); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index b1cf5e636..9a7c78f97 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -2,10 +2,8 @@ import { CSSProperties, ComponentPropsWithoutRef, - lazy, ReactEventHandler, ReactNode, - Suspense, useMemo, useState, } from 'react'; @@ -21,7 +19,6 @@ import classNames from 'classnames'; import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds'; import { IntermediateRepresentation, OptFn, Opts as LinkifyOpts } from 'linkifyjs'; import Linkify from 'linkify-react'; -import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '$styles/CustomHtml.css'; import { @@ -39,6 +36,7 @@ import { copyToClipboard } from '$utils/dom'; import { isMatrixHexColor } from '$utils/matrixHtml'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; +import { CodeHighlightRenderer } from '$components/code-highlight'; import { parseMatrixToRoom, parseMatrixToRoomEvent, @@ -47,8 +45,6 @@ import { } from './matrix-to'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; -const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); - const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); export const LINKIFY_OPTS: LinkifyOpts = { @@ -279,20 +275,40 @@ const extractTextFromChildren = (nodes: ChildNode[]): string => { return text; }; +const getLanguageFromClassName = (className?: string): string | undefined => { + if (!className) return undefined; + + return className + .split(/\s+/) + .find((token) => token.startsWith('language-')) + ?.replace('language-', ''); +}; + +const getCodeBlockLanguage = ( + children: ChildNode[], + attribs?: Record +): string | undefined => { + const code = children.find((child) => child instanceof Element && child.name === 'code'); + const codeAttribs = code instanceof Element ? code.attribs : undefined; + + return ( + codeAttribs?.['data-lang'] ?? + attribs?.['data-lang'] ?? + getLanguageFromClassName(codeAttribs?.class) ?? + getLanguageFromClassName(attribs?.class) + ); +}; + export function CodeBlock({ children, + attribs, opts, }: { children: ChildNode[]; + attribs?: Record; opts: HTMLReactParserOptions; }) { - const code = children[0]; - const languageClass = - code instanceof Element && code.name === 'code' ? code.attribs.class : undefined; - const language = - languageClass && languageClass.startsWith('language-') - ? languageClass.replace('language-', '') - : languageClass; + const language = getCodeBlockLanguage(children, attribs); const LINE_LIMIT = 14; const largeCodeBlock = useMemo( @@ -473,7 +489,11 @@ export const getReactCustomHtmlParser = ( } if (name === 'pre') { - return {children}; + return ( + + {children} + + ); } if (name === 'blockquote') { @@ -501,33 +521,30 @@ export const getReactCustomHtmlParser = ( if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { - const codeReact = renderChildren(); - if (typeof codeReact === 'string') { - let lang = typeof props.className === 'string' ? props.className : undefined; - if (lang === 'language-rs') lang = 'language-rust'; - else if (lang === 'language-js') lang = 'language-javascript'; - else if (lang === 'language-ts') lang = 'language-typescript'; - return ( - {codeReact}}> - {codeReact}}> - - {(ref) => ( - - {codeReact} - - )} - - - - ); + const codeContent = renderChildren(); + if (typeof codeContent !== 'string') { + return undefined; } - } else { + + const language = getCodeBlockLanguage( + parent instanceof Element ? parent.children : [], + parent instanceof Element ? parent.attribs : undefined + ); return ( - - {renderChildren()} - + ); } + + return ( + + {renderChildren()} + + ); } if (name === 'a' && typeof props.href === 'string') { diff --git a/src/app/plugins/react-prism/ReactPrism.css b/src/app/plugins/react-prism/ReactPrism.css deleted file mode 100644 index e6a121771..000000000 --- a/src/app/plugins/react-prism/ReactPrism.css +++ /dev/null @@ -1,97 +0,0 @@ -.prism-light { - --prism-comment: #0f4777; - --prism-punctuation: #6d5050; - --prism-property: #9b1144; - --prism-boolean: #4816a3; - --prism-selector: #659604; - --prism-operator: #2a2a2a; - --prism-atrule: #7e6d00; - --prism-keyword: #00829f; - --prism-regex: #9b6426; -} - -.prism-dark { - --prism-comment: #8292a2; - --prism-punctuation: #f8f8f2; - --prism-property: #f92672; - --prism-boolean: #ae81ff; - --prism-selector: #a6e22e; - --prism-operator: #f8f8f2; - --prism-atrule: #e6db74; - --prism-keyword: #66d9ef; - --prism-regex: #fd971f; -} - -code .token.comment, -code .token.prolog, -code .token.doctype, -code .token.cdata { - color: var(--prism-comment); -} - -code .token.punctuation { - color: var(--prism-punctuation); -} - -code .token.namespace { - opacity: 0.7; -} - -code .token.property, -code .token.tag, -code .token.constant, -code .token.symbol, -code .token.deleted { - color: var(--prism-property); -} - -code .token.boolean, -code .token.number { - color: var(--prism-boolean); -} - -code .token.selector, -code .token.attr-name, -code .token.string, -code .token.char, -code .token.builtin, -code .token.inserted { - color: var(--prism-selector); -} - -code .token.operator, -code .token.entity, -code .token.url, -.language-css code .token.string, -.style code .token.string, -code .token.variable { - color: var(--prism-operator); -} - -code .token.atrule, -code .token.attr-value, -code .token.function, -code .token.class-name { - color: var(--prism-atrule); -} - -code .token.keyword { - color: var(--prism-keyword); -} - -code .token.regex, -code .token.important { - color: var(--prism-regex); -} - -code .token.important, -code .token.bold { - font-weight: bold; -} -code .token.italic { - font-style: italic; -} - -code .token.entity { - cursor: help; -} diff --git a/src/app/plugins/react-prism/ReactPrism.tsx b/src/app/plugins/react-prism/ReactPrism.tsx deleted file mode 100644 index 1d3bb90fa..000000000 --- a/src/app/plugins/react-prism/ReactPrism.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { MutableRefObject, ReactNode, useEffect, useRef } from 'react'; - -import Prism from 'prismjs'; - -import 'prismjs/components/prism-abap.js'; -import 'prismjs/components/prism-abnf.js'; -import 'prismjs/components/prism-actionscript.js'; -import 'prismjs/components/prism-ada.js'; -import 'prismjs/components/prism-agda.js'; -import 'prismjs/components/prism-al.js'; -import 'prismjs/components/prism-antlr4.js'; -import 'prismjs/components/prism-apacheconf.js'; -import 'prismjs/components/prism-apex.js'; -import 'prismjs/components/prism-apl.js'; -import 'prismjs/components/prism-applescript.js'; -import 'prismjs/components/prism-aql.js'; -import 'prismjs/components/prism-arff.js'; -import 'prismjs/components/prism-armasm.js'; -import 'prismjs/components/prism-arturo.js'; -import 'prismjs/components/prism-asciidoc.js'; -import 'prismjs/components/prism-asm6502.js'; -import 'prismjs/components/prism-asmatmel.js'; -import 'prismjs/components/prism-aspnet.js'; -import 'prismjs/components/prism-autohotkey.js'; -import 'prismjs/components/prism-autoit.js'; -import 'prismjs/components/prism-avisynth.js'; -import 'prismjs/components/prism-avro-idl.js'; -import 'prismjs/components/prism-awk.js'; -import 'prismjs/components/prism-bash.js'; -import 'prismjs/components/prism-basic.js'; -import 'prismjs/components/prism-batch.js'; -import 'prismjs/components/prism-bbcode.js'; -import 'prismjs/components/prism-bbj.js'; -import 'prismjs/components/prism-bicep.js'; -import 'prismjs/components/prism-birb.js'; -import 'prismjs/components/prism-bnf.js'; -import 'prismjs/components/prism-bqn.js'; -import 'prismjs/components/prism-brainfuck.js'; -import 'prismjs/components/prism-brightscript.js'; -import 'prismjs/components/prism-bro.js'; -import 'prismjs/components/prism-bsl.js'; -import 'prismjs/components/prism-c.js'; -import 'prismjs/components/prism-cfscript.js'; -import 'prismjs/components/prism-cil.js'; -import 'prismjs/components/prism-cilkc.js'; -import 'prismjs/components/prism-cilkcpp.js'; -import 'prismjs/components/prism-clike.js'; -import 'prismjs/components/prism-clojure.js'; -import 'prismjs/components/prism-cmake.js'; -import 'prismjs/components/prism-cobol.js'; -import 'prismjs/components/prism-coffeescript.js'; -import 'prismjs/components/prism-concurnas.js'; -import 'prismjs/components/prism-cooklang.js'; -import 'prismjs/components/prism-coq.js'; -import 'prismjs/components/prism-cpp.js'; -import 'prismjs/components/prism-csharp.js'; -import 'prismjs/components/prism-cshtml.js'; -import 'prismjs/components/prism-csp.js'; -import 'prismjs/components/prism-css-extras.js'; -import 'prismjs/components/prism-css.js'; -import 'prismjs/components/prism-csv.js'; -import 'prismjs/components/prism-cue.js'; -import 'prismjs/components/prism-cypher.js'; -import 'prismjs/components/prism-d.js'; -import 'prismjs/components/prism-dart.js'; -import 'prismjs/components/prism-dataweave.js'; -import 'prismjs/components/prism-dax.js'; -import 'prismjs/components/prism-dhall.js'; -import 'prismjs/components/prism-diff.js'; -import 'prismjs/components/prism-dns-zone-file.js'; -import 'prismjs/components/prism-docker.js'; -import 'prismjs/components/prism-dot.js'; -import 'prismjs/components/prism-ebnf.js'; -import 'prismjs/components/prism-editorconfig.js'; -import 'prismjs/components/prism-eiffel.js'; -import 'prismjs/components/prism-ejs.js'; -import 'prismjs/components/prism-elixir.js'; -import 'prismjs/components/prism-elm.js'; -import 'prismjs/components/prism-erb.js'; -import 'prismjs/components/prism-erlang.js'; -import 'prismjs/components/prism-etlua.js'; -import 'prismjs/components/prism-excel-formula.js'; -import 'prismjs/components/prism-factor.js'; -import 'prismjs/components/prism-false.js'; -import 'prismjs/components/prism-firestore-security-rules.js'; -import 'prismjs/components/prism-flow.js'; -import 'prismjs/components/prism-fortran.js'; -import 'prismjs/components/prism-fsharp.js'; -import 'prismjs/components/prism-ftl.js'; -import 'prismjs/components/prism-gap.js'; -import 'prismjs/components/prism-gcode.js'; -import 'prismjs/components/prism-gdscript.js'; -import 'prismjs/components/prism-gedcom.js'; -import 'prismjs/components/prism-gettext.js'; -import 'prismjs/components/prism-gherkin.js'; -import 'prismjs/components/prism-git.js'; -import 'prismjs/components/prism-glsl.js'; -import 'prismjs/components/prism-gml.js'; -import 'prismjs/components/prism-gn.js'; -import 'prismjs/components/prism-go-module.js'; -import 'prismjs/components/prism-go.js'; -import 'prismjs/components/prism-gradle.js'; -import 'prismjs/components/prism-graphql.js'; -import 'prismjs/components/prism-groovy.js'; -import 'prismjs/components/prism-haml.js'; -import 'prismjs/components/prism-handlebars.js'; -import 'prismjs/components/prism-haskell.js'; -import 'prismjs/components/prism-haxe.js'; -import 'prismjs/components/prism-hcl.js'; -import 'prismjs/components/prism-hlsl.js'; -import 'prismjs/components/prism-hoon.js'; -import 'prismjs/components/prism-hpkp.js'; -import 'prismjs/components/prism-hsts.js'; -import 'prismjs/components/prism-http.js'; -import 'prismjs/components/prism-ichigojam.js'; -import 'prismjs/components/prism-icon.js'; -import 'prismjs/components/prism-icu-message-format.js'; -import 'prismjs/components/prism-idris.js'; -import 'prismjs/components/prism-iecst.js'; -import 'prismjs/components/prism-ignore.js'; -import 'prismjs/components/prism-inform7.js'; -import 'prismjs/components/prism-ini.js'; -import 'prismjs/components/prism-io.js'; -import 'prismjs/components/prism-j.js'; -import 'prismjs/components/prism-java.js'; -import 'prismjs/components/prism-javadoclike.js'; -import 'prismjs/components/prism-javascript.js'; -import 'prismjs/components/prism-javastacktrace.js'; -import 'prismjs/components/prism-jexl.js'; -import 'prismjs/components/prism-jolie.js'; -import 'prismjs/components/prism-jq.js'; -import 'prismjs/components/prism-js-extras.js'; -import 'prismjs/components/prism-js-templates.js'; -import 'prismjs/components/prism-json.js'; -import 'prismjs/components/prism-json5.js'; -import 'prismjs/components/prism-jsonp.js'; -import 'prismjs/components/prism-jsstacktrace.js'; -import 'prismjs/components/prism-jsx.js'; -import 'prismjs/components/prism-julia.js'; -import 'prismjs/components/prism-keepalived.js'; -import 'prismjs/components/prism-keyman.js'; -import 'prismjs/components/prism-kotlin.js'; -import 'prismjs/components/prism-kumir.js'; -import 'prismjs/components/prism-kusto.js'; -import 'prismjs/components/prism-latex.js'; -import 'prismjs/components/prism-latte.js'; -import 'prismjs/components/prism-less.js'; -import 'prismjs/components/prism-lilypond.js'; -import 'prismjs/components/prism-linker-script.js'; -import 'prismjs/components/prism-liquid.js'; -import 'prismjs/components/prism-lisp.js'; -import 'prismjs/components/prism-livescript.js'; -import 'prismjs/components/prism-llvm.js'; -import 'prismjs/components/prism-log.js'; -import 'prismjs/components/prism-lolcode.js'; -import 'prismjs/components/prism-lua.js'; -import 'prismjs/components/prism-magma.js'; -import 'prismjs/components/prism-makefile.js'; -import 'prismjs/components/prism-markdown.js'; -import 'prismjs/components/prism-markup-templating.js'; -import 'prismjs/components/prism-markup.js'; -import 'prismjs/components/prism-mata.js'; -import 'prismjs/components/prism-matlab.js'; -import 'prismjs/components/prism-maxscript.js'; -import 'prismjs/components/prism-mel.js'; -import 'prismjs/components/prism-mermaid.js'; -import 'prismjs/components/prism-metafont.js'; -import 'prismjs/components/prism-mizar.js'; -import 'prismjs/components/prism-mongodb.js'; -import 'prismjs/components/prism-monkey.js'; -import 'prismjs/components/prism-moonscript.js'; -import 'prismjs/components/prism-n1ql.js'; -import 'prismjs/components/prism-n4js.js'; -import 'prismjs/components/prism-nand2tetris-hdl.js'; -import 'prismjs/components/prism-naniscript.js'; -import 'prismjs/components/prism-nasm.js'; -import 'prismjs/components/prism-neon.js'; -import 'prismjs/components/prism-nevod.js'; -import 'prismjs/components/prism-nginx.js'; -import 'prismjs/components/prism-nim.js'; -import 'prismjs/components/prism-nix.js'; -import 'prismjs/components/prism-nsis.js'; -import 'prismjs/components/prism-objectivec.js'; -import 'prismjs/components/prism-ocaml.js'; -import 'prismjs/components/prism-odin.js'; -import 'prismjs/components/prism-opencl.js'; -import 'prismjs/components/prism-openqasm.js'; -import 'prismjs/components/prism-oz.js'; -import 'prismjs/components/prism-parigp.js'; -import 'prismjs/components/prism-parser.js'; -import 'prismjs/components/prism-pascal.js'; -import 'prismjs/components/prism-pascaligo.js'; -import 'prismjs/components/prism-pcaxis.js'; -import 'prismjs/components/prism-peoplecode.js'; -import 'prismjs/components/prism-perl.js'; -import 'prismjs/components/prism-php-extras.js'; -import 'prismjs/components/prism-php.js'; -import 'prismjs/components/prism-phpdoc.js'; -import 'prismjs/components/prism-plant-uml.js'; -import 'prismjs/components/prism-powerquery.js'; -import 'prismjs/components/prism-powershell.js'; -import 'prismjs/components/prism-processing.js'; -import 'prismjs/components/prism-prolog.js'; -import 'prismjs/components/prism-promql.js'; -import 'prismjs/components/prism-properties.js'; -import 'prismjs/components/prism-protobuf.js'; -import 'prismjs/components/prism-psl.js'; -import 'prismjs/components/prism-pug.js'; -import 'prismjs/components/prism-puppet.js'; -import 'prismjs/components/prism-pure.js'; -import 'prismjs/components/prism-purebasic.js'; -import 'prismjs/components/prism-purescript.js'; -import 'prismjs/components/prism-python.js'; -import 'prismjs/components/prism-q.js'; -import 'prismjs/components/prism-qml.js'; -import 'prismjs/components/prism-qore.js'; -import 'prismjs/components/prism-qsharp.js'; -import 'prismjs/components/prism-r.js'; -import 'prismjs/components/prism-reason.js'; -import 'prismjs/components/prism-regex.js'; -import 'prismjs/components/prism-rego.js'; -import 'prismjs/components/prism-renpy.js'; -import 'prismjs/components/prism-rescript.js'; -import 'prismjs/components/prism-rest.js'; -import 'prismjs/components/prism-rip.js'; -import 'prismjs/components/prism-roboconf.js'; -import 'prismjs/components/prism-robotframework.js'; -import 'prismjs/components/prism-ruby.js'; -import 'prismjs/components/prism-rust.js'; -import 'prismjs/components/prism-sas.js'; -import 'prismjs/components/prism-sass.js'; -import 'prismjs/components/prism-scala.js'; -import 'prismjs/components/prism-scheme.js'; -import 'prismjs/components/prism-scss.js'; -import 'prismjs/components/prism-shell-session.js'; -import 'prismjs/components/prism-smali.js'; -import 'prismjs/components/prism-smalltalk.js'; -import 'prismjs/components/prism-smarty.js'; -import 'prismjs/components/prism-sml.js'; -import 'prismjs/components/prism-solidity.js'; -import 'prismjs/components/prism-solution-file.js'; -import 'prismjs/components/prism-soy.js'; -import 'prismjs/components/prism-splunk-spl.js'; -import 'prismjs/components/prism-sqf.js'; -import 'prismjs/components/prism-sql.js'; -import 'prismjs/components/prism-squirrel.js'; -import 'prismjs/components/prism-stan.js'; -import 'prismjs/components/prism-stata.js'; -import 'prismjs/components/prism-stylus.js'; -import 'prismjs/components/prism-supercollider.js'; -import 'prismjs/components/prism-swift.js'; -import 'prismjs/components/prism-systemd.js'; -import 'prismjs/components/prism-t4-templating.js'; -import 'prismjs/components/prism-t4-vb.js'; -import 'prismjs/components/prism-tap.js'; -import 'prismjs/components/prism-tcl.js'; -import 'prismjs/components/prism-textile.js'; -import 'prismjs/components/prism-toml.js'; -import 'prismjs/components/prism-tremor.js'; -import 'prismjs/components/prism-tsx.js'; -import 'prismjs/components/prism-tt2.js'; -import 'prismjs/components/prism-turtle.js'; -import 'prismjs/components/prism-twig.js'; -import 'prismjs/components/prism-typescript.js'; -import 'prismjs/components/prism-typoscript.js'; -import 'prismjs/components/prism-unrealscript.js'; -import 'prismjs/components/prism-uorazor.js'; -import 'prismjs/components/prism-uri.js'; -import 'prismjs/components/prism-v.js'; -import 'prismjs/components/prism-vala.js'; -import 'prismjs/components/prism-vbnet.js'; -import 'prismjs/components/prism-velocity.js'; -import 'prismjs/components/prism-verilog.js'; -import 'prismjs/components/prism-vhdl.js'; -import 'prismjs/components/prism-vim.js'; -import 'prismjs/components/prism-visual-basic.js'; -import 'prismjs/components/prism-warpscript.js'; -import 'prismjs/components/prism-wasm.js'; -import 'prismjs/components/prism-web-idl.js'; -import 'prismjs/components/prism-wgsl.js'; -import 'prismjs/components/prism-wiki.js'; -import 'prismjs/components/prism-wolfram.js'; -import 'prismjs/components/prism-wren.js'; -import 'prismjs/components/prism-xeora.js'; -import 'prismjs/components/prism-xml-doc.js'; -import 'prismjs/components/prism-xojo.js'; -import 'prismjs/components/prism-xquery.js'; -import 'prismjs/components/prism-yaml.js'; -import 'prismjs/components/prism-yang.js'; -import 'prismjs/components/prism-zig.js'; -import 'prismjs/components/prism-arduino.js'; - -// Broken: -// -// import 'prismjs/components/prism-bison.js'; -// import 'prismjs/components/prism-chaiscript.js'; -// import 'prismjs/components/prism-core.js'; -// import 'prismjs/components/prism-crystal.js'; -// import 'prismjs/components/prism-django.js'; -// import 'prismjs/components/prism-javadoc.js'; -// import 'prismjs/components/prism-jsdoc.js'; -// import 'prismjs/components/prism-plsql.js'; -// import 'prismjs/components/prism-racket.js'; -// import 'prismjs/components/prism-sparql.js'; -// import 'prismjs/components/prism-t4-cs.js'; - -import '$plugins/react-prism/ReactPrism.css'; -// using classNames .prism-dark .prism-light from ReactPrism.css - -export default function ReactPrism({ - children, -}: { - children: (ref: MutableRefObject) => ReactNode; -}) { - const codeRef = useRef(null); - - useEffect(() => { - const el = codeRef.current; - if (el) Prism.highlightElement(el); - }, []); - - return <>{children(codeRef as MutableRefObject)}; -} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6c51cdb31..85ca08500 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -28,6 +28,10 @@ export interface Settings { useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; + useSystemArboriumTheme: boolean; + arboriumThemeId?: string; + arboriumLightTheme?: string; + arboriumDarkTheme?: string; saturationLevel?: number; uniformIcons: boolean; isMarkdown: boolean; @@ -123,6 +127,10 @@ const defaultSettings: Settings = { useSystemTheme: true, lightThemeId: undefined, darkThemeId: undefined, + useSystemArboriumTheme: true, + arboriumThemeId: 'dracula', + arboriumLightTheme: 'github-light', + arboriumDarkTheme: 'dracula', saturationLevel: 100, uniformIcons: false, isMarkdown: true, diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 6ff53d9d5..8669b7fa5 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -106,6 +106,9 @@ export const CodeBlockInternal = style([ { padding: `${config.space.S200} ${config.space.S200} 0`, minWidth: toRem(200), + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + wordBreak: 'break-word', }, ]); diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts index da2386868..c1332cfc7 100644 --- a/src/app/utils/sanitize.test.ts +++ b/src/app/utils/sanitize.test.ts @@ -38,12 +38,12 @@ describe('sanitizeCustomHtml', () => { expect(result).toContain('data-mx-color="#ff0000"'); }); - it('only keeps the permitted attributes on each tag', () => { + it('keeps only the permitted attributes on each tag while preserving markdown metadata', () => { const result = sanitizeCustomHtml( - 'span' + - 'link' + - '
  1. item
' + - 'code' + + 'span' + + 'link' + + '
  1. item
' + + '
fn main() {}
' + '
maths
' ); @@ -51,19 +51,26 @@ describe('sanitizeCustomHtml', () => { expect(result).toContain('data-mx-bg-color="#00ff00"'); expect(result).toContain('data-mx-spoiler="spoiler"'); expect(result).toContain('data-mx-maths="x"'); - expect(result).not.toContain('data-md='); - expect(result).toContain('link'); + expect(result).toContain('data-md="**"'); + expect(result).toContain('href="https://example.com"'); + expect(result).toContain('target="_blank"'); + expect(result).toContain('data-md="[]()"'); expect(result).not.toContain('rel='); - expect(result).toContain('
    '); + expect(result).toContain('
      '); expect(result).not.toContain('type='); - expect(result).not.toContain('class='); + expect(result).toContain('class="language-rust"'); + expect(result).toContain('data-lang="rust"'); + expect(result).toContain('data-md="```"'); expect(result).toContain('
      maths
      '); }); - it('preserves a code class only when every class starts with language-', () => { + it('preserves a language class only when every class starts with language-', () => { expect(sanitizeCustomHtml('code')).toContain( 'class="language-typescript"' ); + expect(sanitizeCustomHtml('
      code
      ')).toContain( + 'class="language-rust"' + ); expect( sanitizeCustomHtml('code') ).toContain('class="language-typescript language-js"'); @@ -88,27 +95,22 @@ describe('sanitizeCustomHtml', () => { 'matrix' ); expect(sanitizeCustomHtml('bad')).toBe('bad'); + expect(sanitizeCustomHtml('bad')).toBe('bad'); }); - it('keeps only mxc image sources', () => { - const allowed = sanitizeCustomHtml('img'); + it('keeps only mxc image sources and preserves custom-emote markers', () => { + const allowed = sanitizeCustomHtml( + 'blobcat' + ); const blocked = sanitizeCustomHtml('img'); expect(allowed).toContain(' { - const result = sanitizeCustomHtml( - 'blobcat' - ); - - expect(result).toContain('data-mx-emoticon'); - expect(result).toContain('src="mxc://example.com/abc123"'); - expect(result).toContain('alt="blobcat"'); - }); - it('restores only one validated image src after masking duplicate image source attributes', () => { const result = sanitizeCustomHtml( 'img' diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 28a1374e9..301b942c5 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -45,12 +45,29 @@ const permittedHtmlTags = [ ] as const; const permittedTagToAttributes = { - span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths'], - a: ['target', 'href'], + span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths', 'data-md'], + a: ['target', 'href', 'data-md'], img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], // data-mx-emoticon is for MSC2545 - ol: ['start'], - code: ['class'], + ol: ['start', 'data-md'], + ul: ['data-md'], + code: ['class', 'data-md', 'data-lang'], + pre: ['class', 'data-md', 'data-lang'], div: ['data-mx-maths'], + blockquote: ['data-md'], + h1: ['data-md'], + h2: ['data-md'], + h3: ['data-md'], + h4: ['data-md'], + h5: ['data-md'], + h6: ['data-md'], + strong: ['data-md'], + i: ['data-md'], + em: ['data-md'], + u: ['data-md'], + s: ['data-md'], + del: ['data-md'], + sub: ['data-md'], + hr: ['data-md'], } as const satisfies Record; const permittedHtmlAttributes = Array.from(new Set(Object.values(permittedTagToAttributes).flat())); @@ -147,7 +164,7 @@ function getValidatedAttributeValue( return undefined; } - if (tagName === 'code' && attrName === 'class') { + if ((tagName === 'code' || tagName === 'pre') && attrName === 'class') { return normalizeCodeClasses(attrValue); } diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 66c4f5274..270d60a11 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -40,7 +40,16 @@ describe('NON_SYNCABLE_KEYS', () => { }); it('does not include ordinary syncable keys', () => { - const syncable = ['isMarkdown', 'twitterEmoji', 'messageLayout', 'urlPreview'] as const; + const syncable = [ + 'isMarkdown', + 'twitterEmoji', + 'messageLayout', + 'urlPreview', + 'useSystemArboriumTheme', + 'arboriumThemeId', + 'arboriumLightTheme', + 'arboriumDarkTheme', + ] as const; syncable.forEach((key) => { expect(NON_SYNCABLE_KEYS.has(key)).toBe(false); });