Kaynağa Gözat

Add copy and quote message actions (#3336)

* Add Copy action button

* Add Quote action button

* Fix tests to support adding more/always present message actions

* Fix message-actions error in many tests

This happens when updating on change:connection_status.

* Fix incorrect modifications to tests

* Add a few tests for quote and copy actions

* Add copy and quote actions to CHANGES

* Unfocus added tests

* Add missing optional semicolons
BetaRays 1 yıl önce
ebeveyn
işleme
13cf6e02ae

+ 1 - 0
CHANGES.md

@@ -2,6 +2,7 @@
 
 ## 11.0.0 (Unreleased)
 
+- #1195: Add actions to quote and copy messages
 - #2716: Fix issue with chat display when opening via URL
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
 - #3300: Adding the maxWait option for `debouncedPruneHistory`

+ 2 - 0
karma.conf.js

@@ -46,6 +46,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/adhoc-views/tests/adhoc.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks-list.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
@@ -70,6 +71,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },

+ 4 - 0
src/headless/plugins/chat/model.js

@@ -1139,6 +1139,10 @@ class ChatBox extends ModelWithContact {
     isScrolledUp () {
         return this.ui.get('scrolled');
     }
+
+    canPostMessages () {
+        return true;
+    }
 }
 
 export default ChatBox;

+ 4 - 0
src/headless/plugins/muc/muc.js

@@ -967,6 +967,10 @@ class MUC extends ChatBox {
         return self && self.isModerator() && api.disco.supports(Strophe.NS.MODERATE, this.get('jid'));
     }
 
+    canPostMessages () {
+        return this.isEntered() && !(this.features.get('moderated') && this.getOwnRole() === 'visitor');
+    }
+
     /**
      * Return an array of unique nicknames based on all occupants and messages in this MUC.
      * @private

+ 5 - 5
src/plugins/chatview/message-form.js

@@ -57,7 +57,7 @@ export default class MessageForm extends CustomElement {
      * @param { number } [position] - The end index of the string to be
      *  replaced with the new value.
      */
-    insertIntoTextArea (value, replace = false, correcting = false, position) {
+    insertIntoTextArea (value, replace = false, correcting = false, position, separator = ' ') {
         const textarea = /** @type {HTMLTextAreaElement} */(this.querySelector('.chat-textarea'));
         if (correcting) {
             u.addClass('correcting', textarea);
@@ -67,17 +67,17 @@ export default class MessageForm extends CustomElement {
         if (replace) {
             if (position && typeof replace == 'string') {
                 textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
-                    offset == position - replace.length ? value + ' ' : match
+                    offset == position - replace.length ? value + separator : match
                 );
             } else {
                 textarea.value = value;
             }
         } else {
             let existing = textarea.value;
-            if (existing && existing[existing.length - 1] !== ' ') {
-                existing = existing + ' ';
+            if (existing && existing[existing.length - 1] !== separator) {
+                existing = existing + separator;
             }
-            textarea.value = existing + value + ' ';
+            textarea.value = existing + value + separator;
         }
         const ev = document.createEvent('HTMLEvents');
         ev.initEvent('change', false, true);

+ 109 - 0
src/plugins/chatview/tests/actions.js

@@ -0,0 +1,109 @@
+/*global mock, converse */
+
+const { $msg, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+    it("Can be copied using a message action",
+            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        const { api } = _converse;
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        const textarea = view.querySelector('textarea.chat-textarea');
+
+        const firstMessageText = 'But soft, what light through yonder airlock breaks?';
+
+        textarea.value = firstMessageText;
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+
+        const spyClipboard = spyOn(navigator.clipboard, 'writeText');
+        let firstAction = view.querySelector('.chat-msg__action-copy');
+        expect(firstAction).not.toBeNull();
+        firstAction.click();
+        expect(spyClipboard).toHaveBeenCalledOnceWith(firstMessageText);
+
+        // Test messages from other users
+        const secondMessageText = 'Hello';
+        _converse.handleMessageStanza(
+            $msg({
+                'from': contact_jid,
+                'to': api.connection.get().jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t(secondMessageText).up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+        );
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        const copyActions = view.querySelectorAll('.chat-msg__action-copy');
+        expect(copyActions.length).toBe(2);
+        let secondAction = copyActions[copyActions.length - 1];
+        expect(secondAction).not.toBeNull();
+        secondAction.click();
+        expect(spyClipboard).toHaveBeenCalledWith(secondMessageText);
+    }));
+
+    it("Can be quoted using a message action",
+            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        const { api } = _converse;
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.openControlBox(_converse);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.chatboxviews.get(contact_jid);
+        const textarea = view.querySelector('textarea.chat-textarea');
+
+        const firstMessageText = 'But soft, what light through yonder airlock breaks?';
+
+        textarea.value = firstMessageText;
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+
+        // Quote with empty text area
+        expect(textarea.value).toBe('');
+        let firstAction = view.querySelector('.chat-msg__action-quote');
+        expect(firstAction).not.toBeNull();
+        firstAction.click();
+        expect(textarea.value).toBe('> ' + firstMessageText + '\n');
+        // Quote with already-present text
+        textarea.value = 'Hi!';
+        firstAction.click();
+        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+
+        // Test messages from other users
+        textarea.value = '';
+        const secondMessageText = 'Hello';
+        _converse.handleMessageStanza(
+            $msg({
+                'from': contact_jid,
+                'to': api.connection.get().jid,
+                'type': 'chat',
+                'id': u.getUniqueId()
+            }).c('body').t(secondMessageText).up()
+            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+        );
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        const quoteActions = view.querySelectorAll('.chat-msg__action-quote');
+        expect(quoteActions.length).toBe(2);
+        let secondAction = quoteActions[quoteActions.length - 1];
+        expect(secondAction).not.toBeNull();
+        secondAction.click();
+        expect(textarea.value).toBe('> ' + secondMessageText + '\n');
+    }));
+
+});

+ 1 - 1
src/plugins/chatview/tests/chatbox.js

@@ -39,7 +39,7 @@ describe("Chatboxes", function () {
                 }).c('body').t('hello world').tree();
             await _converse.handleMessageStanza(msg);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
-            const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
+            const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body .chat-msg__text';
             await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === 'hello world');
         }));
 

