fix: GIFs, topic display, formatting toolbar, Copy Link in Invite, support URL

- RoomInput: GIF domain check uses endsWith('.giphy.com') — handles all
  Giphy CDN shards; all silent failure paths now show user-facing error
- RoomProfile: topic plain-text field is now HTML-stripped (no markdown
  syntax visible in header); formatting toolbar (B/I/S/code) above the
  textarea wraps selected text with correct markdown syntax
- InviteUserPrompt: Copy Link button added to dialog header with
  'Copied!' confirmation; removed Copy Link from both three-dot menus
- RoomViewHeader/RoomNavItem: unused copy-link imports removed
- nginx (live): support_page URL updated from lotusguild.org →
  matrix.lotusguild.org

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:55:04 -04:00
parent 16a15efe9b
commit 0bbfe17559
5 changed files with 119 additions and 70 deletions
@@ -4,6 +4,7 @@ import {
Button,
Chip,
color,
config,
Icon,
Icons,
Input,
@@ -11,7 +12,7 @@ import {
Text,
TextArea,
} from 'folds';
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import React, { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import Linkify from 'linkify-react';
import classNames from 'classnames';
@@ -43,6 +44,22 @@ import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
const inner = selected || placeholder;
const replacement = `${syntax}${inner}${syntax}`;
const newValue = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
nativeSetter?.call(textarea, newValue);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
const cursorStart = start + syntax.length;
const cursorEnd = cursorStart + inner.length;
textarea.focus();
textarea.setSelectionRange(cursorStart, cursorEnd);
}
function topicMarkdownToHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
@@ -60,11 +77,11 @@ function topicMarkdownToHtml(text: string): string {
function buildTopicContent(topic: string): Record<string, string> {
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
return {
topic,
format: 'org.matrix.custom.html',
formatted_body: topicMarkdownToHtml(topic),
};
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
// eslint-disable-next-line @typescript-eslint/naming-convention
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
type RoomProfileEditProps = {
@@ -96,6 +113,7 @@ export function RoomProfileEdit({
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined)
: undefined;
const topicRef = useRef<HTMLTextAreaElement>(null);
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);
const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
@@ -247,20 +265,60 @@ export function RoomProfileEdit({
/>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Topic</Text>
<Box alignItems="Center" justifyContent="SpaceBetween">
<Text size="L400">Topic</Text>
{canEditTopic && !submitting && (
<Box gap="100">
{(
[
{ label: 'B', syntax: '**', placeholder: 'bold', title: 'Bold' },
{ label: 'I', syntax: '*', placeholder: 'italic', title: 'Italic' },
{
label: 'S',
syntax: '~~',
placeholder: 'strikethrough',
title: 'Strikethrough',
},
{ label: '`', syntax: '`', placeholder: 'code', title: 'Inline Code' },
] as const
).map(({ label, syntax, placeholder, title }) => (
<button
key={label}
type="button"
title={title}
aria-label={title}
onClick={() =>
topicRef.current && wrapSelection(topicRef.current, syntax, placeholder)
}
style={{
padding: `${config.space.S100} ${config.space.S200}`,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
background: color.Surface.Container,
color: color.Surface.OnContainer,
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: label === 'B' ? 700 : label === 'I' ? undefined : undefined,
fontStyle: label === 'I' ? 'italic' : undefined,
fontFamily: label === '`' ? 'monospace' : 'inherit',
lineHeight: 1,
}}
>
{label}
</button>
))}
</Box>
)}
</Box>
<TextArea
ref={topicRef}
name="topicTextArea"
defaultValue={topic}
placeholder="Describe this room. Markdown supported: **bold**, *italic*, [link](url)"
placeholder="Describe this room supports **bold**, *italic*, ~~strikethrough~~, `code`"
variant="Secondary"
radii="300"
readOnly={!canEditTopic || submitting}
/>
{canEditTopic && (
<Text size="T200" priority="300">
Supports markdown: **bold**, *italic*, ~~strikethrough~~, `code`, [text](url)
</Text>
)}
</Box>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>