"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
+ "domhandler": "5.0.3",
"emojibase": "6.1.0",
"emojibase-data": "7.0.1",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "1.5.0",
"formik": "2.2.9",
+ "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
+ "domhandler": "5.0.3",
"emojibase": "6.1.0",
"emojibase-data": "7.0.1",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "1.5.0",
"formik": "2.2.9",
+ "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
};
export const useEditor = (): Editor => {
- const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor())))));
+ const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
+ editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
+ editableName,
top,
bottom,
before,
hideTrack
>
<Editable
+ data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
});
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
};
+
+export const isEmptyEditor = (editor: Editor): boolean => {
+ const firstChildren = editor.children[0];
+ if (firstChildren && Element.isElement(firstChildren)) {
+ const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
+ return isEmpty;
+ }
+ return false;
+};
export * from './keyboard';
export * from './output';
export * from './Toolbar';
+export * from './input';
--- /dev/null
+/* eslint-disable no-param-reassign */
+import { Descendant, Text } from 'slate';
+import parse from 'html-dom-parser';
+import { ChildNode, Element, isText, isTag } from 'domhandler';
+
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import { BlockType, MarkType } from './Elements';
+import {
+ BlockQuoteElement,
+ CodeBlockElement,
+ CodeLineElement,
+ EmoticonElement,
+ HeadingElement,
+ HeadingLevel,
+ InlineElement,
+ ListItemElement,
+ MentionElement,
+ OrderedListElement,
+ ParagraphElement,
+ QuoteLineElement,
+ UnorderedListElement,
+} from './slate';
+import { parseMatrixToUrl } from '../../utils/matrix';
+import { createEmoticonElement, createMentionElement } from './common';
+
+const markNodeToType: Record<string, MarkType> = {
+ b: MarkType.Bold,
+ strong: MarkType.Bold,
+ i: MarkType.Italic,
+ em: MarkType.Italic,
+ u: MarkType.Underline,
+ s: MarkType.StrikeThrough,
+ del: MarkType.StrikeThrough,
+ code: MarkType.Code,
+ span: MarkType.Spoiler,
+};
+
+const elementToTextMark = (node: Element): MarkType | undefined => {
+ const markType = markNodeToType[node.name];
+ if (!markType) return undefined;
+
+ if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
+ return undefined;
+ }
+ if (
+ markType === MarkType.Code &&
+ node.parent &&
+ 'name' in node.parent &&
+ node.parent.name === 'pre'
+ ) {
+ return undefined;
+ }
+ return markType;
+};
+
+const parseNodeText = (node: ChildNode): string => {
+ if (isText(node)) {
+ return node.data;
+ }
+ if (isTag(node)) {
+ return node.children.map((child) => parseNodeText(child)).join('');
+ }
+ return '';
+};
+
+const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
+ if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
+ const { src, alt } = node.attribs;
+ if (!src) return undefined;
+ return createEmoticonElement(src, alt || 'Unknown Emoji');
+ }
+ if (node.name === 'a') {
+ const { href } = node.attribs;
+ if (typeof href !== 'string') return undefined;
+ const [mxId] = parseMatrixToUrl(href);
+ if (mxId) {
+ return createMentionElement(mxId, mxId, false);
+ }
+ }
+ return undefined;
+};
+
+const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+ if (isText(node)) {
+ return [{ text: node.data }];
+ }
+ if (isTag(node)) {
+ const markType = elementToTextMark(node);
+ if (markType) {
+ const children = node.children.flatMap(parseInlineNodes);
+ if (node.attribs['data-md'] !== undefined) {
+ children.unshift({ text: node.attribs['data-md'] });
+ children.push({ text: node.attribs['data-md'] });
+ } else {
+ children.forEach((child) => {
+ if (Text.isText(child)) {
+ child[markType] = true;
+ }
+ });
+ }
+ return children;
+ }
+
+ const inlineNode = elementToInlineNode(node);
+ if (inlineNode) return [inlineNode];
+
+ if (node.name === 'a') {
+ const children = node.childNodes.flatMap(parseInlineNodes);
+ children.unshift({ text: '[' });
+ children.push({ text: `](${node.attribs.href})` });
+ return children;
+ }
+
+ return node.childNodes.flatMap(parseInlineNodes);
+ }
+
+ return [];
+};
+
+const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
+ const children: QuoteLineElement[] = [];
+ let lineHolder: InlineElement[] = [];
+
+ const appendLine = () => {
+ if (lineHolder.length === 0) return;
+
+ children.push({
+ type: BlockType.QuoteLine,
+ children: lineHolder,
+ });
+ lineHolder = [];
+ };
+
+ node.children.forEach((child) => {
+ if (isText(child)) {
+ lineHolder.push({ text: child.data });
+ return;
+ }
+ if (isTag(child)) {
+ if (child.name === 'br') {
+ appendLine();
+ return;
+ }
+
+ if (child.name === 'p') {
+ appendLine();
+ children.push({
+ type: BlockType.QuoteLine,
+ children: child.children.flatMap((c) => parseInlineNodes(c)),
+ });
+ return;
+ }
+
+ parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+ }
+ });
+ appendLine();
+
+ return {
+ type: BlockType.BlockQuote,
+ children,
+ };
+};
+const parseCodeBlockNode = (node: Element): CodeBlockElement => {
+ const children: CodeLineElement[] = [];
+
+ const code = parseNodeText(node).trim();
+ code.split('\n').forEach((lineTxt) =>
+ children.push({
+ type: BlockType.CodeLine,
+ children: [
+ {
+ text: lineTxt,
+ },
+ ],
+ })
+ );
+
+ return {
+ type: BlockType.CodeBlock,
+ children,
+ };
+};
+const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
+ const children: ListItemElement[] = [];
+ let lineHolder: InlineElement[] = [];
+
+ const appendLine = () => {
+ if (lineHolder.length === 0) return;
+
+ children.push({
+ type: BlockType.ListItem,
+ children: lineHolder,
+ });
+ lineHolder = [];
+ };
+
+ node.children.forEach((child) => {
+ if (isText(child)) {
+ lineHolder.push({ text: child.data });
+ return;
+ }
+ if (isTag(child)) {
+ if (child.name === 'br') {
+ appendLine();
+ return;
+ }
+
+ if (child.name === 'li') {
+ appendLine();
+ children.push({
+ type: BlockType.ListItem,
+ children: child.children.flatMap((c) => parseInlineNodes(c)),
+ });
+ return;
+ }
+
+ parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+ }
+ });
+ appendLine();
+
+ return {
+ type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList,
+ children,
+ };
+};
+const parseHeadingNode = (node: Element): HeadingElement => {
+ const children = node.children.flatMap((child) => parseInlineNodes(child));
+
+ const headingMatch = node.name.match(/^h([123456])$/);
+ const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
+ const level = parseInt(g1AsLevel, 10);
+ return {
+ type: BlockType.Heading,
+ level: (level <= 3 ? level : 3) as HeadingLevel,
+ children,
+ };
+};
+
+export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+ const children: Descendant[] = [];
+
+ let lineHolder: InlineElement[] = [];
+
+ const appendLine = () => {
+ if (lineHolder.length === 0) return;
+
+ children.push({
+ type: BlockType.Paragraph,
+ children: lineHolder,
+ });
+ lineHolder = [];
+ };
+
+ domNodes.forEach((node) => {
+ if (isText(node)) {
+ lineHolder.push({ text: node.data });
+ return;
+ }
+ if (isTag(node)) {
+ if (node.name === 'br') {
+ appendLine();
+ return;
+ }
+
+ if (node.name === 'p') {
+ appendLine();
+ children.push({
+ type: BlockType.Paragraph,
+ children: node.children.flatMap((child) => parseInlineNodes(child)),
+ });
+ return;
+ }
+
+ if (node.name === 'blockquote') {
+ appendLine();
+ children.push(parseBlockquoteNode(node));
+ return;
+ }
+ if (node.name === 'pre') {
+ appendLine();
+ children.push(parseCodeBlockNode(node));
+ return;
+ }
+ if (node.name === 'ol' || node.name === 'ul') {
+ appendLine();
+ children.push(parseListNode(node));
+ return;
+ }
+
+ if (node.name.match(/^h[123456]$/)) {
+ appendLine();
+ children.push(parseHeadingNode(node));
+ return;
+ }
+
+ parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+ }
+ });
+ appendLine();
+
+ return children;
+};
+
+export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+ const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
+
+ const domNodes = parse(sanitizedHtml);
+ const editorNodes = domToEditorInput(domNodes);
+ return editorNodes;
+};
+
+export const plainToEditorInput = (text: string): Descendant[] => {
+ const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
+ const paragraphNode: ParagraphElement = {
+ type: BlockType.Paragraph,
+ children: [
+ {
+ text: lineText,
+ },
+ ],
+ };
+ return paragraphNode;
+ });
+ return editorNodes;
+};
import { Descendant, Text } from 'slate';
+
import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './Elements';
-import { CustomElement, FormattedText } from './slate';
+import { CustomElement } from './slate';
import { parseInlineMD } from '../../utils/markdown';
export type OutputOptions = {
allowMarkdown?: boolean;
};
-const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => {
+const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
let string = sanitizeText(node.text);
if (opts.allowTextFormatting) {
if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`;
- if (node.strikeThrough) string = `<s>${string}</s>`;
+ if (node.strikeThrough) string = `<del>${string}</del>`;
if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
}
return `<ol>${children}</ol>`;
case BlockType.UnorderedList:
return `<ul>${children}</ul>`;
+
case BlockType.Mention:
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
case BlockType.Emoticon:
export type LinkElement = {
type: BlockType.Link;
href: string;
- children: FormattedText[];
-};
-export type SpoilerElement = {
- type: 'spoiler';
- alert?: string;
- children: FormattedText[];
+ children: Text[];
};
+
export type MentionElement = {
type: BlockType.Mention;
id: string;
children: Text[];
};
+export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
+
export type ParagraphElement = {
type: BlockType.Paragraph;
- children: FormattedText[];
+ children: InlineElement[];
};
export type HeadingElement = {
type: BlockType.Heading;
level: HeadingLevel;
- children: FormattedText[];
+ children: InlineElement[];
};
export type CodeLineElement = {
type: BlockType.CodeLine;
};
export type QuoteLineElement = {
type: BlockType.QuoteLine;
- children: FormattedText[];
+ children: InlineElement[];
};
export type BlockQuoteElement = {
type: BlockType.BlockQuote;
};
export type ListItemElement = {
type: BlockType.ListItem;
- children: FormattedText[];
+ children: InlineElement[];
};
export type OrderedListElement = {
type: BlockType.OrderedList;
export type CustomElement =
| LinkElement
- // | SpoilerElement
| MentionElement
| EmoticonElement
| ParagraphElement
import isHotkey from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
-import { Transforms, Range, Editor, Element } from 'slate';
+import { Transforms, Range, Editor } from 'slate';
import {
Box,
Dialog,
resetEditorHistory,
customHtmlEqualsPlainText,
trimCustomHtml,
+ isEmptyEditor,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import cons from '../../../client/state/cons';
import { MessageReply } from '../../molecules/message/Message';
import colorMXID from '../../../util/colorMXID';
-import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
+import {
+ parseReplyBody,
+ parseReplyFormattedBody,
+ trimReplyFromBody,
+ trimReplyFromFormattedBody,
+} from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
import { useScreenSize } from '../../hooks/useScreenSize';
let body = plainText;
let formattedBody = customHtml;
if (replyDraft) {
- body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
+ body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
- replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
+ replyDraft.formattedBody
+ ? trimReplyFromFormattedBody(replyDraft.formattedBody)
+ : sanitizeText(replyDraft.body)
) + formattedBody;
}
[submit, editor, setReplyDraft]
);
- const handleKeyUp: KeyboardEventHandler = useCallback(() => {
- const firstChildren = editor.children[0];
- if (firstChildren && Element.isElement(firstChildren)) {
- const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
- sendTypingStatus(!isEmpty);
- }
+ const handleKeyUp: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isHotkey('escape', evt)) {
+ evt.preventDefault();
+ return;
+ }
+
+ sendTypingStatus(!isEmptyEditor(editor));
+
+ const prevWordRange = getPrevWorldRange(editor);
+ const query = prevWordRange
+ ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+ : undefined;
+ setAutocompleteQuery(query);
+ },
+ [editor, sendTypingStatus]
+ );
- const prevWordRange = getPrevWorldRange(editor);
- const query = prevWordRange
- ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
- : undefined;
- setAutocompleteQuery(query);
- }, [editor, sendTypingStatus]);
+ const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
roomId={roomId}
editor={editor}
query={autocompleteQuery}
- requestClose={() => setAutocompleteQuery(undefined)}
+ requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
roomId={roomId}
editor={editor}
query={autocompleteQuery}
- requestClose={() => setAutocompleteQuery(undefined)}
+ requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
- requestClose={() => setAutocompleteQuery(undefined)}
+ requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
+ editableName="RoomInput"
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
- EventType,
IEncryptedFile,
MatrixClient,
MatrixEvent,
- RelationType,
Room,
RoomEvent,
RoomEventHandlerMap,
config,
toRem,
} from 'folds';
+import isHotkey from 'is-hotkey';
import Linkify from 'linkify-react';
import {
decryptFile,
getMxIdLocalPart,
isRoomId,
isUserId,
- matrixEventByRecency,
} from '../../utils/matrix';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
-import { scrollToBottom } from '../../utils/dom';
+import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
} from '../../components/message';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import {
+ canEditEvent,
decryptAllTimelineEvent,
+ getEditedEvent,
+ getEventReactions,
+ getLatestEditableEvt,
getMemberDisplayName,
getReactionContent,
isMembershipChanged,
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
-import { createMentionElement, moveCursor } from '../../components/editor';
+import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { MessageEvent } from '../../../types/matrix/room';
import initMatrix from '../../../client/initMatrix';
+import { useKeyDown } from '../../hooks/useKeyDown';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
return baseIndex + eventIndex;
};
-export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
- timelineSet.relations.getChildEventsForEvent(
- eventId,
- RelationType.Annotation,
- EventType.Reaction
- );
-
-export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
- timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
-
-export const getLatestEdit = (
- targetEvent: MatrixEvent,
- editEvents: MatrixEvent[]
-): MatrixEvent | undefined => {
- const eventByTargetSender = (rEvent: MatrixEvent) =>
- rEvent.getSender() === targetEvent.getSender();
- return editEvents.sort(matrixEventByRecency).find(eventByTargetSender);
-};
-
-export const getEditedEvent = (
- mEventId: string,
- mEvent: MatrixEvent,
- timelineSet: EventTimelineSet
-): MatrixEvent | undefined => {
- const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
- return edits && getLatestEdit(mEvent, edits.getRelations());
-};
-
export const factoryGetFileSrcUrl =
(httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
if (encFile) {
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
const canRedact = canDoAction('redact', myPowerLevel);
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+ const [editId, setEditId] = useState<string>();
const imagePackRooms: Room[] = useMemo(() => {
const allParentSpaces = [
const getScrollElement = useCallback(() => scrollRef.current, []);
- const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
- count: eventsLength,
- limit: PAGINATION_LIMIT,
- range: timeline.range,
- onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
- getScrollElement,
- getItemElement: useCallback(
- (index: number) =>
- (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
- undefined,
- []
- ),
- onEnd: handleTimelinePagination,
- });
+ const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
+ useVirtualPaginator({
+ count: eventsLength,
+ limit: PAGINATION_LIMIT,
+ range: timeline.range,
+ onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+ getScrollElement,
+ getItemElement: useCallback(
+ (index: number) =>
+ (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+ undefined,
+ []
+ ),
+ onEnd: handleTimelinePagination,
+ });
const loadEventTimeline = useEventTimelineLoader(
mx,
useCallback(() => atBottomAnchorRef.current, [])
);
+ // Handle up arrow edit
+ useKeyDown(
+ window,
+ useCallback(
+ (evt) => {
+ if (
+ isHotkey('arrowup', evt) &&
+ editableActiveElement() &&
+ document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
+ isEmptyEditor(editor)
+ ) {
+ const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
+ canEditEvent(mx, mEvt)
+ );
+ const editableEvtId = editableEvt?.getId();
+ if (!editableEvtId) return;
+ setEditId(editableEvtId);
+ }
+ },
+ [mx, room, editor]
+ )
+ );
+
useEffect(() => {
if (eventId) {
setTimeline(getEmptyTimeline());
}
}, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
+ // scroll out of view msg editor in view.
+ useEffect(() => {
+ if (editId) {
+ const editMsgElement =
+ (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
+ undefined;
+ if (editMsgElement) {
+ scrollToElement(editMsgElement, {
+ align: 'center',
+ behavior: 'smooth',
+ stopInView: true,
+ });
+ }
+ }
+ }, [scrollToElement, editId]);
+
const handleJumpToLatest = () => {
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
},
[mx, room]
);
+ const handleEdit = useCallback(
+ (editEvtId?: string) => {
+ if (editEvtId) {
+ setEditId(editEvtId);
+ return;
+ }
+ setEditId(undefined);
+ ReactEditor.focus(editor);
+ },
+ [editor]
+ );
const renderBody = (body: string, customBody?: string) => {
if (body === '') <MessageEmptyContent />;
<Message
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
+ edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
imagePackRooms={imagePackRooms}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
+ onEditId={handleEdit}
reply={
replyEventId && (
<Reply
<Message
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
+ edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
imagePackRooms={imagePackRooms}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
+ onEditId={handleEdit}
reply={
replyEventId && (
<Reply
<Message
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
<Event
key={mEvent.getId()}
data-message-item={item}
+ data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
Username,
} from '../../../components/message';
import colorMXID from '../../../../util/colorMXID';
-import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
+import {
+ canEditEvent,
+ getEventEdits,
+ getMemberAvatarMxc,
+ getMemberDisplayName,
+} from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
+import { MessageEditor } from './MessageEditor';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
export const MessageSourceCodeItem = as<
'button',
{
+ room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
->(({ mEvent, onClose, ...props }, ref) => {
+>(({ room, mEvent, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
- const text = JSON.stringify(
- mEvent.isEncrypted()
+
+ const getContent = (evt: MatrixEvent) =>
+ evt.isEncrypted()
? {
- [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
- [`<== ORIGINAL_EVENT ==>`]: mEvent.event,
+ [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
+ [`<== ORIGINAL_EVENT ==>`]: evt.event,
}
- : mEvent.event,
- null,
- 2
- );
+ : evt.event;
+
+ const getText = (): string => {
+ const evtId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(evtId);
+ const edits =
+ evtTimeline &&
+ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
+
+ if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
+
+ const content: Record<string, unknown> = {
+ '<== MAIN_EVENT ==>': getContent(mEvent),
+ };
+
+ edits.forEach((editEvt, index) => {
+ content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
+ });
+
+ return JSON.stringify(content, null, 2);
+ };
const handleClose = () => {
setOpen(false);
<TextViewer
name="Source Code"
langName="json"
- text={text}
+ text={getText()}
requestClose={handleClose}
/>
</Modal>
mEvent: MatrixEvent;
collapse: boolean;
highlight: boolean;
+ edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
imagePackRooms?: Room[];
onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>;
+ onEditId?: (eventId?: string) => void;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode;
reactions?: ReactNode;
mEvent,
collapse,
highlight,
+ edit,
canDelete,
canSendReaction,
imagePackRooms,
onUsernameClick,
onReplyClick,
onReactionToggle,
+ onEditId,
reply,
reactions,
children,
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
- {children}
+ {edit && onEditId ? (
+ <MessageEditor
+ style={{
+ maxWidth: '100%',
+ width: '100vw',
+ }}
+ roomId={room.roomId}
+ room={room}
+ mEvent={mEvent}
+ imagePackRooms={imagePackRooms}
+ onCancel={() => onEditId()}
+ />
+ ) : (
+ children
+ )}
{reactions}
</Box>
);
onMouseLeave={hideOptions}
ref={ref}
>
- {(hover || menu || emojiBoard) && (
+ {!edit && (hover || menu || emojiBoard) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
+ {canEditEvent(mx, mEvent) && onEditId && (
+ <IconButton
+ onClick={() => onEditId(mEvent.getId())}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.Pencil} size="100" />
+ </IconButton>
+ )}
<PopOut
open={menu}
alignOffset={-5}
Reply
</Text>
</MenuItem>
+ {canEditEvent(mx, mEvent) && onEditId && (
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.Pencil} />}
+ radii="300"
+ data-event-id={mEvent.getId()}
+ onClick={() => {
+ onEditId(mEvent.getId());
+ closeMenu();
+ }}
+ >
+ <Text
+ className={css.MessageMenuItemText}
+ as="span"
+ size="T300"
+ truncate
+ >
+ Edit Message
+ </Text>
+ </MenuItem>
+ )}
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
- <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+ <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && (
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
- <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+ <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
--- /dev/null
+import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
+import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, as, config } from 'folds';
+import { Editor, Transforms } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
+import isHotkey from 'is-hotkey';
+import {
+ AUTOCOMPLETE_PREFIXES,
+ AutocompletePrefix,
+ AutocompleteQuery,
+ CustomEditor,
+ EmoticonAutocomplete,
+ RoomMentionAutocomplete,
+ Toolbar,
+ UserMentionAutocomplete,
+ createEmoticonElement,
+ customHtmlEqualsPlainText,
+ getAutocompleteQuery,
+ getPrevWorldRange,
+ htmlToEditorInput,
+ moveCursor,
+ plainToEditorInput,
+ toMatrixCustomHTML,
+ toPlainText,
+ trimCustomHtml,
+ useEditor,
+} from '../../../components/editor';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
+
+type MessageEditorProps = {
+ roomId: string;
+ room: Room;
+ mEvent: MatrixEvent;
+ imagePackRooms?: Room[];
+ onCancel: () => void;
+};
+export const MessageEditor = as<'div', MessageEditorProps>(
+ ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
+ const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+ const [toolbar, setToolbar] = useState(globalToolbar);
+
+ const [autocompleteQuery, setAutocompleteQuery] =
+ useState<AutocompleteQuery<AutocompletePrefix>>();
+
+ const getPrevBodyAndFormattedBody = useCallback(() => {
+ const evtId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(evtId);
+ const editedEvent =
+ evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
+
+ const { body, formatted_body: customHtml }: Record<string, unknown> =
+ editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+ return [body, customHtml];
+ }, [room, mEvent]);
+
+ const [saveState, save] = useAsyncCallback(
+ useCallback(async () => {
+ const plainText = toPlainText(editor.children).trim();
+ const customHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, {
+ allowTextFormatting: true,
+ allowMarkdown: isMarkdown,
+ })
+ );
+
+ const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
+
+ if (plainText === '') return undefined;
+ if (
+ typeof prevCustomHtml === 'string' &&
+ trimReplyFromFormattedBody(prevCustomHtml) === customHtml
+ ) {
+ return undefined;
+ }
+ if (!prevCustomHtml && typeof prevBody === 'string' && prevBody === plainText) {
+ return undefined;
+ }
+
+ const newContent: IContent = {
+ msgtype: mEvent.getContent().msgtype,
+ body: plainText,
+ };
+
+ if (!customHtmlEqualsPlainText(customHtml, plainText)) {
+ newContent.format = 'org.matrix.custom.html';
+ newContent.formatted_body = customHtml;
+ }
+
+ const content: IContent = {
+ ...newContent,
+ body: `* ${plainText}`,
+ 'm.new_content': newContent,
+ 'm.relates_to': {
+ event_id: mEvent.getId(),
+ rel_type: RelationType.Replace,
+ },
+ };
+
+ return mx.sendMessage(roomId, content);
+ }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
+ );
+
+ const handleSave = useCallback(() => {
+ if (saveState.status !== AsyncStatus.Loading) {
+ save();
+ }
+ }, [saveState, save]);
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isHotkey('enter', evt)) {
+ evt.preventDefault();
+ handleSave();
+ }
+ if (isHotkey('escape', evt)) {
+ evt.preventDefault();
+ onCancel();
+ }
+ },
+ [onCancel, handleSave]
+ );
+
+ const handleKeyUp: KeyboardEventHandler = useCallback(
+ (evt) => {
+ if (isHotkey('escape', evt)) {
+ evt.preventDefault();
+ return;
+ }
+
+ const prevWordRange = getPrevWorldRange(editor);
+ const query = prevWordRange
+ ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+ : undefined;
+ setAutocompleteQuery(query);
+ },
+ [editor]
+ );
+
+ const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
+
+ const handleEmoticonSelect = (key: string, shortcode: string) => {
+ editor.insertNode(createEmoticonElement(key, shortcode));
+ moveCursor(editor);
+ };
+
+ useEffect(() => {
+ const [body, customHtml] = getPrevBodyAndFormattedBody();
+
+ const initialValue =
+ typeof customHtml === 'string'
+ ? htmlToEditorInput(customHtml)
+ : plainToEditorInput(typeof body === 'string' ? body : '');
+
+ Transforms.select(editor, {
+ anchor: Editor.start(editor, []),
+ focus: Editor.end(editor, []),
+ });
+
+ editor.insertFragment(initialValue);
+ ReactEditor.focus(editor);
+ }, [editor, getPrevBodyAndFormattedBody]);
+
+ useEffect(() => {
+ if (saveState.status === AsyncStatus.Success) {
+ onCancel();
+ }
+ }, [saveState, onCancel]);
+
+ return (
+ <div {...props} ref={ref}>
+ {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+ <RoomMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+ <UserMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+ <EmoticonAutocomplete
+ imagePackRooms={imagePackRooms || []}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
+ <CustomEditor
+ editor={editor}
+ placeholder="Edit message..."
+ onKeyDown={handleKeyDown}
+ onKeyUp={handleKeyUp}
+ bottom={
+ <>
+ <Box
+ style={{ padding: config.space.S200, paddingTop: 0 }}
+ alignItems="End"
+ justifyContent="SpaceBetween"
+ gap="100"
+ >
+ <Box gap="Inherit">
+ <Chip
+ onClick={handleSave}
+ variant="Primary"
+ radii="Pill"
+ disabled={saveState.status === AsyncStatus.Loading}
+ outlined
+ before={
+ saveState.status === AsyncStatus.Loading ? (
+ <Spinner variant="Primary" fill="Soft" size="100" />
+ ) : undefined
+ }
+ >
+ <Text size="B300">Save</Text>
+ </Chip>
+ <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
+ <Text size="B300">Cancel</Text>
+ </Chip>
+ </Box>
+ <Box gap="Inherit">
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setToolbar(!toolbar)}
+ >
+ <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+ </IconButton>
+ <UseStateProvider initial={false}>
+ {(emojiBoard: boolean, setEmojiBoard) => (
+ <PopOut
+ alignOffset={-8}
+ position="Top"
+ align="End"
+ open={!!emojiBoard}
+ content={
+ <EmojiBoard
+ imagePackRooms={imagePackRooms ?? []}
+ returnFocusOnDeactivate={false}
+ onEmojiSelect={handleEmoticonSelect}
+ onCustomEmojiSelect={handleEmoticonSelect}
+ requestClose={() => {
+ setEmojiBoard(false);
+ ReactEditor.focus(editor);
+ }}
+ />
+ }
+ >
+ {(anchorRef) => (
+ <IconButton
+ ref={anchorRef}
+ aria-pressed={emojiBoard}
+ onClick={() => setEmojiBoard(true)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon size="400" src={Icons.Smile} filled={emojiBoard} />
+ </IconButton>
+ )}
+ </PopOut>
+ )}
+ </UseStateProvider>
+ </Box>
+ </Box>
+ {toolbar && (
+ <div>
+ <Line variant="SurfaceVariant" size="300" />
+ <Toolbar />
+ </div>
+ )}
+ </>
+ }
+ />
+ </div>
+ );
+ }
+);
toRem,
} from 'folds';
import classNames from 'classnames';
-import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk';
+import { Room } from 'matrix-js-sdk';
import { type Relations } from 'matrix-js-sdk/lib/models/relations';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import * as css from './styles.css';
import { ReactionViewer } from '../reaction-viewer';
-export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
- timelineSet.relations.getChildEventsForEvent(
- eventId,
- RelationType.Annotation,
- EventType.Reaction
- );
-
export type ReactionsProps = {
room: Room;
mEventId: string;
export const editableActiveElement = (): boolean =>
!!document.activeElement &&
- /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
+ (document.activeElement.nodeName.toLowerCase() === 'input' ||
+ document.activeElement.nodeName.toLowerCase() === 'textbox' ||
+ document.activeElement.getAttribute('contenteditable') === 'true' ||
+ document.activeElement.getAttribute('role') === 'input' ||
+ document.activeElement.getAttribute('role') === 'textbox');
export const isIntersectingScrollView = (
scrollElement: HTMLElement,
match: (text) => text.match(STRIKE_REG_1),
html: (parse, match) => {
const [, g1] = match;
- return `<s data-md="${STRIKE_MD_1}">${parse(g1)}</s>`;
+ return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`;
},
};
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
+export const parseMatrixToUrl = (url: string): [string | undefined, string | undefined] => {
+ const href = decodeURIComponent(url);
+
+ const match = href.match(/^https?:\/\/matrix.to\/#\/([@!$+#]\S+:[^\\?|^\s|^\\/]+)(\?(via=\S+))?/);
+ if (!match) return [undefined, undefined];
+ const [, g1AsMxId, , g3AsVia] = match;
+ return [g1AsMxId, g3AsVia];
+};
+
export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
import {
EventTimeline,
+ EventTimelineSet,
+ EventType,
IPushRule,
IPushRules,
JoinRule,
MatrixClient,
MatrixEvent,
+ MsgType,
NotificationCountType,
+ RelationType,
Room,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
+ MessageEvent,
NotificationType,
RoomToParents,
RoomType,
return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
};
+export const trimReplyFromBody = (body: string): string => {
+ const match = body.match(/^>\s<.+?>\s.+\n\n/);
+ if (!match) return body;
+ return body.slice(match[0].length);
+};
+
+export const trimReplyFromFormattedBody = (formattedBody: string): string => {
+ const suffix = '</mx-reply>';
+ const i = formattedBody.lastIndexOf(suffix);
+ if (i < 0) {
+ return formattedBody;
+ }
+ return formattedBody.slice(i + suffix.length);
+};
+
export const parseReplyBody = (userId: string, body: string) =>
`> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
},
shortcode,
});
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+ timelineSet.relations.getChildEventsForEvent(
+ eventId,
+ RelationType.Annotation,
+ EventType.Reaction
+ );
+
+export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
+ timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
+
+export const getLatestEdit = (
+ targetEvent: MatrixEvent,
+ editEvents: MatrixEvent[]
+): MatrixEvent | undefined => {
+ const eventByTargetSender = (rEvent: MatrixEvent) =>
+ rEvent.getSender() === targetEvent.getSender();
+ return editEvents.sort((m1, m2) => m2.getTs() - m1.getTs()).find(eventByTargetSender);
+};
+
+export const getEditedEvent = (
+ mEventId: string,
+ mEvent: MatrixEvent,
+ timelineSet: EventTimelineSet
+): MatrixEvent | undefined => {
+ const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
+ return edits && getLatestEdit(mEvent, edits.getRelations());
+};
+
+export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) =>
+ mEvent.getSender() === mx.getUserId() &&
+ !mEvent.isRelation() &&
+ mEvent.getType() === MessageEvent.RoomMessage &&
+ (mEvent.getContent().msgtype === MsgType.Text ||
+ mEvent.getContent().msgtype === MsgType.Emote ||
+ mEvent.getContent().msgtype === MsgType.Notice);
+
+export const getLatestEditableEvt = (
+ timeline: EventTimeline,
+ canEdit: (mEvent: MatrixEvent) => boolean
+): MatrixEvent | undefined => {
+ const events = timeline.getEvents();
+
+ for (let i = events.length - 1; i >= 0; i -= 1) {
+ const evt = events[i];
+ if (canEdit(evt)) return evt;
+ }
+ return undefined;
+};
'data-mx-maths',
'data-mx-pill',
'data-mx-ping',
+ 'data-md',
],
div: ['data-mx-maths'],
- a: ['name', 'target', 'href', 'rel'],
+ a: ['name', 'target', 'href', 'rel', 'data-md'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
ol: ['start'],
- code: ['class'],
+ code: ['class', 'data-md'],
+ strong: ['data-md'],
+ i: ['data-md'],
+ em: ['data-md'],
+ u: ['data-md'],
+ s: ['data-md'],
+ del: ['data-md'],
};
const transformFontTag: Transformer = (tagName, attribs) => ({