+ 2 - 2
src/plugins/chatview/tests/corrections.js

@@ -191,7 +191,7 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => textarea.value === '');
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length >= 1);
         let action = view.querySelector('.chat-msg .chat-msg__action');
         expect(action.textContent.trim()).toBe('Edit');
 
@@ -267,7 +267,7 @@ describe("A Chat Message", function () {
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
         );
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+        expect(view.querySelector('.chat-msg .chat-msg__action .chat-msg__action-edit')).toBeNull()
 
         // Test confirmation dialog
         spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));

+ 3 - 3
src/plugins/muc-views/bottom-panel.js

@@ -13,15 +13,15 @@ export default class MUCBottomPanel extends BottomPanel {
         this.listenTo(this.model, 'change:hidden_occupants', () => this.requestUpdate());
         this.listenTo(this.model, 'change:num_unread_general', () => this.requestUpdate())
         this.listenTo(this.model.features, 'change:moderated', () => this.requestUpdate());
-        this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant)
+        this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant);
         this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant);
         this.listenTo(this.model.session, 'change:connection_status', () => this.requestUpdate());
     }
 
     render () {
         if (!this.model) return '';
-        const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
-        const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
+        const entered = this.model.isEntered();
+        const can_edit = this.model.canPostMessages();
         return tplMUCBottomPanel({
             can_edit, entered,
             'model': this.model,

+ 147 - 0
src/plugins/muc-views/tests/actions.js

@@ -0,0 +1,147 @@
+/*global mock, converse */
+
+const { $msg, u } = converse.env;
+
+describe("A Groupchat Message", function () {
+
+    it("Can be copied using a message action",
+            mock.initConverse([], {}, async function (_converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const stanza = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: 'coven@chat.shakespeare.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const view = _converse.chatboxviews.get(muc_jid);
+        const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+        const message_form = view.querySelector('converse-muc-message-form');
+        const spyClipboard = spyOn(navigator.clipboard, 'writeText');
+
+        const firstMessageText = 'But soft, what light through yonder airlock breaks?';
+        const msg_id = u.getUniqueId();
+        await model.handleMessageStanza($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.api.connection.get().jid,
+                'type': 'groupchat',
+                'id': msg_id,
+            }).c('body').t(firstMessageText).tree());
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+        let firstAction = view.querySelector('.chat-msg__action-copy');
+        expect(firstAction).not.toBeNull();
+        firstAction.click();
+        expect(spyClipboard).toHaveBeenCalledOnceWith(firstMessageText);
+
+        const secondMessageText = 'Hello';
+        textarea.value = secondMessageText;
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+        const copyActions = view.querySelectorAll('.chat-msg__action-copy');
+        expect(copyActions.length).toBe(2);
+        let secondAction = copyActions[copyActions.length - 1];
+        expect(secondAction).not.toBeNull();
+        secondAction.click();
+        expect(spyClipboard).toHaveBeenCalledWith(secondMessageText);
+    }));
+
+    it("Can be quoted using a message action",
+            mock.initConverse([], {}, async function (_converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const stanza = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: 'coven@chat.shakespeare.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const firstMessageText = 'But soft, what light through yonder airlock breaks?';
+        const msg_id = u.getUniqueId();
+        await model.handleMessageStanza($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.api.connection.get().jid,
+                'type': 'groupchat',
+                'id': msg_id,
+            }).c('body').t(firstMessageText).tree());
+
+        const view = _converse.chatboxviews.get(muc_jid);
+        const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+
+        // Quote with empty text area
+        expect(textarea.value).toBe('');
+        let firstAction = await u.waitUntil(() => view.querySelector('.chat-msg__action-quote'));
+        expect(firstAction).not.toBeNull();
+        firstAction.click();
+        expect(textarea.value).toBe('> ' + firstMessageText + '\n');
+        // Quote with already-present text
+        textarea.value = 'Hi!';
+        firstAction.click();
+        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+
+        // Quote with already-present text
+        textarea.value = 'Hi!';
+        firstAction.click();
+        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+
+    }));
+
+    it("Cannot be quoted without permission to speak",
+            mock.initConverse([], {}, async function (_converse) {
+        const muc_jid = 'lounge@montague.lit';
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', ['muc_moderated']);
+        const stanza = $pres({
+                to: 'romeo@montague.lit/_converse.js-29092160',
+                from: 'coven@chat.shakespeare.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const view = _converse.chatboxviews.get(muc_jid);
+
+        const msg_id = u.getUniqueId();
+        await model.handleMessageStanza($msg({
+                'from': 'lounge@montague.lit/newguy',
+                'to': _converse.api.connection.get().jid,
+                'type': 'groupchat',
+                'id': msg_id,
+            }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+        // Quoting should be available before losing permission to speak
+        expect(view.querySelector('.chat-msg__action-quote')).not.toBeNull();
+
+        const presence = $pres({
+                to: 'romeo@montague.lit/orchard',
+                from: `${muc_jid}/romeo`
+            }).c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {'affiliation': 'none', 'role': 'visitor'}).up()
+            .c('status', {code: '110'});
+        _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+        await u.waitUntil(() => occupant.get('role') === 'visitor');
+        expect(view.querySelector('.chat-msg__action-quote')).toBeNull();
+    }));
+
+});

+ 1 - 1
src/plugins/muc-views/tests/mep.js

@@ -202,7 +202,7 @@ describe("A XEP-0316 MEP notification", function () {
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
         expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
         expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
-        expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBe(1);
+        expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBeGreaterThanOrEqual(1);
         const action = view.querySelector('converse-message-actions converse-dropdown .chat-msg__action');
         expect(action.textContent.trim()).toBe('Retract');
         action.click();

+ 1 - 1
src/plugins/muc-views/tests/retractions.js

@@ -755,7 +755,7 @@ describe("Message Retractions", function () {
             // Check that you can only edit a message before it's been
             // reflected. You can't retract because it hasn't
             await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-edit'));
-            expect(view.querySelectorAll('.chat-msg__action').length).toBe(1);
+            expect(view.querySelector('.chat-msg__action .chat-msg__action-retract')).toBeNull();
 
             const stanza_id = 'retraction-id-1';
             const msg_obj = view.model.messages.at(0);

+ 48 - 1
src/shared/chat/message-actions.js

@@ -1,6 +1,6 @@
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
-import { api, converse, log } from '@converse/headless';
+import { api, converse, log, _converse } from '@converse/headless';
 import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
 import { getMediaURLs } from '@converse/headless/shared/chat/utils.js';
 import { CHATROOMS_TYPE } from '@converse/headless/shared/constants';
@@ -43,6 +43,16 @@ class MessageActions extends CustomElement {
         this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
         this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
+        // This may change the ability to send messages, and therefore the presence of the quote button.
+        // See plugins/muc-views/bottom-panel.js
+        this.listenTo(this.model.collection.chatbox.features, 'change:moderated', () => this.requestUpdate());
+        this.listenTo(this.model.collection.chatbox.occupants, 'add', this.updateIfOwnOccupant);
+        this.listenTo(this.model.collection.chatbox.occupants, 'change:role', this.updateIfOwnOccupant);
+        this.listenTo(this.model.collection.chatbox.session, 'change:connection_status', () => this.requestUpdate());
+    }
+
+    updateIfOwnOccupant (o) {
+        (o.get('jid') === _converse.bare_jid) && this.requestUpdate();
     }
 
     render () {
@@ -50,6 +60,11 @@ class MessageActions extends CustomElement {
     }
 
     async renderActions () {
+        // This can be called before the model has been added to the collection
+        // when requesting an update on change:connection_status.
+        // This line allows us to pass tests.
+        if (!this.model.collection) return '';
+
         // We want to let the message actions menu drop upwards if we're at the
         // bottom of the message history, and down otherwise. This is to avoid
         // the menu disappearing behind the bottom panel (toolbar, textarea etc).
@@ -270,6 +285,20 @@ class MessageActions extends CustomElement {
         }
     }
 
+    async onMessageCopyButtonClicked (ev) {
+        ev?.preventDefault?.();
+        await navigator.clipboard.writeText(this.model.getMessageText());
+    }
+
+    onMessageQuoteButtonClicked (ev) {
+        ev?.preventDefault?.();
+        const view = _converse.chatboxviews.get(this.model.collection.chatbox.get('jid'));
+        view?.getMessageForm().insertIntoTextArea(
+            this.model.getMessageText().replaceAll(/^/gm, '> '),
+            false, false, null, '\n'
+        );
+    }
+
     async getActionButtons () {
         const buttons = [];
         if (this.model.get('editable')) {
@@ -303,6 +332,24 @@ class MessageActions extends CustomElement {
 
         this.addMediaRenderingToggle(buttons);
 
+        buttons.push({
+            'i18n_text': __('Copy'),
+            'handler': ev => this.onMessageCopyButtonClicked(ev),
+            'button_class': 'chat-msg__action-copy',
+            'icon_class': 'fas fa-copy',
+            'name': 'copy',
+        });
+
+        if (this.model.collection.chatbox.canPostMessages()) {
+            buttons.push({
+                'i18n_text': __('Quote'),
+                'handler': ev => this.onMessageQuoteButtonClicked(ev),
+                'button_class': 'chat-msg__action-quote',
+                'icon_class': 'fas fa-quote-right',
+                'name': 'quote',
+            });
+        }
+
         /**
          * *Hook* which allows plugins to add more message action buttons
          * @event _converse#getMessageActionButtons

+ 6 - 0
src/shared/components/templates/icons.js

@@ -226,5 +226,11 @@ export default () => html`
     <symbol id="icon-refresh" viewBox="0 0 512 512">
         <path d="M464 16c-17.67 0-32 14.31-32 32v74.09C392.1 66.52 327.4 32 256 32C161.5 32 78.59 92.34 49.58 182.2c-5.438 16.81 3.797 34.88 20.61 40.28c16.89 5.5 34.88-3.812 40.3-20.59C130.9 138.5 189.4 96 256 96c50.5 0 96.26 24.55 124.4 64H336c-17.67 0-32 14.31-32 32s14.33 32 32 32h128c17.67 0 32-14.31 32-32V48C496 30.31 481.7 16 464 16zM441.8 289.6c-16.92-5.438-34.88 3.812-40.3 20.59C381.1 373.5 322.6 416 256 416c-50.5 0-96.25-24.55-124.4-64H176c17.67 0 32-14.31 32-32s-14.33-32-32-32h-128c-17.67 0-32 14.31-32 32v144c0 17.69 14.33 32 32 32s32-14.31 32-32v-74.09C119.9 445.5 184.6 480 255.1 480c94.45 0 177.4-60.34 206.4-150.2C467.9 313 458.6 294.1 441.8 289.6z"></path>
     </symbol>
+    <symbol id="icon-copy" viewBox="0 0 448 512">
+        <path d="M320 448v40c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V120c0-13.3 10.7-24 24-24h72v296c0 30.9 25.1 56 56 56h168zm0-344V0H152c-13.3 0-24 10.7-24 24v368c0 13.3 10.7 24 24 24h272c13.3 0 24-10.7 24-24V128H344c-13.2 0-24-10.8-24-24zm121-31L375 7A24 24 0 0 0 358.1 0H352v96h96v-6.1a24 24 0 0 0 -7-17z"></path>
+    </symbol>
+    <symbol id="icon-quote-right" viewBox="0 0 512 512">
+        <path d="M464 32H336c-26.5 0-48 21.5-48 48v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48zm-288 0H48C21.5 32 0 53.5 0 80v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48z"></path>
+    </symbol>
     </svg>
 `;