diff --git a/.changeset/change-settings-link-markers.md b/.changeset/change-settings-link-markers.md new file mode 100644 index 000000000..fba906673 --- /dev/null +++ b/.changeset/change-settings-link-markers.md @@ -0,0 +1,13 @@ +--- +default: patch +--- + +# Change how settings links are shared + +Settings links copied from Sable now stay on the current client URL and include a small Sable marker in the link. That lets Sable recognize settings links copied from other Sable instances without treating unrelated third-party `/settings/...` links as Sable settings links. + +When you send a bare settings link in the composer, Sable now rewrites it into a labeled link so it looks better on non-Sable clients too. For example: `[Settings > Account > Display Name](https://client.example/settings/account?focus=display-name&moe.sable.client.action=settings)`. + +Invalid or malformed settings-looking links now stay normal links instead of being shown as settings chips. + +If you previously set `settingsLinkBaseUrl` in `config.json`, remove it. Sable now derives settings links from the runtime app URL, and the old config key is no longer used. diff --git a/.changeset/remove-dynamic-settings-links.md b/.changeset/remove-dynamic-settings-links.md new file mode 100644 index 000000000..93a2d76e5 --- /dev/null +++ b/.changeset/remove-dynamic-settings-links.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Hide copied settings links on dynamic rows diff --git a/config.json b/config.json index f0c3c8b61..1bdffb675 100644 --- a/config.json +++ b/config.json @@ -13,8 +13,6 @@ "webPushAppID": "moe.sable.app.sygnal" }, - "settingsLinkBaseUrl": "https://app.sable.moe", - "slidingSync": { "enabled": true }, diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx index d795542fa..191fa23b4 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MsgType } from '$types/matrix-sdk'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { RenderMessageContent } from './RenderMessageContent'; @@ -13,9 +13,9 @@ vi.mock('./url-preview', () => ({ youtubeUrl: () => false, })); -function renderMessage(body: string, settingsLinkBaseUrl = 'https://app.sable.moe') { +function renderMessage(body: string) { return render( - + { + vi.unstubAllGlobals(); +}); + describe('RenderMessageContent', () => { it('does not render url previews for settings links', () => { - renderMessage('https://app.sable.moe/settings/account?focus=status'); + renderMessage( + 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings' + ); expect(screen.queryByTestId('url-preview-holder')).not.toBeInTheDocument(); expect(screen.queryByTestId('url-preview-card')).not.toBeInTheDocument(); @@ -45,4 +55,24 @@ describe('RenderMessageContent', () => { expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com'); }); + + it('still renders url previews for malformed settings-looking links', () => { + renderMessage( + 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings' + ); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent( + 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings' + ); + }); + + it('still renders url previews for settings links with unknown focus ids', () => { + renderMessage('https://app.example/settings/account?focus=display-name2'); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent( + 'https://app.example/settings/account?focus=display-name2' + ); + }); }); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 383678e1a..453256881 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -90,7 +90,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { )}" title="${sanitizeText(node.shortcode)}" height="32" />` : sanitizeText(node.key); case BlockType.Link: - return `${node.children}`; + return `${children}`; case BlockType.Command: return `/${sanitizeText(node.command)}`; default: @@ -184,7 +184,7 @@ const elementToPlainText = (node: CustomElement, children: string): string => { case BlockType.Emoticon: return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; case BlockType.Link: - return `[${node.children}](${node.href})`; + return `[${children}](${node.href})`; case BlockType.Command: return `/${node.command}`; case BlockType.Small: diff --git a/src/app/components/setting-tile/SettingTile.test.tsx b/src/app/components/setting-tile/SettingTile.test.tsx index da5d25c10..b5e87e2c1 100644 --- a/src/app/components/setting-tile/SettingTile.test.tsx +++ b/src/app/components/setting-tile/SettingTile.test.tsx @@ -49,7 +49,7 @@ describe('SettingTile', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://settings.example/settings/appearance?focus=message-link-preview' + 'https://settings.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' ); }); expect(screen.getByRole('button', { name: /copied settings link/i })).toBeInTheDocument(); @@ -64,7 +64,7 @@ describe('SettingTile', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://settings.example/settings/appearance?focus=message-link-preview' + 'https://settings.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' ); }); expect(screen.getByRole('button', { name: /copy settings link/i })).toBeInTheDocument(); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index bc30145c8..78f5d8f3f 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -147,6 +147,7 @@ import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supporte import { sanitizeText } from '$utils/sanitize'; import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -155,6 +156,7 @@ import { getImageMsgContent, getVideoMsgContent, } from './msgContent'; +import { outgoingMessageTransforms } from './outgoingMessageTransforms'; import { CommandAutocomplete } from './CommandAutocomplete'; import { AudioMessageRecorder, @@ -251,6 +253,7 @@ export const RoomInput = forwardRef( const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const commands = useCommands(mx, room); /** * handle pluralkit-style messages @@ -721,12 +724,23 @@ export const RoomInput = forwardRef( /** * the plain text we will send */ - let plainText = toPlainText(editor.children, isMarkdown, true, nicknameReplacement).trim(); + let serializedChildren = editor.children; + const outgoingTransformContext = { + isMarkdown, + settingsLinkBaseUrl, + }; + + outgoingMessageTransforms.forEach((transform) => { + if (!transform.shouldApply(serializedChildren, outgoingTransformContext)) return; + serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); + }); + + let plainText = toPlainText(serializedChildren, isMarkdown, true, nicknameReplacement).trim(); /** * the html we will send */ let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { + toMatrixCustomHTML(serializedChildren, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, @@ -961,6 +975,7 @@ export const RoomInput = forwardRef( queryClient, threadRootId, setReplyDraft, + settingsLinkBaseUrl, isEncrypted, setEditingScheduledDelayId, setScheduledTime, diff --git a/src/app/features/room/outgoingMessageTransforms.ts b/src/app/features/room/outgoingMessageTransforms.ts new file mode 100644 index 000000000..37c5d1a1c --- /dev/null +++ b/src/app/features/room/outgoingMessageTransforms.ts @@ -0,0 +1,28 @@ +import { Descendant } from 'slate'; +import { + hasSettingsLinksToRewriteInDescendants, + rewriteSettingsLinksInDescendants, +} from './settingsLinkMessage'; + +export type OutgoingMessageTransformContext = { + isMarkdown: boolean; + settingsLinkBaseUrl: string; +}; + +export type OutgoingMessageTransform = { + apply: (children: Descendant[], context: OutgoingMessageTransformContext) => Descendant[]; + shouldApply: (children: Descendant[], context: OutgoingMessageTransformContext) => boolean; +}; + +export const outgoingMessageTransforms: OutgoingMessageTransform[] = [ + { + apply: (children, context) => + rewriteSettingsLinksInDescendants(children, context.settingsLinkBaseUrl, context.isMarkdown), + shouldApply: (children, context) => + hasSettingsLinksToRewriteInDescendants( + children, + context.settingsLinkBaseUrl, + context.isMarkdown + ), + }, +]; diff --git a/src/app/features/room/settingsLinkMessage.test.ts b/src/app/features/room/settingsLinkMessage.test.ts new file mode 100644 index 000000000..c769357f7 --- /dev/null +++ b/src/app/features/room/settingsLinkMessage.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { toMatrixCustomHTML, toPlainText, trimCustomHtml } from '$components/editor/output'; +import { BlockType } from '$components/editor/types'; +import { + hasSettingsLinksToRewriteInDescendants, + rewriteSettingsLinksInDescendants, +} from './settingsLinkMessage'; + +const settingsUrl = + 'https://app.example/settings/account?focus=display-name&moe.sable.client.action=settings'; +const settingsUrlWithExtraParam = + 'https://app.example/settings/account?focus=display-name&moe.sable.client.action=settings&hello=world'; +const invalidSettingsUrl = + 'https://app.example/settings/account?focus=display-name2&moe.sable.client.action=settings'; + +describe('settingsLinkMessage', () => { + it('detects bare settings links that need outgoing rewriting', () => { + expect( + hasSettingsLinksToRewriteInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: settingsUrl }], + }, + ], + 'https://app.example' + ) + ).toBe(true); + }); + + it('rewrites bare settings links into message-friendly labels before serialization', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: settingsUrl }], + }, + ], + 'https://app.example' + ); + + expect(toPlainText(rewritten, false).trim()).toBe( + `[Settings > Account > Display Name](${settingsUrl})` + ); + expect( + trimCustomHtml( + toMatrixCustomHTML(rewritten, { + allowTextFormatting: true, + allowBlockMarkdown: false, + allowInlineMarkdown: false, + }) + ) + ).toBe(`Settings > Account > Display Name`); + }); + + it('rewrites same-base settings links with extra query params', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: settingsUrlWithExtraParam }], + }, + ], + 'https://app.example' + ); + + expect(toPlainText(rewritten, false).trim()).toBe( + `[Settings > Account > Display Name](${settingsUrlWithExtraParam})` + ); + }); + + it('does not rewrite settings links that are already in markdown link syntax', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: `[Display Name](${settingsUrl})` }], + }, + ], + 'https://app.example' + ); + + expect(toPlainText(rewritten, true).trim()).toBe(`[Display Name](${settingsUrl})`); + }); + + it('does not rewrite settings links inside code blocks', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.CodeBlock, + children: [ + { + type: BlockType.CodeLine, + children: [{ text: settingsUrl }], + }, + ], + }, + ], + 'https://app.example' + ); + + expect(toPlainText(rewritten, false).trim()).toBe(settingsUrl); + expect( + trimCustomHtml( + toMatrixCustomHTML(rewritten, { + allowTextFormatting: true, + allowBlockMarkdown: false, + allowInlineMarkdown: false, + }) + ) + ).not.toContain(' { + expect( + hasSettingsLinksToRewriteInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: `\`${settingsUrl}\`` }], + }, + ], + 'https://app.example', + true + ) + ).toBe(false); + + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: `\`${settingsUrl}\`` }], + }, + ], + 'https://app.example', + true + ); + + expect(toPlainText(rewritten, true).trim()).toBe(`\`${settingsUrl}\``); + expect( + trimCustomHtml( + toMatrixCustomHTML(rewritten, { + allowTextFormatting: true, + allowBlockMarkdown: false, + allowInlineMarkdown: true, + }) + ) + ).not.toContain('Settings > Account > Display Name'); + }); + + it('does not rewrite settings links inside markdown autolinks', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: `<${settingsUrl}>` }], + }, + ], + 'https://app.example', + true + ); + + expect(toPlainText(rewritten, true).trim()).toBe(`<${settingsUrl}>`); + }); + + it('does not rewrite settings links inside literal html text', () => { + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: `Settings` }], + }, + ], + 'https://app.example', + true + ); + + expect(toPlainText(rewritten, true).trim()).toBe(`Settings`); + }); + + it('does not rewrite settings links with unknown focus ids', () => { + expect( + hasSettingsLinksToRewriteInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: invalidSettingsUrl }], + }, + ], + 'https://app.example' + ) + ).toBe(false); + + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: invalidSettingsUrl }], + }, + ], + 'https://app.example' + ); + + expect(toPlainText(rewritten, false).trim()).toBe(invalidSettingsUrl); + }); + + it('rewrites plain same-base hash-router settings links when given the runtime app base', () => { + const hashRouterSettingsUrl = 'https://app.example/#/app/settings/account?focus=display-name'; + const rewritten = rewriteSettingsLinksInDescendants( + [ + { + type: BlockType.Paragraph, + children: [{ text: hashRouterSettingsUrl }], + }, + ], + 'https://app.example/#/app' + ); + + expect(toPlainText(rewritten, false).trim()).toBe( + `[Settings > Account > Display Name](${hashRouterSettingsUrl})` + ); + }); +}); diff --git a/src/app/features/room/settingsLinkMessage.ts b/src/app/features/room/settingsLinkMessage.ts new file mode 100644 index 000000000..8a8ee46c2 --- /dev/null +++ b/src/app/features/room/settingsLinkMessage.ts @@ -0,0 +1,285 @@ +import { find as findLinks } from 'linkifyjs'; +import { Descendant, Text } from 'slate'; +import type { + BlockQuoteElement, + FormattedText, + HeadingElement, + InlineElement, + ListItemElement, + OrderedListElement, + ParagraphElement, + QuoteLineElement, + SmallElement, + UnorderedListElement, +} from '$components/editor/slate'; +import { BlockType } from '$components/editor/types'; +import { createLinkElement } from '$components/editor/utils'; +import { getSettingsLinkLabel, parseSettingsLink } from '$features/settings/settingsLink'; + +type RewritableSettingsLinkMatch = { + end: number; + href: string; + label: string; + start: number; +}; + +const isMarkdownSettingsLink = (text: string, start: number, end: number): boolean => + text.slice(0, start).endsWith('](') && text.slice(end).startsWith(')'); + +const getMarkdownCodeSpanRanges = (text: string): [number, number][] => { + const ranges: [number, number][] = []; + let openRun: { start: number; length: number } | undefined; + + for (let index = 0; index < text.length; index += 1) { + if (text[index] === '`') { + let runEnd = index; + while (runEnd < text.length && text[runEnd] === '`') { + runEnd += 1; + } + + const runLength = runEnd - index; + if (!openRun) { + openRun = { start: index, length: runLength }; + } else if (openRun.length === runLength) { + ranges.push([openRun.start, runEnd]); + openRun = undefined; + } + + index = runEnd - 1; + } + } + + return ranges; +}; + +const isInsideMarkdownCodeSpan = ( + start: number, + end: number, + codeSpanRanges: [number, number][] +): boolean => codeSpanRanges.some(([rangeStart, rangeEnd]) => start > rangeStart && end < rangeEnd); + +const isMarkdownAutolink = (text: string, start: number, end: number): boolean => + text[start - 1] === '<' && text[end] === '>'; + +const isInsideHtmlTag = (text: string, start: number): boolean => { + const tagStart = text.lastIndexOf('<', start); + if (tagStart === -1) return false; + + const tagEnd = text.lastIndexOf('>', start); + if (tagEnd > tagStart) return false; + + return /^<\/?[A-Za-z][^>]*$/.test(text.slice(tagStart, start)); +}; + +const isProtectedMarkdownContext = ( + text: string, + start: number, + end: number, + isMarkdown: boolean, + codeSpanRanges: [number, number][] +): boolean => + isMarkdownSettingsLink(text, start, end) || + (isMarkdown && + (isInsideMarkdownCodeSpan(start, end, codeSpanRanges) || + isMarkdownAutolink(text, start, end) || + isInsideHtmlTag(text, start))); + +const getRewritableSettingsLinkMatches = ( + text: string, + baseUrl: string, + isMarkdown: boolean +): RewritableSettingsLinkMatch[] => { + const matches = findLinks(text, 'url'); + if (matches.length === 0) return []; + + const codeSpanRanges = isMarkdown ? getMarkdownCodeSpanRanges(text) : []; + + return matches.flatMap((match) => { + const href = match.value; + const settingsLink = parseSettingsLink(baseUrl, href); + + if ( + !settingsLink || + isProtectedMarkdownContext(text, match.start, match.end, isMarkdown, codeSpanRanges) + ) { + return []; + } + + return [ + { + end: match.end, + href, + label: getSettingsLinkLabel(settingsLink.section, settingsLink.focus), + start: match.start, + }, + ]; + }); +}; + +const hasRewritableSettingsLinksInInlineChildren = ( + children: InlineElement[], + baseUrl: string, + isMarkdown: boolean +): boolean => + children.some( + (child) => + Text.isText(child) && + getRewritableSettingsLinkMatches(child.text, baseUrl, isMarkdown).length > 0 + ); + +const createTextSegment = (node: FormattedText, text: string): FormattedText => ({ + ...node, + text, +}); + +const rewriteInlineText = ( + node: FormattedText, + baseUrl: string, + isMarkdown: boolean +): InlineElement[] => { + const matches = getRewritableSettingsLinkMatches(node.text, baseUrl, isMarkdown); + if (matches.length === 0) return [node]; + + const rewritten: InlineElement[] = []; + let cursor = 0; + + matches.forEach((match) => { + if (cursor < match.start) { + rewritten.push(createTextSegment(node, node.text.slice(cursor, match.start))); + } + + rewritten.push(createLinkElement(match.href, [createTextSegment(node, match.label)])); + cursor = match.end; + }); + + if (rewritten.length === 0) return [node]; + + if (cursor < node.text.length) { + rewritten.push(createTextSegment(node, node.text.slice(cursor))); + } + + return rewritten.filter((child) => !Text.isText(child) || child.text.length > 0); +}; + +const rewriteInlineChildren = ( + children: InlineElement[], + baseUrl: string, + isMarkdown: boolean +): InlineElement[] => + children.flatMap((child) => + Text.isText(child) ? rewriteInlineText(child, baseUrl, isMarkdown) : [child] + ); + +const rewriteInlineContainer = < + T extends ParagraphElement | HeadingElement | QuoteLineElement | ListItemElement | SmallElement, +>( + node: T, + baseUrl: string, + isMarkdown: boolean +): T => ({ + ...node, + children: rewriteInlineChildren(node.children, baseUrl, isMarkdown), +}); + +const rewriteBlockQuote = ( + node: BlockQuoteElement, + baseUrl: string, + isMarkdown: boolean +): BlockQuoteElement => ({ + ...node, + children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), +}); + +const rewriteOrderedList = ( + node: OrderedListElement, + baseUrl: string, + isMarkdown: boolean +): OrderedListElement => ({ + ...node, + children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), +}); + +const rewriteUnorderedList = ( + node: UnorderedListElement, + baseUrl: string, + isMarkdown: boolean +): UnorderedListElement => ({ + ...node, + children: node.children.map((child) => rewriteInlineContainer(child, baseUrl, isMarkdown)), +}); + +const hasSettingsLinksToRewriteInNode = ( + node: Descendant, + baseUrl: string, + isMarkdown: boolean +): boolean => { + if (Text.isText(node)) { + return getRewritableSettingsLinkMatches(node.text, baseUrl, isMarkdown).length > 0; + } + + switch (node.type) { + case BlockType.Paragraph: + case BlockType.Heading: + case BlockType.QuoteLine: + case BlockType.ListItem: + case BlockType.Small: + return hasRewritableSettingsLinksInInlineChildren(node.children, baseUrl, isMarkdown); + case BlockType.BlockQuote: + case BlockType.OrderedList: + case BlockType.UnorderedList: + return node.children.some((child) => + hasSettingsLinksToRewriteInNode(child, baseUrl, isMarkdown) + ); + case BlockType.CodeBlock: + case BlockType.CodeLine: + case BlockType.HorizontalRule: + case BlockType.Link: + case BlockType.Mention: + case BlockType.Emoticon: + case BlockType.Command: + return false; + default: + return false; + } +}; + +const rewriteNode = (node: Descendant, baseUrl: string, isMarkdown: boolean): Descendant => { + if (Text.isText(node)) return node; + + switch (node.type) { + case BlockType.Paragraph: + case BlockType.Heading: + case BlockType.QuoteLine: + case BlockType.ListItem: + case BlockType.Small: + return rewriteInlineContainer(node, baseUrl, isMarkdown); + case BlockType.BlockQuote: + return rewriteBlockQuote(node, baseUrl, isMarkdown); + case BlockType.OrderedList: + return rewriteOrderedList(node, baseUrl, isMarkdown); + case BlockType.UnorderedList: + return rewriteUnorderedList(node, baseUrl, isMarkdown); + case BlockType.CodeBlock: + case BlockType.CodeLine: + case BlockType.HorizontalRule: + case BlockType.Link: + case BlockType.Mention: + case BlockType.Emoticon: + case BlockType.Command: + return node; + default: + return node; + } +}; + +export const rewriteSettingsLinksInDescendants = ( + children: Descendant[], + baseUrl: string, + isMarkdown = false +): Descendant[] => children.map((child) => rewriteNode(child, baseUrl, isMarkdown)); + +export const hasSettingsLinksToRewriteInDescendants = ( + children: Descendant[], + baseUrl: string, + isMarkdown = false +): boolean => children.some((child) => hasSettingsLinksToRewriteInNode(child, baseUrl, isMarkdown)); diff --git a/src/app/features/settings/Settings.test.tsx b/src/app/features/settings/Settings.test.tsx index 0ad55b17b..8a77e3c36 100644 --- a/src/app/features/settings/Settings.test.tsx +++ b/src/app/features/settings/Settings.test.tsx @@ -12,8 +12,6 @@ const { mockMatrixClient, mockProfile } = vi.hoisted(() => ({ mockProfile: { displayName: 'Alice', avatarUrl: undefined }, })); -let settingsLinkBaseUrlOverride: string | undefined; - vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => mockMatrixClient, })); @@ -27,13 +25,7 @@ vi.mock('$hooks/useMediaAuthentication', () => ({ })); vi.mock('$state/hooks/settings', () => ({ - useSetting: (_atom: unknown, key: string) => { - if (key === 'settingsLinkBaseUrlOverride') { - return [settingsLinkBaseUrlOverride, vi.fn()] as const; - } - - return [true, vi.fn()] as const; - }, + useSetting: () => [true, vi.fn()] as const, })); vi.mock('$state/settings', () => ({ @@ -137,7 +129,6 @@ vi.mock('./keyboard-shortcuts', () => ({ beforeEach(() => { writeText.mockReset(); - settingsLinkBaseUrlOverride = undefined; vi.stubGlobal('location', { origin: 'https://app.example' } as Location); vi.stubGlobal('navigator', { clipboard: { writeText } } as unknown as Navigator); }); @@ -147,11 +138,11 @@ afterEach(() => { }); describe('Settings', () => { - it('uses the configured settings link base URL for copied settings links', async () => { + it('uses the current app origin for copied settings links', async () => { writeText.mockResolvedValueOnce(undefined); render( - + @@ -162,17 +153,16 @@ describe('Settings', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://config.example/settings/appearance?focus=message-link-preview' + 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' ); }); }); - it('prefers the client-side override over the configured settings link base URL', async () => { + it('preserves the configured hash-router basename in copied settings links', async () => { writeText.mockResolvedValueOnce(undefined); - settingsLinkBaseUrlOverride = 'https://override.example/'; render( - + @@ -183,7 +173,7 @@ describe('Settings', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://override.example/settings/appearance?focus=message-link-preview' + 'https://app.example/#/app/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' ); }); }); diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index e65c925bc..947a92a9b 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -855,4 +855,25 @@ describe('useSettingsFocus', () => { vi.useRealTimers(); } }); + + it('ignores malformed focus ids without throwing', () => { + expect(() => + render( + + + + + + + ) + ).not.toThrow(); + + const target = document.querySelector('[data-settings-focus="message-link-preview"]'); + const highlightTarget = target?.parentElement; + + expect(highlightTarget).not.toHaveClass(focusedSettingTile); + expect(screen.getByTestId('location-probe')).toHaveTextContent( + '/settings/appearance?focus=display-name%22%3ESettings' + ); + }); }); diff --git a/src/app/features/settings/account/Profile.test.tsx b/src/app/features/settings/account/Profile.test.tsx new file mode 100644 index 000000000..e58495b8e --- /dev/null +++ b/src/app/features/settings/account/Profile.test.tsx @@ -0,0 +1,122 @@ +import { render, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { SettingsLinkProvider } from '$features/settings/SettingsLinkContext'; +import { Profile } from './Profile'; + +const mockMatrixClient = { + getUserId: () => '@alice:example.org', + setAvatarUrl: vi.fn(), + setDisplayName: vi.fn(), + setExtendedProfileProperty: vi.fn(), + setPresence: vi.fn(), +}; + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMatrixClient, +})); + +vi.mock('$hooks/useUserProfile', () => ({ + useUserProfile: () => ({ + displayName: 'Alice', + extended: { + 'example.favorite': 'Blue', + }, + }), +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + +vi.mock('$hooks/useCapabilities', () => ({ + useCapabilities: () => ({}), +})); + +vi.mock('$hooks/useUserPresence', () => ({ + useUserPresence: () => undefined, +})); + +vi.mock('$hooks/useFilePicker', () => ({ + useFilePicker: () => vi.fn(), +})); + +vi.mock('$hooks/useObjectURL', () => ({ + useObjectURL: () => undefined, +})); + +vi.mock('$hooks/useAsyncCallback', () => ({ + AsyncStatus: { + Idle: 'idle', + Loading: 'loading', + Success: 'success', + Error: 'error', + }, + useAsyncCallback: (callback: (...args: unknown[]) => unknown) => [ + { status: 'idle' }, + callback, + vi.fn(), + ], +})); + +vi.mock('$components/user-avatar', () => ({ + UserAvatar: () =>
Avatar
, +})); + +vi.mock('jotai', async () => { + const actual = await vi.importActual('jotai'); + return { + ...actual, + useSetAtom: () => vi.fn(), + }; +}); + +vi.mock('./TimezoneEditor', () => ({ + TimezoneEditor: () =>
Timezone
, +})); + +vi.mock('./PronounEditor', () => ({ + PronounEditor: () =>
Pronouns
, +})); + +vi.mock('./BioEditor', () => ({ + BioEditor: () =>
Bio
, +})); + +vi.mock('./NameColorEditor', () => ({ + NameColorEditor: () =>
Name Color
, +})); + +vi.mock('./StatusEditor', () => ({ + StatusEditor: () =>
Status
, +})); + +vi.mock('./AnimalCosmetics', () => ({ + AnimalCosmetics: () =>
Animal Cosmetics
, +})); + +describe('Profile', () => { + it('does not show copy settings links for custom profile fields', () => { + const { container } = render( + + + + + + ); + + const displayNameTile = container.querySelector('[data-settings-focus="display-name"]'); + expect(displayNameTile).not.toBeNull(); + expect( + within(displayNameTile as HTMLElement).getByRole('button', { name: /copy settings link/i }) + ).toBeInTheDocument(); + + const customFieldTile = container.querySelector( + '[data-settings-focus="profile-field-example-favorite"]' + ); + expect(customFieldTile).not.toBeNull(); + expect( + within(customFieldTile as HTMLElement).queryByRole('button', { name: /copy settings link/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index da2d140f6..8ee4d9b35 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -658,6 +658,7 @@ function ProfileExtended({ profile, userId }: Readonly) { undefined, +} as never; + +vi.mock('$hooks/useImagePacks', () => ({ + useGlobalImagePacks: () => [globalPack], + useRoomsImagePacks: () => [], +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({ + getRoom: () => undefined, + getAccountData: () => undefined, + setAccountData: vi.fn(), + }), +})); + +vi.mock('jotai', async () => { + const actual = await vi.importActual('jotai'); + return { + ...actual, + useAtomValue: () => [], + }; +}); + +describe('GlobalPacks', () => { + it('does not show copy settings links for individual pack rows', () => { + const { container } = render( + + + + + + ); + + const selectorTile = container.querySelector('[data-settings-focus="select-pack"]'); + expect(selectorTile).not.toBeNull(); + expect( + within(selectorTile as HTMLElement).getByRole('button', { name: /copy settings link/i }) + ).toBeInTheDocument(); + + const packTile = container.querySelector('[data-settings-focus="selected-pack-pack-1"]'); + expect(packTile).not.toBeNull(); + expect(screen.getByText('Animals')).toBeInTheDocument(); + expect( + within(packTile as HTMLElement).queryByRole('button', { name: /copy settings link/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index 224ed4934..622c12927 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -187,6 +187,7 @@ function GlobalPackSelector({ {pack.meta.attribution}} before={ @@ -361,6 +362,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { > {pack.meta.name ?? 'Unknown'} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 291e76f2f..6a5f7d5c5 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -54,7 +54,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; import { SettingsSectionPage } from '../SettingsSectionPage'; -import { SettingsLinkBaseUrlSetting } from './SettingsLinkBaseUrlSetting'; type DateHintProps = { hasChanges: boolean; @@ -1072,9 +1071,6 @@ function Messages() { } /> - - - ({ - useSetting: () => [settingsLinkBaseUrlOverride, setSettingsLinkBaseUrlOverride] as const, -})); - -vi.mock('$state/settings', () => ({ - settingsAtom: {}, -})); - -function renderSetting(settingsLinkBaseUrl = 'https://app.sable.moe') { - return render( - - - - ); -} - -describe('SettingsLinkBaseUrlSetting', () => { - beforeEach(() => { - settingsLinkBaseUrlOverride = undefined; - setSettingsLinkBaseUrlOverride.mockReset(); - }); - - it('uses Url casing in the visible setting title', () => { - renderSetting('https://config.example'); - - expect(screen.getByText('Settings Link Base Url')).toBeInTheDocument(); - }); - - it('shows the configured default in the input and no separate reset button', () => { - renderSetting('https://config.example'); - - expect(screen.getByRole('textbox', { name: 'Settings link base URL' })).toHaveValue( - 'https://config.example' - ); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument(); - }); - - it('uses an inline reset control to restore the configured default URL', () => { - renderSetting('https://config.example'); - - fireEvent.change(screen.getByRole('textbox', { name: 'Settings link base URL' }), { - target: { value: 'https://override.example' }, - }); - - expect( - screen.getByRole('button', { name: 'Reset settings link base URL' }) - ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Reset settings link base URL' })); - - expect(screen.getByRole('textbox', { name: 'Settings link base URL' })).toHaveValue( - 'https://config.example' - ); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - }); - - it('clears the override when saving the configured default URL', () => { - settingsLinkBaseUrlOverride = 'https://override.example'; - renderSetting('https://config.example'); - - fireEvent.change(screen.getByRole('textbox', { name: 'Settings link base URL' }), { - target: { value: 'https://config.example' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - expect(setSettingsLinkBaseUrlOverride).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx deleted file mode 100644 index deb6b760b..000000000 --- a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { ChangeEventHandler, FormEventHandler, useEffect, useMemo, useState } from 'react'; -import { Box, Button, config, Icon, IconButton, Icons, Input, Text } from 'folds'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { SettingTile } from '$components/setting-tile'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; -import { getConfiguredSettingsLinkBaseUrl, normalizeSettingsLinkBaseUrl } from '../settingsLink'; - -export function SettingsLinkBaseUrlSetting() { - const clientConfig = useClientConfig(); - const [settingsLinkBaseUrlOverride, setSettingsLinkBaseUrlOverride] = useSetting( - settingsAtom, - 'settingsLinkBaseUrlOverride' - ); - const configuredBaseUrl = useMemo( - () => getConfiguredSettingsLinkBaseUrl(clientConfig), - [clientConfig] - ); - const currentValue = - normalizeSettingsLinkBaseUrl(settingsLinkBaseUrlOverride) ?? configuredBaseUrl; - const [inputValue, setInputValue] = useState(currentValue); - - useEffect(() => { - setInputValue(currentValue); - }, [currentValue]); - - const trimmedValue = inputValue.trim(); - const normalizedInputValue = normalizeSettingsLinkBaseUrl(trimmedValue); - const nextOverrideValue = - normalizedInputValue && normalizedInputValue !== configuredBaseUrl - ? normalizedInputValue - : undefined; - const hasChanges = normalizedInputValue !== currentValue; - const isValid = Boolean(normalizedInputValue); - - const handleChange: ChangeEventHandler = (evt) => { - setInputValue(evt.currentTarget.value); - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (!isValid) return; - - setSettingsLinkBaseUrlOverride(nextOverrideValue); - setInputValue(normalizedInputValue ?? configuredBaseUrl); - }; - - const handleReset = () => { - setInputValue(configuredBaseUrl); - }; - - return ( - - - - - - - - ) - } - /> - - - - - {!isValid && ( - - Enter a full `http://` or `https://` URL. - - )} - - ); -} diff --git a/src/app/features/settings/notifications/KeywordMessages.test.tsx b/src/app/features/settings/notifications/KeywordMessages.test.tsx new file mode 100644 index 000000000..dde430909 --- /dev/null +++ b/src/app/features/settings/notifications/KeywordMessages.test.tsx @@ -0,0 +1,71 @@ +import { render, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { SettingsLinkProvider } from '$features/settings/SettingsLinkContext'; +import { KeywordMessagesNotifications } from './KeywordMessages'; + +vi.mock('$hooks/useAccountData', () => ({ + useAccountData: () => ({ + getContent: () => ({ + global: { + content: [ + { + rule_id: 'kitty', + pattern: 'kitty', + default: false, + actions: [], + }, + ], + }, + }), + }), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({ + addPushRule: vi.fn(), + deletePushRule: vi.fn(), + setPushRuleActions: vi.fn(), + }), +})); + +vi.mock('$hooks/useNotificationMode', () => ({ + NotificationMode: { + Notify: 'notify', + }, + getNotificationModeActions: () => [], + useNotificationActionsMode: () => 'notify', + useNotificationModeActions: () => () => [], +})); + +vi.mock('$components/setting-menu-selector', () => ({ + SettingMenuSelector: () =>
Mode
, +})); + +vi.mock('./NotificationLevelsHint', () => ({ + NotificationLevelsHint: () =>
Hint
, +})); + +describe('KeywordMessagesNotifications', () => { + it('does not show copy settings links for individual keyword rows', () => { + const { container } = render( + + + + + + ); + + const selectorTile = container.querySelector('[data-settings-focus="select-keyword"]'); + expect(selectorTile).not.toBeNull(); + expect( + within(selectorTile as HTMLElement).getByRole('button', { name: /copy settings link/i }) + ).toBeInTheDocument(); + + const keywordTile = container.querySelector('[data-settings-focus="keyword-kitty"]'); + expect(keywordTile).not.toBeNull(); + expect( + within(keywordTile as HTMLElement).queryByRole('button', { name: /copy settings link/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index 7eab66eb7..39de1e238 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -214,6 +214,7 @@ export function KeywordMessagesNotifications() { focusId={`keyword-${toSettingsFocusIdPart( pushRule.pattern ?? pushRule.rule_id ?? 'custom-keyword' )}`} + showSettingLinkAction={false} before={} after={} /> diff --git a/src/app/features/settings/settingsLink.test.ts b/src/app/features/settings/settingsLink.test.ts index cb695e28f..37b1e9921 100644 --- a/src/app/features/settings/settingsLink.test.ts +++ b/src/app/features/settings/settingsLink.test.ts @@ -1,54 +1,85 @@ import { describe, expect, it } from 'vitest'; import { - DEFAULT_SETTINGS_LINK_BASE_URL, buildSettingsLink, - getEffectiveSettingsLinkBaseUrl, + getSettingsLinkLabel, + normalizeSettingsFocusId, parseSettingsLink, + SETTINGS_LINK_ACTION_PARAM, + SETTINGS_LINK_ACTION_SETTINGS, toSettingsFocusIdPart, } from './settingsLink'; describe('settingsLink', () => { - it('builds settings links for plain and hash-router base urls', () => { + it('builds settings links with the explicit action marker for plain and hash-router base urls', () => { expect(buildSettingsLink('https://app.example', 'appearance', 'message-link-preview')).toBe( - 'https://app.example/settings/appearance?focus=message-link-preview' + `https://app.example/settings/appearance?focus=message-link-preview&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` ); expect( buildSettingsLink('https://app.example/#/app', 'appearance', 'message-link-preview') - ).toBe('https://app.example/#/app/settings/appearance?focus=message-link-preview'); + ).toBe( + `https://app.example/#/app/settings/appearance?focus=message-link-preview&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ); }); - it('resolves the settings link base URL from built-in default, config, and override', () => { - expect(getEffectiveSettingsLinkBaseUrl({}, undefined)).toBe(DEFAULT_SETTINGS_LINK_BASE_URL); - expect(getEffectiveSettingsLinkBaseUrl({}, true as never)).toBe(DEFAULT_SETTINGS_LINK_BASE_URL); + it('parses plain same-base settings links for compatibility', () => { expect( - getEffectiveSettingsLinkBaseUrl({ settingsLinkBaseUrl: 'https://config.example/' }) - ).toBe('https://config.example'); + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance?focus=message-link-preview' + ) + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); expect( - getEffectiveSettingsLinkBaseUrl( - { settingsLinkBaseUrl: 'https://config.example' }, - 'https://override.example/' + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance/?focus=message-link-preview' ) - ).toBe('https://override.example'); + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/account?focus=display-name&moe.sable.client.action=settings&hello=world' + ) + ).toEqual({ section: 'account', focus: 'display-name' }); + + expect(parseSettingsLink('https://app.example', 'https://app.example/home/')).toBeUndefined(); }); - it('parses settings links from the same app origin only', () => { + it('parses cross-base settings links only when the explicit action marker is present', () => { expect( parseSettingsLink( 'https://app.example', - 'https://app.example/settings/appearance?focus=message-link-preview' + `https://other.example/settings/appearance?focus=message-link-preview&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` ) ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + + expect( + parseSettingsLink( + 'https://app.example/#/app', + `https://other.example/#/client/settings/account?focus=status&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ) + ).toEqual({ section: 'account', focus: 'status' }); expect( parseSettingsLink( 'https://app.example', - 'https://app.example/settings/appearance/?focus=message-link-preview' + `https://other.example/settings/account?focus=status&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}&hello=world` ) - ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + ).toEqual({ section: 'account', focus: 'status' }); expect( parseSettingsLink('https://app.example', 'https://other.example/settings/appearance') ).toBeUndefined(); - expect(parseSettingsLink('https://app.example', 'https://app.example/home/')).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + `https://other.example/settings/appearance?${SETTINGS_LINK_ACTION_PARAM}=not-settings` + ) + ).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + `https://other.example/redirect?next=/settings/appearance?focus=status&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ) + ).toBeUndefined(); }); it('rejects a same-origin hash settings link that does not match the configured app base', () => { @@ -69,8 +100,72 @@ describe('settingsLink', () => { ).toBeUndefined(); }); + it('rejects settings links with malformed focus ids', () => { + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/account?focus=display-name%22%3ESettings' + ) + ).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + `https://other.example/settings/account?focus=display-name%22%3ESettings&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ) + ).toBeUndefined(); + }); + + it('rejects settings links with unknown focus ids', () => { + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/account?focus=display-name2' + ) + ).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + `https://other.example/settings/account?focus=display-name2&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ) + ).toBeUndefined(); + }); + + it('rejects settings links with malformed query params', () => { + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings%22%3ESettings' + ) + ).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/account?focus=status&hello=world%22%3ESettings' + ) + ).toBeUndefined(); + expect( + parseSettingsLink( + 'https://app.example', + `https://other.example/settings/account?focus=status&hello=world%22%3ESettings&${SETTINGS_LINK_ACTION_PARAM}=${SETTINGS_LINK_ACTION_SETTINGS}` + ) + ).toBeUndefined(); + }); + it('normalizes focus id parts', () => { expect(toSettingsFocusIdPart('@alice:example.org')).toBe('alice-example-org'); expect(toSettingsFocusIdPart('DEVICE-123')).toBe('device-123'); }); + + it('accepts only valid settings focus ids', () => { + expect(normalizeSettingsFocusId('display-name')).toBe('display-name'); + expect(normalizeSettingsFocusId('display-name">Settings')).toBeUndefined(); + expect(normalizeSettingsFocusId('')).toBeUndefined(); + }); + + it('builds human-readable settings link labels', () => { + expect(getSettingsLinkLabel('appearance', 'message-link-preview')).toBe( + 'Settings > Appearance > Message Link Preview' + ); + expect(getSettingsLinkLabel('account')).toBe('Settings > Account'); + }); }); diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index da652d28a..030472a25 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -1,76 +1,355 @@ -import type { ClientConfig } from '$hooks/useClientConfig'; import { getAppPathFromHref, getSettingsPath, withOriginBaseUrl } from '$pages/pathUtils'; -import { isSettingsSectionId, type SettingsSectionId } from './routes'; +import { isSettingsSectionId, settingsSections, type SettingsSectionId } from './routes'; export type SettingsLink = { section: SettingsSectionId; focus?: string; }; -export const DEFAULT_SETTINGS_LINK_BASE_URL = 'https://app.sable.moe'; +export const SETTINGS_LINK_ACTION_PARAM = 'moe.sable.client.action'; +export const SETTINGS_LINK_ACTION_SETTINGS = 'settings'; +const SETTINGS_FOCUS_ID_PATTERN = /^[a-z0-9-]+$/; +const SETTINGS_LINK_FORBIDDEN_QUERY_VALUE_PATTERN = /["<>]/; -export const normalizeSettingsLinkBaseUrl = (value?: string | null): string | undefined => { - if (typeof value !== 'string') return undefined; +const settingsSectionLabel = Object.fromEntries( + settingsSections.map((section) => [section.id, section.label]) +) as Record; - const trimmed = value.trim(); - if (!trimmed) return undefined; +const settingsLinkFocusIdsBySection: Record = { + general: [ + 'client-side-embeds', + 'custom-date-format', + 'date-format', + 'disable-media-auto-load', + 'display-bundled-embeds', + 'embed-youtube-links', + 'emoji-selector-threshold', + 'enable-swiping', + 'encrypted-room-embeds', + 'encrypted-room-url-preview', + 'enter-for-newline', + 'error-reporting', + 'file-description-placement', + 'hide-member-events-read-only-rooms', + 'hide-membership-change', + 'hide-profile-change', + 'hide-read-receipts', + 'hide-typing-indicators', + 'large-room-call-button', + 'markdown-formatting', + 'message-layout', + 'message-spacing', + 'presence-status', + 'reply-notifications', + 'right-aligned-bubbles', + 'right-swipe-action', + 'session-replay', + 'show-hidden-events', + 'show-redacted-message-tombstones', + 'sync-across-devices', + 'sync-status', + 'twenty-four-hour-time-format', + 'url-preview', + 'use-sliding-sync', + ], + account: [ + 'about-you', + 'avatar', + 'banner', + 'blocked-users', + 'display-name', + 'email-address', + 'has-cats', + 'is-cat', + 'matrix-id', + 'name-color', + 'name-color-dark-theme', + 'name-color-light-theme', + 'pronouns', + 'render-animals', + 'status', + 'timezone', + ], + persona: ['enable-pk-commands', 'enable-pk-shorthands'], + appearance: [ + 'autoplay-emojis', + 'autoplay-gifs', + 'autoplay-stickers', + 'blur-avatars', + 'blur-emotes', + 'blur-media', + 'code-block-dark-theme', + 'code-block-light-theme', + 'code-block-manual-theme', + 'code-block-system-theme', + 'collapse-folders-by-default', + 'colorful-names', + 'consistent-icon-style', + 'customize-dm-cards', + 'dark-theme', + 'jumbo-emoji-size', + 'light-theme', + 'manual-theme', + 'message-link-preview', + 'page-zoom', + 'pronoun-pills-for-all', + 'reduced-motion', + 'render-global-username-colors', + 'render-space-room-fonts', + 'render-space-room-username-colors', + 'saturation', + 'selected-language-for-pronouns', + 'show-easter-eggs', + 'show-pronoun-pills', + 'show-pronouns-only-in-selected-language', + 'subspace-hierarchy-limit', + 'system-theme', + 'twitter-emoji', + 'underline-links', + ], + notifications: [ + 'background-push-notifications', + 'clear-notifications-when-read-elsewhere', + 'contains-display-name', + 'contains-username', + 'direct-messages', + 'direct-messages-encrypted', + 'email-notification', + 'favicon-dot-mentions-only', + 'highlight-mentions', + 'in-app-notification-sound', + 'in-app-notifications', + 'mention-room', + 'mention-user-id', + 'reset-all-push-notifications', + 'rooms', + 'rooms-encrypted', + 'select-keyword', + 'show-dm-counts', + 'show-encrypted-message-content', + 'show-mention-counts', + 'show-message-content', + 'show-room-counts', + 'system-notifications', + ], + devices: [ + 'device-dashboard', + 'device-verification', + 'export-messages-data', + 'import-messages-data', + ], + emojis: ['default-pack', 'select-pack'], + 'developer-tools': [ + 'access-token', + 'enable-developer-tools', + 'export-debug-logs', + 'global-account-data', + 'sentry-category-call', + 'sentry-category-error', + 'sentry-category-general', + 'sentry-category-message', + 'sentry-category-network', + 'sentry-category-notification', + 'sentry-category-sync', + 'sentry-category-timeline', + 'sentry-category-ui', + 'session-activity', + 'session-error-budget', + 'session-replay', + 'traces-profiles', + ], + experimental: ['bandwidth-saving-emojis', 'sharehistory-command', 'show-personas-tab'], + about: [ + 'base-url', + 'clear-cache-and-reload', + 'domain', + 'federation-url', + 'homeserver-compiler', + 'homeserver-name', + 'homeserver-version', + 'report-an-issue', + ], + 'keyboard-shortcuts': [], +}; - try { - const url = new URL(trimmed); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return undefined; - } +const settingsLinkFocusIdsBySectionSet = settingsSections.reduce( + (acc, section) => { + acc[section.id] = new Set(settingsLinkFocusIdsBySection[section.id]); + return acc; + }, + {} as Record> +); - return url.toString().replace(/\/+$/, ''); - } catch { +export const normalizeSettingsFocusId = (focus?: string): string | undefined => { + if (!focus || !SETTINGS_FOCUS_ID_PATTERN.test(focus)) { + return undefined; + } + + return focus; +}; + +const isShareableSettingsFocusId = (section: SettingsSectionId, focus: string): boolean => + settingsLinkFocusIdsBySectionSet[section].has(focus); + +const parseSettingsLinkQuery = ( + search: string +): { focus?: string; hasActionMarker: boolean } | undefined => { + const params = new URLSearchParams(search); + + if ( + Array.from(params.entries()).some( + ([key, value]) => + SETTINGS_LINK_FORBIDDEN_QUERY_VALUE_PATTERN.test(key) || + SETTINGS_LINK_FORBIDDEN_QUERY_VALUE_PATTERN.test(value) + ) + ) { + return undefined; + } + + const focusValues = params.getAll('focus'); + if (focusValues.length > 1) { + return undefined; + } + + const focus = focusValues[0]; + if (focus !== undefined && normalizeSettingsFocusId(focus) === undefined) { + return undefined; + } + + const actionValues = params.getAll(SETTINGS_LINK_ACTION_PARAM); + if (actionValues.length > 1) { + return undefined; + } + + const action = actionValues[0]; + if (action !== undefined && action !== SETTINGS_LINK_ACTION_SETTINGS) { + return undefined; + } + + return { + focus, + hasActionMarker: action === SETTINGS_LINK_ACTION_SETTINGS, + }; +}; + +const withSettingsLinkAction = (path: string): string => { + const [pathname, search = ''] = path.split('?'); + const params = new URLSearchParams(search); + params.set(SETTINGS_LINK_ACTION_PARAM, SETTINGS_LINK_ACTION_SETTINGS); + + return `${pathname}?${params.toString()}`; +}; + +const parseSettingsAppPath = (appPath: string): SettingsLink | undefined => { + if (!appPath.startsWith('/settings/')) return undefined; + + const [pathname, search = ''] = appPath.split('?'); + const sectionMatch = pathname.match(/^\/settings\/([^/]+)\/?$/); + if (!sectionMatch) return undefined; + + const section = sectionMatch[1]; + if (!isSettingsSectionId(section)) return undefined; + + const query = parseSettingsLinkQuery(search); + if (!query) return undefined; + + if (query.focus && !isShareableSettingsFocusId(section, query.focus)) { return undefined; } + + return { section, focus: query.focus }; }; -export const getConfiguredSettingsLinkBaseUrl = ( - clientConfig: Pick -): string => - normalizeSettingsLinkBaseUrl(clientConfig.settingsLinkBaseUrl) ?? DEFAULT_SETTINGS_LINK_BASE_URL; +const hasSettingsLinkAction = (search: string): boolean => + parseSettingsLinkQuery(search)?.hasActionMarker === true; + +const getCrossBaseSettingsPathname = (pathname: string): string | undefined => + pathname.match(/(\/settings\/[^/]+\/?)$/)?.[1]; + +const getCrossBaseSettingsAppPath = (pathname: string, search: string): string | undefined => { + if (!hasSettingsLinkAction(search)) return undefined; + + const settingsPathname = getCrossBaseSettingsPathname(pathname); + if (!settingsPathname) return undefined; + + const appPath = search ? `${settingsPathname}?${search}` : settingsPathname; + return parseSettingsAppPath(appPath) ? appPath : undefined; +}; + +const getSameBaseSettingsAppPath = (baseUrl: string, href: string): string | undefined => { + const base = new URL(baseUrl); + const target = new URL(href); + + if (base.origin !== target.origin) return undefined; + + if (base.hash) { + const baseHash = base.hash.replace(/\/+$/, ''); + if (!(target.hash === baseHash || target.hash.startsWith(`${baseHash}/`))) { + return undefined; + } + } + + return getAppPathFromHref(baseUrl, href); +}; -export const getEffectiveSettingsLinkBaseUrl = ( - clientConfig: Pick, - override?: string -): string => - normalizeSettingsLinkBaseUrl(override) ?? getConfiguredSettingsLinkBaseUrl(clientConfig); +const getCrossBaseSettingsAppPathFromHref = (href: string): string | undefined => { + const target = new URL(href); + + const directAppPath = getCrossBaseSettingsAppPath( + target.pathname, + target.search.replace(/^\?/, '') + ); + if (directAppPath) { + return directAppPath; + } + + const hashPath = target.hash.startsWith('#') ? target.hash.slice(1) : target.hash; + if (!hashPath) return undefined; + + const [hashPathname, hashSearch = ''] = hashPath.split('?'); + return getCrossBaseSettingsAppPath(hashPathname, hashSearch); +}; export const buildSettingsLink = ( baseUrl: string, section: SettingsSectionId, focus?: string -): string => withOriginBaseUrl(baseUrl, getSettingsPath(section, focus)); +): string => withOriginBaseUrl(baseUrl, withSettingsLinkAction(getSettingsPath(section, focus))); -export const parseSettingsLink = (baseUrl: string, href: string): SettingsLink | undefined => { - try { - const base = new URL(baseUrl); - const target = new URL(href); +const humanizeSettingsLinkPart = (value: string): string => + value + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); - if (base.origin !== target.origin) return undefined; +export const getSettingsLinkLabel = (section: SettingsSectionId, focus?: string): string => { + const sectionLabel = settingsSectionLabel[section]; + const focusLabel = focus ? humanizeSettingsLinkPart(focus) : undefined; - if (base.hash) { - const baseHash = base.hash.replace(/\/+$/, ''); - if (!(target.hash === baseHash || target.hash.startsWith(`${baseHash}/`))) { - return undefined; - } - } + return focusLabel ? `Settings > ${sectionLabel} > ${focusLabel}` : `Settings > ${sectionLabel}`; +}; - const appPath = getAppPathFromHref(baseUrl, href); - if (!appPath.startsWith('/settings/')) return undefined; +export const getSettingsLinkChipLabel = (section: SettingsSectionId, focus?: string): string => { + const sectionLabel = settingsSectionLabel[section]; + const focusLabel = focus ? humanizeSettingsLinkPart(focus) : undefined; - const [pathname, search = ''] = appPath.split('?'); - const sectionMatch = pathname.match(/^\/settings\/([^/]+)\/?$/); - if (!sectionMatch) return undefined; + return focusLabel ? `${sectionLabel} / ${focusLabel}` : sectionLabel; +}; - const section = sectionMatch[1]; - if (!isSettingsSectionId(section)) return undefined; +export const parseSettingsLink = (baseUrl: string, href: string): SettingsLink | undefined => { + try { + const sameBaseAppPath = getSameBaseSettingsAppPath(baseUrl, href); + if (sameBaseAppPath) { + return parseSettingsAppPath(sameBaseAppPath); + } - const focus = new URLSearchParams(search).get('focus') ?? undefined; + const crossBaseAppPath = getCrossBaseSettingsAppPathFromHref(href); + if (crossBaseAppPath) { + return parseSettingsAppPath(crossBaseAppPath); + } - return { section, focus }; + return undefined; } catch { return undefined; } diff --git a/src/app/features/settings/useOpenSettings.ts b/src/app/features/settings/useOpenSettings.ts index 9c38bb2a8..7a7a41a11 100644 --- a/src/app/features/settings/useOpenSettings.ts +++ b/src/app/features/settings/useOpenSettings.ts @@ -3,6 +3,7 @@ import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { getSettingsPath } from '$pages/pathUtils'; import { SETTINGS_PATH } from '$pages/paths'; import type { SettingsSectionId } from './routes'; +import { normalizeSettingsFocusId } from './settingsLink'; export function useOpenSettings() { const navigate = useNavigate(); @@ -14,7 +15,7 @@ export function useOpenSettings() { ? undefined : { backgroundLocation: location }; - navigate(getSettingsPath(section, focus), { + navigate(getSettingsPath(section, normalizeSettingsFocusId(focus)), { state: settingsState, }); }, diff --git a/src/app/features/settings/useSettingsFocus.ts b/src/app/features/settings/useSettingsFocus.ts index 8edfaa020..0ccda1495 100644 --- a/src/app/features/settings/useSettingsFocus.ts +++ b/src/app/features/settings/useSettingsFocus.ts @@ -5,6 +5,12 @@ import { focusedSettingTile } from './styles.css'; const focusedSettingTileClasses = focusedSettingTile.split(' ').filter(Boolean); const getHighlightTarget = (target: HTMLElement): HTMLElement => target.closest('[data-sequence-card="true"]') ?? target.parentElement ?? target; +const getFocusTarget = (focusId: string): HTMLElement | null => + document.getElementById(focusId) ?? + Array.from(document.querySelectorAll('[data-settings-focus]')).find( + (element) => element.getAttribute('data-settings-focus') === focusId + ) ?? + null; const SETTINGS_FOCUS_HANDLED_STATE_KEY = 'settingsFocusHandledKey'; type SettingsFocusRouteState = { @@ -39,9 +45,7 @@ export function useSettingsFocus() { return; } - const target = - document.getElementById(focusId) ?? - document.querySelector(`[data-settings-focus="${focusId}"]`); + const target = getFocusTarget(focusId); if (!target) return; diff --git a/src/app/features/settings/useSettingsLinkBaseUrl.ts b/src/app/features/settings/useSettingsLinkBaseUrl.ts index 15eb73a66..6a68301c0 100644 --- a/src/app/features/settings/useSettingsLinkBaseUrl.ts +++ b/src/app/features/settings/useSettingsLinkBaseUrl.ts @@ -1,15 +1,9 @@ import { useMemo } from 'react'; import { useClientConfig } from '$hooks/useClientConfig'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; -import { getEffectiveSettingsLinkBaseUrl } from './settingsLink'; +import { getOriginBaseUrl } from '$pages/pathUtils'; export const useSettingsLinkBaseUrl = (): string => { const clientConfig = useClientConfig(); - const [settingsLinkBaseUrlOverride] = useSetting(settingsAtom, 'settingsLinkBaseUrlOverride'); - return useMemo( - () => getEffectiveSettingsLinkBaseUrl(clientConfig, settingsLinkBaseUrlOverride), - [clientConfig, settingsLinkBaseUrlOverride] - ); + return useMemo(() => getOriginBaseUrl(clientConfig.hashRouter), [clientConfig.hashRouter]); }; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..87685337d 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -42,7 +42,6 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; matrixToBaseUrl?: string; - settingsLinkBaseUrl?: string; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useMentionClickHandler.test.tsx b/src/app/hooks/useMentionClickHandler.test.tsx index 171e071c4..39662a708 100644 --- a/src/app/hooks/useMentionClickHandler.test.tsx +++ b/src/app/hooks/useMentionClickHandler.test.tsx @@ -60,4 +60,26 @@ describe('useMentionClickHandler', () => { expect(mockOpenSettings).toHaveBeenCalledWith('appearance', 'message-link-preview'); }); + + it('drops malformed settings focus ids before calling openSettings', () => { + const { result } = renderHook(() => useMentionClickHandler('!room:example.org'), { + wrapper: Wrapper, + }); + const malformedFocus = 'display-name">Settings'; + + const { getByRole } = render( + + ); + + fireEvent.click(getByRole('button', { name: 'Open malformed settings link' })); + + expect(mockOpenSettings).toHaveBeenCalledWith('account', undefined); + }); }); diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts index 350b7c2e8..9d231e786 100644 --- a/src/app/hooks/useMentionClickHandler.ts +++ b/src/app/hooks/useMentionClickHandler.ts @@ -4,6 +4,7 @@ import { isRoomId, isUserId } from '$utils/matrix'; import { getHomeRoomPath, withSearchParam } from '$pages/pathUtils'; import { RoomSearchParams } from '$pages/paths'; import { isSettingsSectionId } from '$features/settings/routes'; +import { normalizeSettingsFocusId } from '$features/settings/settingsLink'; import { useOpenSettings } from '$features/settings/useOpenSettings'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; import { useMatrixClient } from './useMatrixClient'; @@ -25,7 +26,9 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler { {renderLink({ tagName: 'a', attributes: { - href: 'https://app.example/settings/appearance?focus=message-link-preview', + href: 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings', }, - content: 'https://app.example/settings/appearance?focus=message-link-preview', + content: + 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings', } as never)} ); @@ -159,20 +160,20 @@ describe('react custom html parser', () => { expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); expect(link.className).toContain(customHtmlCss.Mention({})); - expect(link).not.toHaveTextContent('Settings:'); + expect(link).not.toHaveTextContent('Settings >'); expect(link.className).toContain(customHtmlCss.MentionWithIcon); }); it('renders same-origin settings links as internal app links with settings metadata', () => { renderParsedHtml( - 'Appearance', + 'Appearance', { sanitize: false } ); - const link = screen.getByRole('link', { name: 'Appearance' }); + const link = screen.getByRole('link', { name: 'Appearance / Message Link Preview' }); expect(link).toHaveAttribute( 'href', - 'https://app.example/settings/appearance?focus=message-link-preview' + 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' ); expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); @@ -181,6 +182,64 @@ describe('react custom html parser', () => { expect(link.className).toContain(customHtmlCss.MentionWithIcon); }); + it('renders marked cross-instance settings links as internal app links with settings metadata', () => { + renderParsedHtml( + 'Account', + { sanitize: false } + ); + + const link = screen.getByRole('link', { name: 'Account / Status' }); + expect(link).toHaveAttribute( + 'href', + 'https://other.example/#/client/settings/account?focus=status&moe.sable.client.action=settings' + ); + expect(link).toHaveAttribute('data-settings-link-section', 'account'); + expect(link).toHaveAttribute('data-settings-link-focus', 'status'); + }); + + it('keeps malformed settings-looking linkified tokens as normal links', () => { + const renderLink = factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + () => undefined, + undefined + ) as (ir: never) => JSX.Element; + const malformedToken = + 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings'; + + render( +
+ {renderLink({ + tagName: 'a', + attributes: { + href: malformedToken, + }, + content: malformedToken, + } as never)} +
+ ); + + const link = screen.getByRole('link', { name: malformedToken }); + expect(link).not.toHaveAttribute('data-settings-link-section'); + expect(link).not.toHaveAttribute('data-settings-link-focus'); + expect(link.className).not.toContain(customHtmlCss.MentionWithIcon); + }); + + it('keeps settings links with unknown focus ids as normal links', () => { + renderParsedHtml( + 'Settings > Account > Display Name2', + { sanitize: false } + ); + + const link = screen.getByRole('link', { name: 'Settings > Account > Display Name2' }); + expect(link).toHaveAttribute( + 'href', + 'https://app.example/settings/account?focus=display-name2' + ); + expect(link).not.toHaveAttribute('data-settings-link-section'); + expect(link).not.toHaveAttribute('data-settings-link-focus'); + expect(link.className).not.toContain(customHtmlCss.MentionWithIcon); + }); + it('renders matrix message permalinks with an icon instead of the Message prefix', () => { render(
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index c39ac976c..51a718f53 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -36,8 +36,7 @@ import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { isMatrixHexColor } from '$utils/matrixHtml'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; -import { parseSettingsLink } from '$features/settings/settingsLink'; -import { settingsSections } from '$features/settings/routes'; +import { getSettingsLinkChipLabel, parseSettingsLink } from '$features/settings/settingsLink'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { CodeHighlightRenderer } from '$components/code-highlight'; import { @@ -202,65 +201,20 @@ export const renderMatrixMention = ( return undefined; }; -const settingsSectionLabel = Object.fromEntries( - settingsSections.map((section) => [section.id, section.label]) -) as Record<(typeof settingsSections)[number]['id'], string>; - -const humanizeSettingsLinkPart = (value: string): string => - value - .split(/[^a-zA-Z0-9]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); - -const getSettingsLinkLabel = ( - section: keyof typeof settingsSectionLabel, - focus?: string -): string => { - const sectionLabel = settingsSectionLabel[section]; - const focusLabel = focus ? humanizeSettingsLinkPart(focus) : undefined; - - return focusLabel ? `${sectionLabel} / ${focusLabel}` : sectionLabel; -}; - -const getSettingsLinkChildren = ({ - href, - section, - focus, - content, - fallbackChildren, -}: { - href: string; - section: keyof typeof settingsSectionLabel; - focus?: string; - content?: string; - fallbackChildren?: ReactNode; -}): ReactNode => { - if (!content || content === href || content === safeDecodeUrl(href)) { - return getSettingsLinkLabel(section, focus); - } - - return fallbackChildren ?? content; -}; - const renderSettingsLink = ({ href, section, focus, handleMentionClick, - content, - fallbackChildren, }: { href: string; - section: keyof typeof settingsSectionLabel; + section: Parameters[0]; focus?: string; handleMentionClick?: ReactEventHandler; - content?: string; - fallbackChildren?: ReactNode; }) => ( ); @@ -299,8 +253,6 @@ export const factoryRenderLinkifyWithMention = ( section, focus, handleMentionClick, - content, - fallbackChildren: content, }); } } @@ -715,8 +667,6 @@ export const getReactCustomHtmlParser = ( section, focus, handleMentionClick: params.handleMentionClick, - content, - fallbackChildren: renderedChildren, }); } } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 05bb8e0fb..e26fff911 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -74,7 +74,6 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; settingsSyncEnabled: boolean; - settingsLinkBaseUrlOverride?: string; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -177,7 +176,6 @@ const defaultSettings: Settings = { developerTools: false, settingsSyncEnabled: false, - settingsLinkBaseUrlOverride: undefined, // Cosmetics! jumboEmojiSize: 'normal', diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index fb6882f95..81d841faa 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -178,6 +178,7 @@ export const MentionWithIcon = style({ display: 'inline-flex', alignItems: 'center', gap: toRem(2), + verticalAlign: 'middle', }); export const MentionIcon = style({ diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index e3d29aebc..270d60a11 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -30,7 +30,6 @@ describe('NON_SYNCABLE_KEYS', () => { 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', - 'settingsLinkBaseUrlOverride', 'developerTools', 'settingsSyncEnabled', ] as const; diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 0f6b25888..a154ff92d 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -14,7 +14,6 @@ export const NON_SYNCABLE_KEYS = new Set([ 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', - 'settingsLinkBaseUrlOverride', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local)