From 702a8ff4a947adba2f859f9e063655d8d3652b44 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sun, 8 Dec 2019 01:01:19 +0000 Subject: [PATCH 001/101] Change ref handling in TextualBody to prevent it parsing generated nodes Remove unused paths Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/HtmlUtils.js | 5 ++-- .../views/context_menus/MessageContextMenu.js | 2 +- src/components/views/messages/TextualBody.js | 29 ++++++++++--------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 2b7384a5aa..9cf3994ff4 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -394,6 +394,7 @@ class TextHighlighter extends BaseHighlighter { * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing * opts.returnString: return an HTML string rather than JSX elements * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer + * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; @@ -476,8 +477,8 @@ export function bodyToHtml(content, highlights, opts={}) { }); return isDisplayedWithHtml ? - : - { strippedBody }; + : + { strippedBody }; } /** diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index efbfc4322f..2084a67cdc 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -422,7 +422,7 @@ module.exports = createReactClass({ ); - if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) { + if (this.props.eventTileOps) { // this event is rendered using TextuaLBody quoteButton = (
); + const channelLink = channel.external_url ? ({channelName}) : channelName; + const networkLink = network && network.external_url ? ({networkName}) + : networkName; + const chanAndNetworkInfo = ( -Bridged into {channelName}{networkName}, on {protocolName}
+Bridged into {channelLink} {networkLink}, on {protocolName}
); + let networkIcon = null; + if (networkName && network.avatar) { + const avatarUrl = ContentRepo.getHttpUriForMxc( + MatrixClientPeg.get().getHomeserverUrl(), + network.avatar, 32, 32, "crop", + ); + networkIcon =Connected via {protocolName}
Bridged into {channelLink} {networkLink}, on {protocolName}
From d9943754f7c936fae5643ae6c16205d99467d9f9 Mon Sep 17 00:00:00 2001 From: Half-ShotStatus: Active
); - } else if (content.status === "disabled") { - status = (Status: Disabled
); - } let creator = null; if (content.creator) { @@ -122,7 +116,6 @@ export default class BridgeSettingsTab extends React.Component {Connected via {protocolName}
- This bridge was provisioned by
{ + _t("This bridge was provisioned by %(pill)s", { + pill, + }) + }
); } - const bot = (
- The bridge is managed by the
{_t("This bridge is managed by the %(pill)s bot user.", {
+ pill:
Bridged into {channelLink} {networkLink}, on {protocolName}
+ (_t("Bridged into %(channelLink)s %(networkLink)s, on %(protocolName)s", { + channelLink, + networkLink, + protocolName, + })) ); let networkIcon = null; @@ -111,14 +118,21 @@ export default class BridgeSettingsTab extends React.Component { url={ avatarUrl } />; } + const heading = _t("Connected to %(channelIcon)s %(channelName)s on %(networkIcon)s %(networkName)s", { + channelIcon, + channelName, + networkName, + networkIcon, + }); + return (Connected via {protocolName}
+{_t("Connected via %(protocolName)s", { protocolName })}
{chanAndNetworkInfo}
Below is a list of bridges connected to this room.
+{ _t("Below is a list of bridges connected to this room.") }
%(homeserverDomain)s
) to configure a TURN server in order for calls to work reliably.": "Xahiş edirik, baş serverin administratoruna müraciət edin (%(homeserverDomain)s
) ki zənglərin etibarlı işləməsi üçün dönüş serverini konfiqurasiya etsin.",
+ "Alternatively, you can try to use the public server at turn.matrix.org
, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternativ olaraq, ümumi serveri turn.matrix.org
istifadə etməyə cəhd edə bilərsiniz, lakin bu qədər etibarlı olmayacaq və IP ünvanınızı bu serverlə bölüşəcəkdir. Bunu Ayarlarda da idarə edə bilərsiniz.",
+ "Try using turn.matrix.org": "Turn.matrix.org istifadə edin",
+ "The file '%(fileName)s' failed to upload.": "'%(fileName)s' faylı yüklənə bilmədi.",
+ "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "'%(fileName)s' faylı yükləmə üçün bu server ölçü həddini aşmışdır",
+ "Send cross-signing keys to homeserver": "Ev serveri üçün çarpaz imzalı açarları göndərin",
+ "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s",
+ "Add rooms to the community": "Icmaya otaqlar əlavə edin",
+ "Failed to invite the following users to %(groupId)s:": "Aşağıdakı istifadəçiləri %(groupId)s - ə dəvət etmək alınmadı:",
+ "Failed to invite users to %(groupId)s": "İstifadəçiləri %(groupId)s - a dəvət etmək alınmadı",
+ "Failed to add the following rooms to %(groupId)s:": "Aşağıdakı otaqları %(groupId)s - a əlavə etmək alınmadı:",
+ "Identity server has no terms of service": "Şəxsiyyət serverinin xidmət şərtləri yoxdur",
+ "This action requires accessing the default identity server turn.matrix.org
, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Також ви можете спробувати використати публічний сервер turn.matrix.org
, але це буде не настільки надійно, а також цей сервер матиме змогу бачити вашу IP-адресу. Ви можете керувати цим у налаштуваннях.",
"Try using turn.matrix.org": "Спробуйте використати turn.matrix.org",
"Replying With Files": "Відповісти файлами",
- "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Зараз неможливо відповісти файлом. Хочете завантажити цей файл без відповіді?",
+ "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Зараз неможливо відповісти файлом. Хочете відвантажити цей файл без відповіді?",
"Name or Matrix ID": "Імʼя або Matrix ID",
"Identity server has no terms of service": "Сервер ідентифікації не має умов надання послуг",
"This action requires accessing the default identity server @bot:*
would ignore all users that have the name 'bot' on any server.": "Добавете тук потребители или сървъри, които искате да игнорирате. Използвайте звездички за да кажете на Riot да търси съвпадения с всеки символ. Например: @bot:*
ще игнорира всички потребители с име 'bot' на кой да е сървър.",
+ "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Игнорирането на хора става чрез списъци за блокиране, които съдържат правила кой да бъде блокиран. Абонирането към списък за блокиране означава, че сървърите/потребителите блокирани от този списък ще бъдат скрити от вас.",
+ "Personal ban list": "Персонален списък за блокиране",
+ "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Персоналния ви списък за блокиране съдържа потребители/сървъри, от които не искате да виждате съобщения. След игнориране на първия потребител/сървър, ще се появи нова стая в списъка със стаи, наречена 'My Ban List' - останете в тази стая за да работи списъкът с блокиране.",
+ "Server or user ID to ignore": "Сървър или потребителски идентификатор за игнориране",
+ "eg: @bot:* or example.org": "напр.: @bot:* или example.org",
+ "Subscribed lists": "Абонирани списъци",
+ "Subscribing to a ban list will cause you to join it!": "Абонирането към списък ще направи така, че да се присъедините към него!",
+ "If this isn't what you want, please use a different tool to ignore users.": "Ако това не е каквото искате, използвайте друг инструмент за игнориране на потребители.",
+ "Room ID or alias of ban list": "Идентификатор или име на стая списък за блокиране",
+ "Subscribe": "Абонирай ме",
+ "Cross-signing": "Кръстосано-подписване",
+ "This user has not verified all of their devices.": "Този потребител не е потвърдил всичките си устройства.",
+ "You have not verified this user. This user has verified all of their devices.": "Не сте потвърдили този потребител. Потребителят е потвърдил всичките си устройства.",
+ "You have verified this user. This user has verified all of their devices.": "Потвърдили сте този потребител. Потребителят е потвърдил всичките си устройства.",
+ "Some users in this encrypted room are not verified by you or they have not verified their own devices.": "Някои потребители в тази стая не са потвърдени от вас или не са потвърдили собствените си устройства.",
+ "All users in this encrypted room are verified by you and they have verified their own devices.": "Всички потребители в тази стая са потвърдени от вас и са потвърдили всичките си устройства.",
+ "This message cannot be decrypted": "Съобщението не може да бъде дешифровано",
+ "Unencrypted": "Нешифровано",
+ "Close preview": "Затвори прегледа",
+ "{ - _t("This bridge was provisioned by %(pill)s", { - pill, - }) - }
); + creator = { _t("This bridge was provisioned by
{_t("This bridge is managed by the %(pill)s bot user.", {
- pill: {_t("Connected via %(protocolName)s", { protocolName })} {_t("This bridge is managed by the {_t("This bridge is managed by {heading}
+ {heading}
{_t("Recent Conversations")}
+ {sectionName}
{tiles}
{showMore}
{_t("No results")}
+ which breaks quoting in RT mode
- if (fragment.document.nodes.size && fragment.document.nodes.get(0).type === DEFAULT_NODE) {
- change = change.insertFragmentByKey(quote.key, 0, fragment.document.nodes.get(0));
- } else {
- change = change.insertFragmentByKey(quote.key, 0, fragment.document);
- }
-
- // XXX: this is to bring back the focus in a sane place and add a paragraph after it
- change = change.select(Range.create({
- anchor: {
- key: quote.key,
- },
- focus: {
- key: quote.key,
- },
- })).moveToEndOfBlock().insertBlock(Block.create(DEFAULT_NODE)).focus();
-
- this.onChange(change);
- } else {
- const fragmentChange = fragment.change();
- fragmentChange.moveToRangeOfNode(fragment.document)
- .wrapBlock(quote);
-
- // FIXME: handle pills and use commonmark rather than md-serialize
- const md = this.md.serialize(fragmentChange.value);
- const change = editorState.change()
- .insertText(md + '\n\n')
- .focus();
- this.onChange(change);
- }
- }
- break;
- }
- };
-
- onChange = (change: Change, originalEditorState?: Value) => {
- let editorState = change.value;
-
- if (this.direction !== '') {
- const focusedNode = editorState.focusInline || editorState.focusText;
- if (editorState.schema.isVoid(focusedNode)) {
- // XXX: does this work in RTL?
- const edge = this.direction === 'Previous' ? 'End' : 'Start';
- if (editorState.selection.isCollapsed) {
- change = change[`moveTo${ edge }Of${ this.direction }Text`]();
- } else {
- const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText;
- if (block) {
- change = change[`moveFocusTo${ edge }OfNode`](block);
- }
- }
- editorState = change.value;
- }
- }
-
- // when in autocomplete mode and selection changes hide the autocomplete.
- // Selection changes when we enter text so use a heuristic to compare documents without doing it recursively
- if (this.autocomplete.state.completionList.length > 0 && !this.autocomplete.state.hide &&
- !rangeEquals(this.state.editorState.selection, editorState.selection) &&
- // XXX: the heuristic failed when inlines like pills weren't taken into account. This is inideal
- this.state.editorState.document.toJSON() === editorState.document.toJSON()) {
- this.autocomplete.hide();
- }
-
- if (Plain.serialize(editorState) !== '') {
- TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, true);
- } else {
- TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, false);
- }
-
- if (editorState.startText !== null) {
- const text = editorState.startText.text;
- const currentStartOffset = editorState.selection.start.offset;
-
- // Automatic replacement of plaintext emoji to Unicode emoji
- if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
- // The first matched group includes just the matched plaintext emoji
- const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(text.slice(0, currentStartOffset));
- if (emoticonMatch) {
- const query = emoticonMatch[1].toLowerCase().replace("-", "");
- const data = EMOTICON_TO_EMOJI.get(query);
-
- // only perform replacement if we found a match, otherwise we would be not letting user type
- if (data) {
- const range = Range.create({
- anchor: {
- key: editorState.startText.key,
- offset: currentStartOffset - emoticonMatch[1].length - 1,
- },
- focus: {
- key: editorState.startText.key,
- offset: currentStartOffset - 1,
- },
- });
- change = change.insertTextAtRange(range, data.unicode);
- editorState = change.value;
- }
- }
- }
- }
-
- if (this.props.onInputStateChanged && editorState.blocks.size > 0) {
- let blockType = editorState.blocks.first().type;
- // console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks);
-
- if (blockType === 'list-item') {
- const parent = editorState.document.getParent(editorState.blocks.first().key);
- if (parent.type === 'numbered-list') {
- blockType = 'numbered-list';
- } else if (parent.type === 'bulleted-list') {
- blockType = 'bulleted-list';
- }
- }
- const inputState = {
- marks: editorState.activeMarks,
- blockType,
- };
- this.props.onInputStateChanged(inputState);
- }
-
- // Record the editor state for this room so that it can be retrieved after switching to another room and back
- MessageComposerStore.setEditorState(this.props.room.roomId, editorState, this.state.isRichTextEnabled);
-
- this.setState({
- editorState,
- originalEditorState: originalEditorState || null,
- });
- };
-
- mdToRichEditorState(editorState: Value): Value {
- // for consistency when roundtripping, we could use slate-md-serializer rather than
- // commonmark, but then we would lose pills as the MD deserialiser doesn't know about
- // them and doesn't have any extensibility hooks.
- //
- // The code looks like this:
- //
- // const markdown = this.plainWithMdPills.serialize(editorState);
- //
- // // weirdly, the Md serializer can't deserialize '' to a valid Value...
- // if (markdown !== '') {
- // editorState = this.md.deserialize(markdown);
- // }
- // else {
- // editorState = Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
- // }
-
- // so, instead, we use commonmark proper (which is arguably more logical to the user
- // anyway, as they'll expect the RTE view to match what they'll see in the timeline,
- // but the HTML->MD conversion is anyone's guess).
-
- const textWithMdPills = this.plainWithMdPills.serialize(editorState);
- const markdown = new Markdown(textWithMdPills);
- // HTML deserialize has custom rules to turn permalinks into pill objects.
- return this.html.deserialize(markdown.toHTML());
- }
-
- richToMdEditorState(editorState: Value): Value {
- // FIXME: this conversion loses pills (turning them into pure MD links).
- // We need to add a pill-aware deserialize method
- // to PlainWithPillsSerializer which recognises pills in raw MD and turns them into pills.
- return Plain.deserialize(
- // FIXME: we compile the MD out of the RTE state using slate-md-serializer
- // which doesn't roundtrip symmetrically with commonmark, which we use for
- // compiling MD out of the MD editor state above.
- this.md.serialize(editorState),
- { defaultBlock: DEFAULT_NODE },
- );
- }
-
- enableRichtext(enabled: boolean) {
- if (enabled === this.state.isRichTextEnabled) return;
-
- Analytics.setRichtextMode(enabled);
-
- this.setState({
- editorState: this.createEditorState(
- enabled,
- this.state.editorState,
- this.state.isRichTextEnabled,
- ),
- isRichTextEnabled: enabled,
- }, () => {
- this._editor.focus();
- if (this.props.onInputStateChanged) {
- this.props.onInputStateChanged({
- isRichTextEnabled: enabled,
- });
- }
- });
-
- SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled);
- }
-
- /**
- * Check if the current selection has a mark with `type` in it.
- *
- * @param {String} type
- * @return {Boolean}
- */
-
- hasMark = type => {
- const { editorState } = this.state;
- return editorState.activeMarks.some(mark => mark.type === type);
- };
-
- /**
- * Check if the any of the currently selected blocks are of `type`.
- *
- * @param {String} type
- * @return {Boolean}
- */
-
- hasBlock = type => {
- const { editorState } = this.state;
- return editorState.blocks.some(node => node.type === type);
- };
-
- onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => {
- this.suppressAutoComplete = false;
- this.direction = '';
-
- // Navigate autocomplete list with arrow keys
- if (this.autocomplete.countCompletions() > 0) {
- if (!(ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey)) {
- switch (ev.key) {
- case Key.ARROW_UP:
- this.autocomplete.moveSelection(-1);
- ev.preventDefault();
- return true;
- case Key.ARROW_DOWN:
- this.autocomplete.moveSelection(+1);
- ev.preventDefault();
- return true;
- }
- }
- }
-
- // skip void nodes - see
- // https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095
- if (ev.key === Key.ARROW_LEFT) {
- this.direction = 'Previous';
- } else if (ev.key === Key.ARROW_RIGHT) {
- this.direction = 'Next';
- }
-
- switch (ev.key) {
- case Key.ENTER:
- return this.handleReturn(ev, change);
- case Key.BACKSPACE:
- return this.onBackspace(ev, change);
- case Key.ARROW_UP:
- return this.onVerticalArrow(ev, true);
- case Key.ARROW_DOWN:
- return this.onVerticalArrow(ev, false);
- case Key.TAB:
- return this.onTab(ev);
- case Key.ESCAPE:
- return this.onEscape(ev);
- case Key.SPACE:
- return this.onSpace(ev, change);
- }
-
- if (isOnlyCtrlOrCmdKeyEvent(ev)) {
- const ctrlCmdCommand = {
- // C-m => Toggles between rich text and markdown modes
- [Key.M]: 'toggle-mode',
- [Key.B]: 'bold',
- [Key.I]: 'italic',
- [Key.U]: 'underlined',
- [Key.J]: 'inline-code',
- }[ev.key];
-
- if (ctrlCmdCommand) {
- ev.preventDefault(); // to prevent clashing with Mac's minimize window
- return this.handleKeyCommand(ctrlCmdCommand);
- }
- }
- };
-
- onSpace = (ev: KeyboardEvent, change: Change): Change => {
- if (ev.metaKey || ev.altKey || ev.shiftKey || ev.ctrlKey) {
- return;
- }
-
- // drop a point in history so the user can undo a word
- // XXX: this seems nasty but adding to history manually seems a no-go
- ev.preventDefault();
- return change.withoutMerging(() => {
- change.insertText(ev.key);
- });
- };
-
- onBackspace = (ev: KeyboardEvent, change: Change): Change => {
- if (ev.metaKey || ev.altKey || ev.shiftKey) {
- return;
- }
-
- const { editorState } = this.state;
-
- // Allow Ctrl/Cmd-Backspace when focus starts at the start of the composer (e.g select-all)
- // for some reason if slate sees you Ctrl-backspace and your anchor.offset=0 it just resets your focus
- // XXX: Doing this now seems to put slate into a broken state, and it didn't appear to be doing
- // what it claims to do on the old version of slate anyway...
- /*if (!editorState.isCollapsed && editorState.selection.anchor.offset === 0) {
- return change.delete();
- }*/
-
- if (this.state.isRichTextEnabled) {
- // let backspace exit lists
- const isList = this.hasBlock('list-item');
-
- if (isList && editorState.selection.anchor.offset == 0) {
- change
- .setBlocks(DEFAULT_NODE)
- .unwrapBlock('bulleted-list')
- .unwrapBlock('numbered-list');
- return change;
- } else if (editorState.selection.anchor.offset == 0 && editorState.isCollapsed) {
- // turn blocks back into paragraphs
- if ((this.hasBlock('block-quote') ||
- this.hasBlock('heading1') ||
- this.hasBlock('heading2') ||
- this.hasBlock('heading3') ||
- this.hasBlock('heading4') ||
- this.hasBlock('heading5') ||
- this.hasBlock('heading6') ||
- this.hasBlock('code'))) {
- return change.setBlocks(DEFAULT_NODE);
- }
-
- // remove paragraphs entirely if they're nested
- const parent = editorState.document.getParent(editorState.anchorBlock.key);
- if (editorState.selection.anchor.offset == 0 &&
- this.hasBlock('paragraph') &&
- parent.nodes.size == 1 &&
- parent.object !== 'document') {
- return change.replaceNodeByKey(editorState.anchorBlock.key, editorState.anchorText)
- .moveToEndOfNode(parent)
- .focus();
- }
- }
- }
- return;
- };
-
- handleKeyCommand = (command: string): boolean => {
- if (command === 'toggle-mode') {
- this.enableRichtext(!this.state.isRichTextEnabled);
- return true;
- }
-
- //const newState: ?Value = null;
-
- // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
- if (this.state.isRichTextEnabled) {
- const type = command;
- const { editorState } = this.state;
- const change = editorState.change();
- const { document } = editorState;
- switch (type) {
- // list-blocks:
- case 'bulleted-list':
- case 'numbered-list': {
- // Handle the extra wrapping required for list buttons.
- const isList = this.hasBlock('list-item');
- const isType = editorState.blocks.some(block => {
- return !!document.getClosest(block.key, parent => parent.type === type);
- });
-
- if (isList && isType) {
- change
- .setBlocks(DEFAULT_NODE)
- .unwrapBlock('bulleted-list')
- .unwrapBlock('numbered-list');
- } else if (isList) {
- change
- .unwrapBlock(
- type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list',
- )
- .wrapBlock(type);
- } else {
- change.setBlocks('list-item').wrapBlock(type);
- }
- }
- break;
-
- // simple blocks
- case 'paragraph':
- case 'block-quote':
- case 'heading1':
- case 'heading2':
- case 'heading3':
- case 'heading4':
- case 'heading5':
- case 'heading6':
- case 'list-item':
- case 'code': {
- const isActive = this.hasBlock(type);
- const isList = this.hasBlock('list-item');
-
- if (isList) {
- change
- .setBlocks(isActive ? DEFAULT_NODE : type)
- .unwrapBlock('bulleted-list')
- .unwrapBlock('numbered-list');
- } else {
- change.setBlocks(isActive ? DEFAULT_NODE : type);
- }
- }
- break;
-
- // marks:
- case 'bold':
- case 'italic':
- case 'inline-code':
- case 'underlined':
- case 'deleted': {
- change.toggleMark(type === 'inline-code' ? 'code' : type);
- }
- break;
-
- default:
- console.warn(`ignoring unrecognised RTE command ${type}`);
- return false;
- }
-
- this.onChange(change);
-
- return true;
- } else {
-/*
- const contentState = this.state.editorState.getCurrentContent();
- const multipleLinesSelected = RichText.hasMultiLineSelection(this.state.editorState);
-
- const selectionState = this.state.editorState.getSelection();
- const start = selectionState.getStartOffset();
- const end = selectionState.getEndOffset();
-
- // If multiple lines are selected or nothing is selected, insert a code block
- // instead of applying inline code formatting. This is an attempt to mimic what
- // happens in non-MD mode.
- const treatInlineCodeAsBlock = multipleLinesSelected || start === end;
- const textMdCodeBlock = (text) => `\`\`\`\n${text}\n\`\`\`\n`;
- const modifyFn = {
- 'bold': (text) => `**${text}**`,
- 'italic': (text) => `*${text}*`,
- 'underline': (text) => `${text}`,
- 'strike': (text) => `${text}`,
- // ("code" is triggered by ctrl+j by draft-js by default)
- 'code': (text) => treatInlineCodeAsBlock ? textMdCodeBlock(text) : `\`${text}\``,
- 'code': textMdCodeBlock,
- 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join('') + '\n',
- 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''),
- 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''),
- }[command];
-
- const selectionAfterOffset = {
- 'bold': -2,
- 'italic': -1,
- 'underline': -4,
- 'strike': -6,
- 'code': treatInlineCodeAsBlock ? -5 : -1,
- 'code': -5,
- 'blockquote': -2,
- }[command];
-
- // Returns a function that collapses a selection to its end and moves it by offset
- const collapseAndOffsetSelection = (selection, offset) => {
- const key = selection.endKey();
- return new Range({
- anchorKey: key, anchor.offset: offset,
- focus.key: key, focus.offset: offset,
- });
- };
-
- if (modifyFn) {
-
- const previousSelection = this.state.editorState.getSelection();
- const newContentState = RichText.modifyText(contentState, previousSelection, modifyFn);
- newState = EditorState.push(
- this.state.editorState,
- newContentState,
- 'insert-characters',
- );
-
- let newSelection = newContentState.getSelectionAfter();
- // If the selection range is 0, move the cursor inside the formatted body
- if (previousSelection.getStartOffset() === previousSelection.getEndOffset() &&
- previousSelection.getStartKey() === previousSelection.getEndKey() &&
- selectionAfterOffset !== undefined
- ) {
- const selectedBlock = newContentState.getBlockForKey(previousSelection.getAnchorKey());
- const blockLength = selectedBlock.getText().length;
- const newOffset = blockLength + selectionAfterOffset;
- newSelection = collapseAndOffsetSelection(newSelection, newOffset);
- }
-
- newState = EditorState.forceSelection(newState, newSelection);
- }
- }
-
- if (newState != null) {
- this.setState({editorState: newState});
- return true;
- }
-*/
- }
- return false;
- };
-
- onPaste = (event: Event, change: Change, editor: Editor): Change => {
- const transfer = getEventTransfer(event);
-
- switch (transfer.type) {
- case 'files':
- // This actually not so much for 'files' as such (at time of writing
- // neither chrome nor firefox let you paste a plain file copied
- // from Finder) but more images copied from a different website
- // / word processor etc.
- return ContentMessages.sharedInstance().sendContentListToRoom(
- transfer.files, this.props.room.roomId, this.client,
- );
- case 'html': {
- if (this.state.isRichTextEnabled) {
- // FIXME: https://github.com/ianstormtaylor/slate/issues/1497 means
- // that we will silently discard nested blocks (e.g. nested lists) :(
- const fragment = this.html.deserialize(transfer.html);
- return change
- // XXX: this somehow makes Slate barf on undo and get too empty and break entirely
- // .setOperationFlag("skip", false)
- // .setOperationFlag("merge", false)
- .insertFragment(fragment.document);
- } else {
- // in MD mode we don't want the rich content pasted as the magic was annoying people so paste plain
- return change.withoutMerging(() => {
- change.insertText(transfer.text);
- });
- }
- }
- case 'text':
- // don't skip/merge so that multiple consecutive pastes can be undone individually
- return change.withoutMerging(() => {
- change.insertText(transfer.text);
- });
- }
- };
-
- handleReturn = (ev, change) => {
- const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
- if (ev.shiftKey || (isMac && ev.altKey)) {
- return change.insertText('\n');
- }
-
- if (this.autocomplete.hasSelection()) {
- this.autocomplete.hide();
- ev.preventDefault();
- return true;
- }
-
- const editorState = this.state.editorState;
-
- const lastBlock = editorState.blocks.last();
- if (['code', 'block-quote', 'list-item'].includes(lastBlock.type)) {
- const text = lastBlock.text;
- if (text === '') {
- // allow the user to cancel empty block by hitting return, useful in conjunction with below `inBlock`
- return change
- .setBlocks(DEFAULT_NODE)
- .unwrapBlock('bulleted-list')
- .unwrapBlock('numbered-list');
- }
-
- // TODO strip trailing lines from blockquotes/list entries
- // the below code seemingly works but doesn't account for edge cases like return with caret not at end
- /* const trailingNewlines = text.match(/\n*$/);
- if (trailingNewlines && trailingNewlines[0]) {
- remove trailing newlines at the end of this block before making a new one
- return change.deleteBackward(trailingNewlines[0].length);
- }*/
-
- return;
- }
-
- let contentText;
- let contentHTML;
-
- // only look for commands if the first block contains simple unformatted text
- // i.e. no pills or rich-text formatting and begins with a /.
- let cmd; let commandText;
- const firstChild = editorState.document.nodes.get(0);
- const firstGrandChild = firstChild && firstChild.nodes.get(0);
- if (firstChild && firstGrandChild &&
- firstChild.object === 'block' && firstGrandChild.object === 'text' &&
- firstGrandChild.text[0] === '/') {
- commandText = this.plainWithIdPills.serialize(editorState);
- cmd = processCommandInput(this.props.room.roomId, commandText);
- }
-
- if (cmd) {
- if (!cmd.error) {
- this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
- this.setState({
- editorState: this.createEditorState(),
- }, ()=>{
- this._editor.focus();
- });
- }
- if (cmd.promise) {
- cmd.promise.then(()=>{
- console.log("Command success.");
- }, (err)=>{
- console.error("Command failure: %s", err);
- const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
- Modal.createTrackedDialog('Server error', '', ErrorDialog, {
- title: _t("Server error"),
- description: ((err && err.message) ? err.message : _t(
- "Server unavailable, overloaded, or something else went wrong.",
- )),
- });
- });
- } else if (cmd.error) {
- console.error(cmd.error);
- const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
- // TODO possibly track which command they ran (not its Arguments) here
- Modal.createTrackedDialog('Command error', '', ErrorDialog, {
- title: _t("Command error"),
- description: cmd.error,
- });
- }
- return true;
- }
-
- const replyingToEv = RoomViewStore.getQuotingEvent();
- const mustSendHTML = Boolean(replyingToEv);
-
- if (this.state.isRichTextEnabled) {
- // We should only send HTML if any block is styled or contains inline style
- let shouldSendHTML = false;
-
- if (mustSendHTML) shouldSendHTML = true;
-
- if (!shouldSendHTML) {
- shouldSendHTML = !!editorState.document.findDescendant(node => {
- // N.B. node.getMarks() might be private?
- return ((node.object === 'block' && node.type !== 'paragraph') ||
- (node.object === 'inline') ||
- (node.object === 'text' && node.getMarks().size > 0));
- });
- }
-
- contentText = this.plainWithPlainPills.serialize(editorState);
- if (contentText === '') return true;
-
- if (shouldSendHTML) {
- contentHTML = HtmlUtils.processHtmlForSending(this.html.serialize(editorState));
- }
- } else {
- const sourceWithPills = this.plainWithMdPills.serialize(editorState);
- if (sourceWithPills === '') return true;
-
- const mdWithPills = new Markdown(sourceWithPills);
-
- // if contains no HTML and we're not quoting (needing HTML)
- if (mdWithPills.isPlainText() && !mustSendHTML) {
- // N.B. toPlainText is only usable here because we know that the MD
- // didn't contain any formatting in the first place...
- contentText = mdWithPills.toPlaintext();
- } else {
- // to avoid ugliness on clients which ignore the HTML body we don't
- // send pills in the plaintext body.
- contentText = this.plainWithPlainPills.serialize(editorState);
- contentHTML = mdWithPills.toHTML();
- }
- }
-
- let sendHtmlFn = ContentHelpers.makeHtmlMessage;
- let sendTextFn = ContentHelpers.makeTextMessage;
-
- this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
-
- if (commandText && commandText.startsWith('/me')) {
- if (replyingToEv) {
- const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
- Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
- title: _t("Unable to reply"),
- description: _t("At this time it is not possible to reply with an emote."),
- });
- return false;
- }
-
- contentText = contentText.substring(4);
- // bit of a hack, but the alternative would be quite complicated
- if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, '');
- sendHtmlFn = ContentHelpers.makeHtmlEmote;
- sendTextFn = ContentHelpers.makeEmoteMessage;
- }
-
- let content = contentHTML ?
- sendHtmlFn(contentText, contentHTML) :
- sendTextFn(contentText);
-
- if (replyingToEv) {
- const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
- content = Object.assign(replyContent, content);
-
- // Part of Replies fallback support - prepend the text we're sending
- // with the text we're replying to
- const nestedReply = ReplyThread.getNestedReplyText(replyingToEv, this.props.permalinkCreator);
- if (nestedReply) {
- if (content.formatted_body) {
- content.formatted_body = nestedReply.html + content.formatted_body;
- }
- content.body = nestedReply.body + content.body;
- }
-
- // Clear reply_to_event as we put the message into the queue
- // if the send fails, retry will handle resending.
- dis.dispatch({
- action: 'reply_to_event',
- event: null,
- });
- }
-
- this.client.sendMessage(this.props.room.roomId, content).then((res) => {
- dis.dispatch({
- action: 'message_sent',
- });
- }).catch((e) => {
- onSendMessageFailed(e, this.props.room);
- });
-
- this.setState({
- editorState: this.createEditorState(),
- }, ()=>{ this._editor.focus(); });
-
- return true;
- };
-
- onVerticalArrow = (e, up) => {
- if (e.ctrlKey || e.shiftKey || e.metaKey) return;
-
- const shouldSelectHistory = e.altKey;
- const shouldEditLastMessage = !e.altKey && up && !RoomViewStore.getQuotingEvent();
-
- if (shouldSelectHistory) {
- // Try select composer history
- const selected = this.selectHistory(up);
- if (selected) {
- // We're selecting history, so prevent the key event from doing anything else
- e.preventDefault();
- }
- } else if (shouldEditLastMessage) {
- // selection must be collapsed
- const selection = this.state.editorState.selection;
- if (!selection.isCollapsed) return;
- // and we must be at the edge of the document (up=start, down=end)
- const document = this.state.editorState.document;
- if (up) {
- if (!selection.anchor.isAtStartOfNode(document)) return;
- } else {
- if (!selection.anchor.isAtEndOfNode(document)) return;
- }
-
- const editEvent = findEditableEvent(this.props.room, false);
- if (editEvent) {
- // We're selecting history, so prevent the key event from doing anything else
- e.preventDefault();
- dis.dispatch({
- action: 'edit_event',
- event: editEvent,
- });
- }
- }
- };
-
- selectHistory = (up) => {
- const delta = up ? -1 : 1;
-
- // True if we are not currently selecting history, but composing a message
- if (this.historyManager.currentIndex === this.historyManager.history.length) {
- // We can't go any further - there isn't any more history, so nop.
- if (!up) {
- return;
- }
- this.setState({
- currentlyComposedEditorState: this.state.editorState,
- });
- } else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
- // True when we return to the message being composed currently
- this.setState({
- editorState: this.state.currentlyComposedEditorState,
- });
- this.historyManager.currentIndex = this.historyManager.history.length;
- return;
- }
-
- let editorState;
- const historyItem = this.historyManager.getItem(delta);
- if (!historyItem) return;
-
- if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
- editorState = this.richToMdEditorState(historyItem.value);
- } else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
- editorState = this.mdToRichEditorState(historyItem.value);
- } else {
- editorState = historyItem.value;
- }
-
- // Move selection to the end of the selected history
- const change = editorState.change().moveToEndOfNode(editorState.document);
-
- // We don't call this.onChange(change) now, as fixups on stuff like pills
- // should already have been done and persisted in the history.
- editorState = change.value;
-
- this.suppressAutoComplete = true;
-
- this.setState({ editorState }, ()=>{
- this._editor.focus();
- });
- return true;
- };
-
- onTab = async (e) => {
- this.setState({
- someCompletions: null,
- });
- e.preventDefault();
- if (this.autocomplete.countCompletions() === 0) {
- // Force completions to show for the text currently entered
- const completionCount = await this.autocomplete.forceComplete();
- this.setState({
- someCompletions: completionCount > 0,
- });
- // Select the first item by moving "down"
- await this.autocomplete.moveSelection(+1);
- } else {
- await this.autocomplete.moveSelection(e.shiftKey ? -1 : +1);
- }
- };
-
- onEscape = async (e) => {
- e.preventDefault();
- if (this.autocomplete) {
- this.autocomplete.onEscape(e);
- }
- await this.setDisplayedCompletion(null); // restore originalEditorState
- };
-
- onAutocompleteConfirm = (displayedCompletion: ?Completion) => {
- this.focusComposer();
- // XXX: this fails if the composer isn't focused so focus it and delay the completion until next tick
- setImmediate(() => {
- this.setDisplayedCompletion(displayedCompletion);
- });
- };
-
- /* If passed null, restores the original editor content from state.originalEditorState.
- * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
- */
- setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
- const activeEditorState = this.state.originalEditorState || this.state.editorState;
-
- if (displayedCompletion == null) {
- if (this.state.originalEditorState) {
- const editorState = this.state.originalEditorState;
- this.setState({editorState});
- }
- return false;
- }
-
- const {
- range = null,
- completion = '',
- completionId = '',
- href = null,
- suffix = '',
- } = displayedCompletion;
-
- let inline;
- if (href) {
- inline = Inline.create({
- type: 'pill',
- data: { completion, completionId, href },
- });
- } else if (completion === '@room') {
- inline = Inline.create({
- type: 'pill',
- data: { completion, completionId },
- });
- }
-
- let editorState = activeEditorState;
-
- if (range) {
- const change = editorState.change()
- .moveToAnchor()
- .moveAnchorTo(range.start)
- .moveFocusTo(range.end)
- .focus();
- editorState = change.value;
- }
-
- let change;
- if (inline) {
- change = editorState.change()
- .insertInlineAtRange(editorState.selection, inline)
- .insertText(suffix)
- .focus();
- } else {
- change = editorState.change()
- .insertTextAtRange(editorState.selection, completion)
- .insertText(suffix)
- .focus();
- }
- // for good hygiene, keep editorState updated to track the result of the change
- // even though we don't do anything subsequently with it
- editorState = change.value;
-
- this.onChange(change, activeEditorState);
-
- return true;
- };
-
- renderNode = props => {
- const { attributes, children, node, isSelected } = props;
-
- switch (node.type) {
- case 'paragraph':
- return
{children}
; - case 'block-quote': - return{children}; - case 'bulleted-list': - return
{children}; - case 'link': - return {children}; - case 'pill': { - const { data } = node; - const url = data.get('href'); - const completion = data.get('completion'); - - const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); - const Pill = sdk.getComponent('elements.Pill'); - - if (completion === '@room') { - return
{children}
;
- case 'underlined':
- return {children};
- case 'deleted':
- return