From b70515576687ee12cff1cdb16131826f37fd7563 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 10 Jan 2025 23:56:57 +0200 Subject: [PATCH 1/6] Remove unused type files --- .../types/plugins/blocking/collection.d.ts | 21 ------------- .../types/plugins/blocking/index.d.ts | 2 -- .../types/plugins/blocking/model.d.ts | 6 ---- .../types/plugins/blocking/plugin.d.ts | 2 -- src/headless/types/plugins/pubsub.d.ts | 4 --- .../minimize/components/minimized-chat.d.ts | 31 ------------------- .../minimize/templates/chats-panel.d.ts | 7 ----- src/types/shared/styling.d.ts | 11 ------- src/types/templates/background_logo.d.ts | 3 -- src/types/templates/form_help.d.ts | 3 -- 10 files changed, 90 deletions(-) delete mode 100644 src/headless/types/plugins/blocking/collection.d.ts delete mode 100644 src/headless/types/plugins/blocking/index.d.ts delete mode 100644 src/headless/types/plugins/blocking/model.d.ts delete mode 100644 src/headless/types/plugins/blocking/plugin.d.ts delete mode 100644 src/headless/types/plugins/pubsub.d.ts delete mode 100644 src/types/plugins/minimize/components/minimized-chat.d.ts delete mode 100644 src/types/plugins/minimize/templates/chats-panel.d.ts delete mode 100644 src/types/shared/styling.d.ts delete mode 100644 src/types/templates/background_logo.d.ts delete mode 100644 src/types/templates/form_help.d.ts diff --git a/src/headless/types/plugins/blocking/collection.d.ts b/src/headless/types/plugins/blocking/collection.d.ts deleted file mode 100644 index d710d7f72b..0000000000 --- a/src/headless/types/plugins/blocking/collection.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default Blocklist; -declare class Blocklist extends Collection { - constructor(); - get idAttribute(): string; - model: typeof BlockedEntity; - initialize(): Promise; - fetched_flag: string; - fetchBlocklist(): any; - /** - * @param {Object} deferred - */ - fetchBlocklistFromServer(deferred: any): Promise; - /** - * @param {Object} deferred - * @param {Element} iq - */ - onBlocklistReceived(deferred: any, iq: Element): Promise; -} -import { Collection } from '@converse/skeletor'; -import BlockedEntity from './model.js'; -//# sourceMappingURL=collection.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/blocking/index.d.ts b/src/headless/types/plugins/blocking/index.d.ts deleted file mode 100644 index e26a57a8ca..0000000000 --- a/src/headless/types/plugins/blocking/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/blocking/model.d.ts b/src/headless/types/plugins/blocking/model.d.ts deleted file mode 100644 index 05b0828531..0000000000 --- a/src/headless/types/plugins/blocking/model.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default BlockedEntity; -declare class BlockedEntity extends Model { - getDisplayName(): any; -} -import { Model } from '@converse/skeletor'; -//# sourceMappingURL=model.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/blocking/plugin.d.ts b/src/headless/types/plugins/blocking/plugin.d.ts deleted file mode 100644 index 6d717ab1c9..0000000000 --- a/src/headless/types/plugins/blocking/plugin.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=plugin.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/pubsub.d.ts b/src/headless/types/plugins/pubsub.d.ts deleted file mode 100644 index 1ec0170046..0000000000 --- a/src/headless/types/plugins/pubsub.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export namespace Strophe { - type Builder = import("strophe.js").Builder; -} -//# sourceMappingURL=pubsub.d.ts.map \ No newline at end of file diff --git a/src/types/plugins/minimize/components/minimized-chat.d.ts b/src/types/plugins/minimize/components/minimized-chat.d.ts deleted file mode 100644 index 937fcca8c3..0000000000 --- a/src/types/plugins/minimize/components/minimized-chat.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -export default class MinimizedChat extends CustomElement { - static get properties(): { - model: { - type: ObjectConstructor; - }; - title: { - type: StringConstructor; - }; - type: { - type: StringConstructor; - }; - num_unread: { - type: NumberConstructor; - }; - }; - model: any; - num_unread: any; - type: any; - title: any; - render(): import("lit").TemplateResult<1>; - /** - * @param {MouseEvent} ev - */ - close(ev: MouseEvent): void; - /** - * @param {MouseEvent} ev - */ - restore(ev: MouseEvent): void; -} -import { CustomElement } from "shared/components/element.js"; -//# sourceMappingURL=minimized-chat.d.ts.map \ No newline at end of file diff --git a/src/types/plugins/minimize/templates/chats-panel.d.ts b/src/types/plugins/minimize/templates/chats-panel.d.ts deleted file mode 100644 index 1b556e44a7..0000000000 --- a/src/types/plugins/minimize/templates/chats-panel.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -<<<<<<< HEAD -declare function _default(o: any): import("lit").TemplateResult<1>; -======= -declare function _default(el: import('../view').default): import("lit").TemplateResult<1>; ->>>>>>> f5b398263 (Update to boostrap5) -export default _default; -//# sourceMappingURL=chats-panel.d.ts.map \ No newline at end of file diff --git a/src/types/shared/styling.d.ts b/src/types/shared/styling.d.ts deleted file mode 100644 index 7be23343af..0000000000 --- a/src/types/shared/styling.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function getDirectiveAndLength(text: any, i: any): { - d: string; - length: number; -} | { - d?: undefined; - length?: undefined; -}; -export function getDirectiveTemplate(d: any, text: any, offset: any, options: any): any; -export function containsDirectives(text: any): boolean; -export function isQuoteDirective(d: any): boolean; -//# sourceMappingURL=styling.d.ts.map \ No newline at end of file diff --git a/src/types/templates/background_logo.d.ts b/src/types/templates/background_logo.d.ts deleted file mode 100644 index d27f12e475..0000000000 --- a/src/types/templates/background_logo.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare function _default(): import("lit").TemplateResult<1>; -export default _default; -//# sourceMappingURL=background_logo.d.ts.map \ No newline at end of file diff --git a/src/types/templates/form_help.d.ts b/src/types/templates/form_help.d.ts deleted file mode 100644 index faf6b81f7f..0000000000 --- a/src/types/templates/form_help.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare function _default(o: any): import("lit").TemplateResult<1>; -export default _default; -//# sourceMappingURL=form_help.d.ts.map \ No newline at end of file From d8048fe9b7bd82b3b4cb60968b7dc1f2b8157597 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 11 Jan 2025 00:24:47 +0200 Subject: [PATCH 2/6] Use stx --- .../chatview/tests/http-file-upload.js | 203 ++++++++---------- 1 file changed, 90 insertions(+), 113 deletions(-) diff --git a/src/plugins/chatview/tests/http-file-upload.js b/src/plugins/chatview/tests/http-file-upload.js index ee138fe8ec..b276eeaed9 100644 --- a/src/plugins/chatview/tests/http-file-upload.js +++ b/src/plugins/chatview/tests/http-file-upload.js @@ -1,8 +1,5 @@ /*global mock, converse */ - -const Strophe = converse.env.Strophe; -const $iq = converse.env.$iq; -const u = converse.env.utils; +const { stx, Strophe, $iq, u } = converse.env; describe("XEP-0363: HTTP File Upload", function () { @@ -15,59 +12,37 @@ describe("XEP-0363: HTTP File Upload", function () { let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'; let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000); - /* - * - * - * - * - * - * - */ - stanza = $iq({ - 'type': 'result', - 'from': 'montague.lit', - 'to': 'romeo@montague.lit/orchard', - 'id': stanza.getAttribute('id'), - }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) - .c('identity', { - 'category': 'server', - 'type': 'im'}).up() - .c('feature', { - 'var': 'http://jabber.org/protocol/disco#info'}).up() - .c('feature', { - 'var': 'http://jabber.org/protocol/disco#items'}); + stanza = stx` + + + + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); // Converse.js sees that the entity has a disco#items feature, // so it will make a query for it. selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'; await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000); - /* - * - * - * - * - * - */ + selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'; stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500); - stanza = $iq({ - 'type': 'result', - 'from': 'montague.lit', - 'to': 'romeo@montague.lit/orchard', - 'id': stanza.getAttribute('id'), - }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) - .c('item', { - 'jid': 'upload.montague.lit', - 'name': 'HTTP File Upload'}); + stanza = stx` + + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); @@ -95,35 +70,25 @@ describe("XEP-0363: HTTP File Upload", function () { ``); // Upload service responds and reports a maximum file size of 5MiB - /* - * - * - * - * - * - * urn:xmpp:http:upload:0 - * - * - * 5242880 - * - * - * - * - */ - stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'}) - .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) - .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up() - .c('feature', {'var':'urn:xmpp:http:upload:0'}).up() - .c('x', {'type':'result', 'xmlns':'jabber:x:data'}) - .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) - .c('value').t('urn:xmpp:http:upload:0').up().up() - .c('field', {'var':'max-file-size'}) - .c('value').t('5242880'); + stanza = stx` + + + + + + + urn:xmpp:http:upload:0 + + + 5242880 + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); entities = await _converse.api.disco.entities.get(); @@ -228,10 +193,11 @@ describe("XEP-0363: HTTP File Upload", function () { const message = base_url+"/logo/conversejs-filled.svg"; - const stanza = u.toStanza(` + const stanza = stx` @@ -240,7 +206,7 @@ describe("XEP-0363: HTTP File Upload", function () { - `); + `; spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () { const message = view.model.messages.at(0); @@ -300,19 +266,18 @@ describe("XEP-0363: HTTP File Upload", function () { iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')); const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; - stanza = $iq({ - 'type': 'result', - 'from': 'montague.lit', - 'to': 'romeo@montague.lit/orchard', - 'id': info_IQ_id - }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) - .c('identity', { - 'category': 'server', - 'type': 'im'}).up() - .c('feature', { - 'var': 'http://jabber.org/protocol/disco#info'}).up() - .c('feature', { - 'var': 'http://jabber.org/protocol/disco#items'}); + stanza = stx` + + + + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); await u.waitUntil(function () { @@ -327,15 +292,16 @@ describe("XEP-0363: HTTP File Upload", function () { return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'); }); const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; - stanza = $iq({ - 'type': 'result', - 'from': 'montague.lit', - 'to': 'romeo@montague.lit/orchard', - 'id': items_IQ_id - }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) - .c('item', { - 'jid': 'upload.montague.lit', - 'name': 'HTTP File Upload'}); + stanza = stx` + + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); @@ -362,15 +328,25 @@ describe("XEP-0363: HTTP File Upload", function () { ``); // Upload service responds and reports a maximum file size of 5MiB - stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'}) - .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) - .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up() - .c('feature', {'var':'urn:xmpp:http:upload:0'}).up() - .c('x', {'type':'result', 'xmlns':'jabber:x:data'}) - .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) - .c('value').t('urn:xmpp:http:upload:0').up().up() - .c('field', {'var':'max-file-size'}) - .c('value').t('5242880'); + stanza = stx` + + + + + + + urn:xmpp:http:upload:0 + + + 5242880 + + + + `; api.connection.get()._dataRecv(mock.createRequest(stanza)); entities = await _converse.api.disco.entities.get(); const entity = await api.disco.entities.get('upload.montague.lit'); @@ -439,10 +415,11 @@ describe("XEP-0363: HTTP File Upload", function () { const base_url = 'https://conversejs.org'; const message = base_url+"/logo/conversejs-filled.svg"; - const stanza = u.toStanza(` + const stanza = stx` @@ -451,7 +428,7 @@ describe("XEP-0363: HTTP File Upload", function () { - `); + `; const promise = u.getOpenPromise(); From d4b550323b772232f656cbd5d2342aa7f7d5eb53 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 10 Jan 2025 23:47:40 +0200 Subject: [PATCH 3/6] Rename RichText to Texture --- .../muc-views/modals/templates/muc-details.js | 6 +- .../muc-views/templates/mep-message.js | 6 +- src/plugins/muc-views/templates/muc-head.js | 10 +- src/plugins/muc-views/tests/mep.js | 12 +- src/plugins/muc-views/tests/muc-avatar.js | 10 +- src/shared/chat/message-body.js | 6 +- src/shared/chat/templates/info-message.js | 4 +- src/shared/chat/templates/unfurl.js | 2 +- src/shared/components/rich-text.js | 87 ---- src/shared/directives/rich-text.js | 43 -- src/shared/directives/styling.js | 27 -- src/shared/texture/README.md | 3 + src/shared/texture/component.js | 70 ++++ src/shared/texture/constants.js | 11 + src/shared/texture/directive.js | 48 +++ .../{rich-text.js => texture/texture.js} | 383 ++++++------------ .../rich-text.scss => texture/texture.scss} | 0 src/shared/texture/utils.js | 165 ++++++++ src/types/shared/chat/message-body.d.ts | 2 +- src/types/shared/directives/rich-text.d.ts | 7 - .../rich-text.d.ts => texture/component.d.ts} | 17 +- src/types/shared/texture/constants.d.ts | 30 ++ src/types/shared/texture/directive.d.ts | 13 + .../{rich-text.d.ts => texture/texture.d.ts} | 63 +-- src/types/shared/texture/utils.d.ts | 37 ++ 25 files changed, 588 insertions(+), 474 deletions(-) delete mode 100644 src/shared/components/rich-text.js delete mode 100644 src/shared/directives/rich-text.js delete mode 100644 src/shared/directives/styling.js create mode 100644 src/shared/texture/README.md create mode 100644 src/shared/texture/component.js create mode 100644 src/shared/texture/constants.js create mode 100644 src/shared/texture/directive.js rename src/shared/{rich-text.js => texture/texture.js} (52%) rename src/shared/{components/styles/rich-text.scss => texture/texture.scss} (100%) create mode 100644 src/shared/texture/utils.js delete mode 100644 src/types/shared/directives/rich-text.d.ts rename src/types/shared/{components/rich-text.d.ts => texture/component.d.ts} (70%) create mode 100644 src/types/shared/texture/constants.d.ts create mode 100644 src/types/shared/texture/directive.d.ts rename src/types/shared/{rich-text.d.ts => texture/texture.d.ts} (77%) create mode 100644 src/types/shared/texture/utils.d.ts diff --git a/src/plugins/muc-views/modals/templates/muc-details.js b/src/plugins/muc-views/modals/templates/muc-details.js index 82784b375f..ff7d6ea8c5 100644 --- a/src/plugins/muc-views/modals/templates/muc-details.js +++ b/src/plugins/muc-views/modals/templates/muc-details.js @@ -11,7 +11,7 @@ const subject = (model) => { const i18n_topic = __('Topic'); const i18n_topic_author = __('Topic author'); return html` -

${i18n_topic}:

+

${i18n_topic}:

${i18n_topic_author}: ${subject && subject.author}

`; } @@ -66,9 +66,9 @@ export default (model) => { height="72" width="72">

${i18n_name}: ${model.get('name')}

-

${i18n_address}:

+

${i18n_address}:


-

${i18n_desc}:

+

${i18n_desc}:

${ (model.get('subject')) ? subject(model) : '' }

${i18n_online_users}: ${num_occupants}

${i18n_features}: diff --git a/src/plugins/muc-views/templates/mep-message.js b/src/plugins/muc-views/templates/mep-message.js index 381a1a2205..471d17005f 100644 --- a/src/plugins/muc-views/templates/mep-message.js +++ b/src/plugins/muc-views/templates/mep-message.js @@ -15,13 +15,13 @@ export default (el) => {

${ el.isRetracted() ? el.renderRetraction() : html` - - + ${ el.model.get('reason') ? - html`` : `` } + html`` : `` } `}
{ @@ -52,7 +52,7 @@ export default (el) => {
${ show_subject ? html`

- +

` : '' } `; } diff --git a/src/plugins/muc-views/tests/mep.js b/src/plugins/muc-views/tests/mep.js index d6fd2864a9..93aa9cfca9 100644 --- a/src/plugins/muc-views/tests/mep.js +++ b/src/plugins/muc-views/tests/mep.js @@ -37,7 +37,7 @@ describe("A XEP-0316 MEP notification", function () { _converse.api.connection.get()._dataRecv(mock.createRequest(message)); 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('.chat-info__message converse-texture').textContent.trim()).toBe(msg); expect(view.querySelector('.reason').textContent.trim()).toBe(reason); // Check that duplicates aren't created @@ -76,7 +76,7 @@ describe("A XEP-0316 MEP notification", function () { _converse.api.connection.get()._dataRecv(mock.createRequest(message)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); - expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-texture').textContent.trim()).toBe(msg); expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason); // Check that duplicates aren't created @@ -129,7 +129,7 @@ describe("A XEP-0316 MEP notification", function () { const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); - expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.chat-info__message converse-texture').textContent.trim()).toBe(msg); expect(view.querySelector('.reason').textContent.trim()).toBe(reason); })); @@ -165,8 +165,8 @@ describe("A XEP-0316 MEP notification", function () { const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); - expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); - expect(view.querySelector('.reason converse-rich-text').innerHTML.replace(//g, '').trim()).toBe( + expect(view.querySelector('.chat-info__message converse-texture').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason converse-texture').innerHTML.replace(//g, '').trim()).toBe( 'Check out https://conversejs.org'); })); @@ -205,7 +205,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('.chat-info__message converse-texture').textContent.trim()).toBe(msg); expect(view.querySelector('.reason').textContent.trim()).toBe(reason); 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'); diff --git a/src/plugins/muc-views/tests/muc-avatar.js b/src/plugins/muc-views/tests/muc-avatar.js index 215639fb78..3c667a8368 100644 --- a/src/plugins/muc-views/tests/muc-avatar.js +++ b/src/plugins/muc-views/tests/muc-avatar.js @@ -119,11 +119,11 @@ describe('Groupchats', () => { expect(els[0].textContent).toBe('Name: A Dark Cave'); expect(els[1].querySelector('strong').textContent).toBe('XMPP address'); - expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe( + expect(els[1].querySelector('converse-texture').textContent.trim()).toBe( 'xmpp:coven@chat.shakespeare.lit?join' ); expect(els[2].querySelector('strong').textContent).toBe('Description'); - expect(els[2].querySelector('converse-rich-text').textContent).toBe('This is the description'); + expect(els[2].querySelector('converse-texture').textContent).toBe('This is the description'); expect(els[3].textContent).toBe('Online users: 1'); const features_list = modal.querySelector('.features-list'); @@ -154,14 +154,14 @@ describe('Groupchats', () => { expect(els[0].textContent).toBe('Name: A Dark Cave'); expect(els[1].querySelector('strong').textContent).toBe('XMPP address'); - expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe( + expect(els[1].querySelector('converse-texture').textContent.trim()).toBe( 'xmpp:coven@chat.shakespeare.lit?join' ); expect(els[2].querySelector('strong').textContent).toBe('Description'); - expect(els[2].querySelector('converse-rich-text').textContent).toBe('This is the description'); + expect(els[2].querySelector('converse-texture').textContent).toBe('This is the description'); expect(els[3].querySelector('strong').textContent).toBe('Topic'); await u.waitUntil( - () => els[3].querySelector('converse-rich-text').textContent === 'Hatching dark plots' + () => els[3].querySelector('converse-texture').textContent === 'Hatching dark plots' ); expect(els[4].textContent).toBe('Topic author: someone'); diff --git a/src/shared/chat/message-body.js b/src/shared/chat/message-body.js index b893555fd9..a598eb4958 100644 --- a/src/shared/chat/message-body.js +++ b/src/shared/chat/message-body.js @@ -1,8 +1,8 @@ +import { api } from "@converse/headless"; import 'shared/registry.js'; import 'shared/modals/image.js'; -import renderRichText from 'shared/directives/rich-text.js'; import { CustomElement } from 'shared/components/element.js'; -import { api } from "@converse/headless"; +import renderTexture from 'shared/texture/directive.js'; import './styles/message-body.scss'; @@ -65,7 +65,7 @@ export default class MessageBody extends CustomElement { options.embed_videos = false; options.show_images = false; } - return renderRichText(this.text, offset, options, callback); + return renderTexture(this.text, offset, options, callback); } } diff --git a/src/shared/chat/templates/info-message.js b/src/shared/chat/templates/info-message.js index a2d8f800fd..106634a127 100644 --- a/src/shared/chat/templates/info-message.js +++ b/src/shared/chat/templates/info-message.js @@ -14,11 +14,11 @@ export default (el) => { data-value="${el.data_value}">
- - +
${ el.model.get('reason') ? html`${el.model.get('reason')}` : `` } ${ el.model.get('error_text') ? html`${el.model.get('error_text')}` : `` } diff --git a/src/shared/chat/templates/unfurl.js b/src/shared/chat/templates/unfurl.js index 66ce8e86e2..b7e2649596 100644 --- a/src/shared/chat/templates/unfurl.js +++ b/src/shared/chat/templates/unfurl.js @@ -36,7 +36,7 @@ export default o => { ${o.title ? tplUrlWrapper(o, o => html`
${o.title}
`) : ''} ${o.description ? html`

- +

` : ''} ${o.url diff --git a/src/shared/components/rich-text.js b/src/shared/components/rich-text.js deleted file mode 100644 index 5be940d024..0000000000 --- a/src/shared/components/rich-text.js +++ /dev/null @@ -1,87 +0,0 @@ -import renderRichText from 'shared/directives/rich-text.js'; -import { CustomElement } from 'shared/components/element.js'; -import { api } from "@converse/headless"; - -import './styles/rich-text.scss'; - -/** - * The RichText custom element allows you to parse transform text into rich DOM elements. - * @example - */ -export default class RichText extends CustomElement { - - static get properties () { - /** - * @typedef { Object } RichTextComponentProperties - * @property { Boolean } embed_audio - * Whether URLs that point to audio files should render as audio players. - * @property { Boolean } embed_videos - * Whether URLs that point to video files should render as video players. - * @property { Array } mentions - An array of objects representing chat mentions - * @property { String } nick - The current user's nickname, relevant for mentions - * @property { Number } offset - The text offset, in case this is a nested RichText element. - * @property { Function } onImgClick - * @property { Function } onImgLoad - * @property { Boolean } render_styling - * Whether XEP-0393 message styling hints should be rendered - * @property { Boolean } show_images - * Whether URLs that point to image files should render as images - * @property { Boolean } hide_media_urls - * If media URLs are rendered as media, then this option determines - * whether the original URL is also still shown or not. - * Only relevant in conjunction with `show_images`, `embed_audio` and `embed_videos`. - * @property { Boolean } show_me_message - * Whether text that starts with /me should be rendered in the 3rd person. - * @property { String } text - The text that will get transformed. - */ - return { - embed_audio: { type: Boolean }, - embed_videos: { type: Boolean }, - mentions: { type: Array }, - nick: { type: String }, - offset: { type: Number }, - onImgClick: { type: Function }, - onImgLoad: { type: Function }, - render_styling: { type: Boolean }, - show_images: { type: Boolean }, - hide_media_urls: { type: Boolean }, - show_me_message: { type: Boolean }, - text: { type: String }, - } - } - - constructor () { - super(); - this.nick = null; - this.onImgClick = null; - this.onImgLoad = null; - this.text = null; - this.embed_audio = false; - this.embed_videos = false; - this.hide_media_urls = false; - this.mentions = []; - this.offset = 0; - this.render_styling = false; - this.show_image_urls = true; - this.show_images = false; - this.show_me_message = false; - } - - render () { - const options = { - embed_audio: this.embed_audio, - embed_videos: this.embed_videos, - hide_media_urls: this.hide_media_urls, - mentions: this.mentions, - nick: this.nick, - onImgClick: this.onImgClick, - onImgLoad: this.onImgLoad, - render_styling: this.render_styling, - show_images: this.show_images, - show_me_message: this.show_me_message, - } - return renderRichText(this.text, this.offset, options); - } -} - -api.elements.define('converse-rich-text', RichText); diff --git a/src/shared/directives/rich-text.js b/src/shared/directives/rich-text.js deleted file mode 100644 index effcfa6c41..0000000000 --- a/src/shared/directives/rich-text.js +++ /dev/null @@ -1,43 +0,0 @@ -import { log } from '@converse/headless'; -import { Directive, directive } from 'lit/directive.js'; -import { RichText } from 'shared/rich-text.js'; -import { html } from "lit"; -import { until } from 'lit/directives/until.js'; - - -class RichTextRenderer { - - constructor (text, offset, options={}) { - this.offset = offset; - this.options = options; - this.text = text; - } - - async transform () { - const text = new RichText(this.text, this.offset, this.options); - try { - await text.addTemplates(); - } catch (e) { - log.error(e); - } - return text.payload; - } - - render () { - return html`${until(this.transform(), html`${this.text}`)}`; - } -} - - -class RichTextDirective extends Directive { - render (text, offset, options, callback) { // eslint-disable-line class-methods-use-this - const renderer = new RichTextRenderer(text, offset, options); - const result = renderer.render(); - callback?.(); - return result; - } -} - - -const renderRichText = directive(RichTextDirective); -export default renderRichText; diff --git a/src/shared/directives/styling.js b/src/shared/directives/styling.js deleted file mode 100644 index 65a0860d03..0000000000 --- a/src/shared/directives/styling.js +++ /dev/null @@ -1,27 +0,0 @@ -import { log } from '@converse/headless'; -import { Directive, directive } from 'lit/directive.js'; -import { RichText } from '../rich-text.js'; -import { html } from 'lit'; -import { until } from 'lit/directives/until.js'; - -async function transform (t) { - try { - await t.addTemplates(); - } catch (e) { - log.error(e); - } - return t.payload; -} - -class StylingDirective extends Directive { - render (txt, offset, options) { - const t = new RichText( - txt, - offset, - Object.assign(options, { 'show_images': false, 'embed_videos': false, 'embed_audio': false }) - ); - return html`${until(transform(t), html`${t}`)}`; - } -} - -export const renderStylingDirectiveBody = directive(StylingDirective); diff --git a/src/shared/texture/README.md b/src/shared/texture/README.md new file mode 100644 index 0000000000..12f374762c --- /dev/null +++ b/src/shared/texture/README.md @@ -0,0 +1,3 @@ +# Texture + +Converse's library for showing rich, multi-media messages based on text diff --git a/src/shared/texture/component.js b/src/shared/texture/component.js new file mode 100644 index 0000000000..836a763770 --- /dev/null +++ b/src/shared/texture/component.js @@ -0,0 +1,70 @@ +import { LitElement } from 'lit'; +import renderTexture from './directive.js'; + +import './texture.scss'; + +/** + * The Texture custom element allows you to parse transform text into rich DOM elements. + * @example + */ +export default class Texture extends LitElement { + static get properties() { + return { + embed_audio: { type: Boolean }, // Whether URLs to audio files should render as audio players. + embed_videos: { type: Boolean }, // Whether URLs to video files should render as video players. + mentions: { type: Array }, // An array of objects representing chat mentions + nick: { type: String }, // The current user's nickname, relevant for mentions + offset: { type: Number }, // The text offset, in case this is a nested Texture element. + onImgClick: { type: Function }, + onImgLoad: { type: Function }, + render_styling: { type: Boolean }, // Whether XEP-0393 message styling hints should be rendered + show_images: { type: Boolean }, // Whether URLs to image files should render as images + // If media URLs are rendered as media, then this option determines + // whether the original URL is also still shown or not. + // Only relevant in conjunction with `show_images`, `embed_audio` and `embed_videos`. + hide_media_urls: { type: Boolean }, + show_me_message: { type: Boolean }, // Whether text that starts with /me is rendered in the 3rd person. + text: { type: String }, // The text that will get transformed. + }; + } + + createRenderRoot () { + // Render without the shadow DOM + return this; + } + + constructor() { + super(); + this.nick = null; + this.onImgClick = null; + this.onImgLoad = null; + this.text = null; + this.embed_audio = false; + this.embed_videos = false; + this.hide_media_urls = false; + this.mentions = []; + this.offset = 0; + this.render_styling = false; + this.show_image_urls = true; + this.show_images = false; + this.show_me_message = false; + } + + render() { + const options = { + embed_audio: this.embed_audio, + embed_videos: this.embed_videos, + hide_media_urls: this.hide_media_urls, + mentions: this.mentions, + nick: this.nick, + onImgClick: this.onImgClick, + onImgLoad: this.onImgLoad, + render_styling: this.render_styling, + show_images: this.show_images, + show_me_message: this.show_me_message, + }; + return renderTexture(this.text, this.offset, options); + } +} + +customElements.define('converse-texture', Texture); diff --git a/src/shared/texture/constants.js b/src/shared/texture/constants.js new file mode 100644 index 0000000000..0d2dc835f6 --- /dev/null +++ b/src/shared/texture/constants.js @@ -0,0 +1,11 @@ +export const bracketing_directives = ['*', '_', '~', '`']; +export const styling_directives = [...bracketing_directives, '```', '>']; +export const styling_map = { + '*': { 'name': 'strong', 'type': 'span' }, + '_': { 'name': 'emphasis', 'type': 'span' }, + '~': { 'name': 'strike', 'type': 'span' }, + '`': { 'name': 'preformatted', 'type': 'span' }, + '```': { 'name': 'preformatted_block', 'type': 'block' }, + '>': { 'name': 'quote', 'type': 'block' }, +}; +export const dont_escape = ['_', '>', '`', '~']; diff --git a/src/shared/texture/directive.js b/src/shared/texture/directive.js new file mode 100644 index 0000000000..e4cf532fbb --- /dev/null +++ b/src/shared/texture/directive.js @@ -0,0 +1,48 @@ +import { html } from 'lit'; +import { until } from 'lit/directives/until.js'; +import { Directive, directive } from 'lit/directive.js'; +import { Texture } from './texture.js'; + +class TextureRenderer { + /** + * @param {string} text + * @param {number} offset + */ + constructor(text, offset, options = {}) { + this.offset = offset; + this.options = options; + this.text = text; + } + + async transform() { + const text = new Texture(this.text, this.offset, this.options); + try { + await text.addTemplates(); + } catch (e) { + console.error(e); + } + return text.payload; + } + + render() { + return html`${until(this.transform(), html`${this.text}`)}`; + } +} + +class TextureDirective extends Directive { + /** + * @param {string} text + * @param {number} offset + * @param {object} options + * @param {Function} [callback] + */ + render(text, offset, options, callback) { + const renderer = new TextureRenderer(text, offset, options); + const result = renderer.render(); + callback?.(); + return result; + } +} + +const renderTexture = directive(TextureDirective); +export default renderTexture; diff --git a/src/shared/rich-text.js b/src/shared/texture/texture.js similarity index 52% rename from src/shared/rich-text.js rename to src/shared/texture/texture.js index da506d3306..17c94124be 100644 --- a/src/shared/rich-text.js +++ b/src/shared/texture/texture.js @@ -1,18 +1,24 @@ -/** - * @typedef {module:headless-shared-parsers.MediaURLMetadata} MediaURLMetadata - * @typedef {module:headless-shared-parsers.MediaURLMetadata} MediaURLData - */ import { html } from 'lit'; import { until } from 'lit/directives/until.js'; import { Directive, directive } from 'lit/directive.js'; -import { api, log, u } from '@converse/headless'; +import { api, u } from '@converse/headless'; import tplAudio from 'templates/audio.js'; import tplGif from 'templates/gif.js'; import tplImage from 'templates/image.js'; import tplVideo from 'templates/video.js'; -import { getEmojiMarkup } from './chat/utils.js'; -import { getHyperlinkTemplate } from '../utils/html.js'; +import { getEmojiMarkup } from '../chat/utils.js'; +import { getHyperlinkTemplate } from '../../utils/html.js'; import { shouldRenderMediaFromURL } from 'utils/url.js'; +import { + collapseLineBreaks, + containsDirectives, + getDirectiveAndLength, + isQuoteDirective, + isString, + tplMention, + tplMentionWithNick, +} from './utils.js'; +import { styling_map } from './constants.js'; const { convertASCII2Emoji, @@ -27,14 +33,13 @@ const { isVideoURL, } = u; - /** - * @class RichText + * @class Texture * A String subclass that is used to render rich text (i.e. text that contains * hyperlinks, images, mentions, styling etc.). * * The "rich" parts of the text is represented by lit TemplateResult - * objects which are added via the {@link RichText.addTemplateResult} + * objects which are added via the {@link Texture.addTemplateResult} * method and saved as metadata. * * By default Converse adds TemplateResults to support emojis, hyperlinks, @@ -42,17 +47,17 @@ const { * * 3rd party plugins can listen for the `beforeMessageBodyTransformed` * and/or `afterMessageBodyTransformed` events and then call - * `addTemplateResult` on the RichText instance in order to add their own + * `addTemplateResult` on the Texture instance in order to add their own * rich features. */ -export class RichText extends String { +export class Texture extends String { /** - * Create a new {@link RichText} instance. + * Create a new {@link Texture} instance. * @param {string} text - The text to be annotated * @param {number} offset - The offset of this particular piece of text * from the start of the original message text. This is necessary because - * RichText instances can be nested when templates call directives - * which create new RichText instances (as happens with XEP-393 styling directives). + * Texture instances can be nested when templates call directives + * which create new Texture instances (as happens with XEP-393 styling directives). * @param {Object} [options] * @param {string} [options.nick] - The current user's nickname (only relevant if the message is in a XEP-0045 MUC) * @param {boolean} [options.render_styling] - Whether XEP-0393 message styling should be applied to the message @@ -76,8 +81,10 @@ export class RichText extends String { * @param {Function} [options.onImgClick] - Callback for when an inline rendered image has been clicked * @param {Function} [options.onImgLoad] - Callback for when an inline rendered image has been loaded * @param {boolean} [options.hide_media_urls] - Callback for when an inline rendered image has been loaded + * + * @typedef {module:headless-shared-parsers.MediaURLMetadata} MediaURLMetadata */ - constructor (text, offset = 0, options = {}) { + constructor(text, offset = 0, options = {}) { super(text); this.embed_audio = options?.embed_audio; this.embed_videos = options?.embed_videos; @@ -95,7 +102,11 @@ export class RichText extends String { this.hide_media_urls = options?.hide_media_urls; } - shouldRenderMedia (url_text, type) { + /** + * @param {string} url - The URL to be checked + * @param {'audio'|'image'|'video'} type - The type of media + */ + shouldRenderMedia(url, type) { let override; if (type === 'image') { override = this.show_images; @@ -107,53 +118,57 @@ export class RichText extends String { if (typeof override === 'boolean') { return override; } - return shouldRenderMediaFromURL(url_text, type); + return shouldRenderMediaFromURL(url, type); } /** * Look for `http` URIs and return templates that render them as URL links * @param {string} text * @param {number} local_offset - The index of the passed in text relative to - * the start of this RichText instance (which is not necessarily the same as the + * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). + * + * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData */ - addHyperlinks (text, local_offset) { + addHyperlinks(text, local_offset) { const full_offset = local_offset + this.offset; const urls_meta = this.media_urls || getMediaURLsMetadata(text, local_offset).media_urls || []; - const media_urls = /** @type {MediaURLData[]} */(getMediaURLs(urls_meta, text, full_offset)); - - media_urls.filter(o => !o.is_encrypted).forEach(url_obj => { - const url_text = url_obj.url; - const filtered_url = filterQueryParamsFromURL(url_text); - let template; - if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { - template = tplGif(filtered_url, this.hide_media_urls); - } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { - template = tplImage({ - 'src': filtered_url, - // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here - 'href': this.hide_media_urls ? null : filtered_url, - 'onClick': this.onImgClick, - 'onLoad': this.onImgLoad - }); - } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) { - template = tplVideo(filtered_url, this.hide_media_urls); - } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) { - template = tplAudio(filtered_url, this.hide_media_urls); - } else { - template = getHyperlinkTemplate(filtered_url); - } - this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template); - }); + const media_urls = /** @type {MediaURLData[]} */ (getMediaURLs(urls_meta, text, full_offset)); + + media_urls + .filter((o) => !o.is_encrypted) + .forEach((url_obj) => { + const url_text = url_obj.url; + const filtered_url = filterQueryParamsFromURL(url_text); + let template; + if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { + template = tplGif(filtered_url, this.hide_media_urls); + } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { + template = tplImage({ + 'src': filtered_url, + // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here + 'href': this.hide_media_urls ? null : filtered_url, + 'onClick': this.onImgClick, + 'onLoad': this.onImgLoad, + }); + } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) { + template = tplVideo(filtered_url, this.hide_media_urls); + } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) { + template = tplAudio(filtered_url, this.hide_media_urls); + } else { + template = getHyperlinkTemplate(filtered_url); + } + this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template); + }); } /** * Look for `geo` URIs and return templates that render them as URL links - * @param { String } text - * @param { number } offset - The index of the passed in text relative to + * @param {String} text + * @param {number} offset - The index of the passed in text relative to * the start of the message body text. */ - addMapURLs (text, offset) { + addMapURLs(text, offset) { const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g; const matches = text.matchAll(regex); for (const m of matches) { @@ -171,28 +186,24 @@ export class RichText extends String { * @param {number} offset - The index of the passed in text relative to * the start of the message body text. */ - addEmojis (text, offset) { + addEmojis(text, offset) { const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())]; - references.forEach(e => { - this.addTemplateResult( - e.begin + offset, - e.end + offset, - getEmojiMarkup(e, { add_title_wrapper: true }) - ); + references.forEach((e) => { + this.addTemplateResult(e.begin + offset, e.end + offset, getEmojiMarkup(e, { add_title_wrapper: true })); }); } /** * Look for mentions included as XEP-0372 references and add templates for * rendering them. - * @param { String } text - * @param { number } local_offset - The index of the passed in text relative to - * the start of this RichText instance (which is not necessarily the same as the + * @param {String} text + * @param {number} local_offset - The index of the passed in text relative to + * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). */ - addMentions (text, local_offset) { + addMentions(text, local_offset) { const full_offset = local_offset + this.offset; - this.mentions?.forEach(ref => { + this.mentions?.forEach((ref) => { const begin = Number(ref.begin) - full_offset; if (begin < 0 || begin >= full_offset + text.length) { return; @@ -203,10 +214,10 @@ export class RichText extends String { this.addTemplateResult( begin + local_offset, end + local_offset, - tplMentionWithNick({...ref, mention }) + tplMentionWithNick({ ...ref, mention }) ); } else { - this.addTemplateResult(begin + local_offset, end + local_offset, tplMention({...ref, mention })); + this.addTemplateResult(begin + local_offset, end + local_offset, tplMention({ ...ref, mention })); } }); } @@ -214,18 +225,19 @@ export class RichText extends String { /** * Look for XEP-0393 styling directives and add templates for rendering them. */ - addStyling () { + addStyling() { if (!containsDirectives(this)) { return; } const references = []; - const mention_ranges = this.mentions.map(m => + const mention_ranges = this.mentions.map((m) => Array.from({ 'length': Number(m.end) }, (_, i) => Number(m.begin) + i) ); let i = 0; while (i < this.length) { - if (mention_ranges.filter(r => r.includes(i)).length) { // eslint-disable-line no-loop-func + if (mention_ranges.filter((r) => r.includes(i)).length) { + // eslint-disable-line no-loop-func // Don't treat potential directives if they fall within a // declared XEP-0372 reference i++; @@ -244,18 +256,18 @@ export class RichText extends String { const offset = slice_begin; const text = this.slice(slice_begin, slice_end); references.push({ - 'begin': i, - 'template': getDirectiveTemplate(d, text, offset, this.options), - end + begin: i, + template: getDirectiveTemplate(d, text, offset, this.options), + end, }); i = end; } i++; } - references.forEach(ref => this.addTemplateResult(ref.begin, ref.end, ref.template)); + references.forEach((ref) => this.addTemplateResult(ref.begin, ref.end, ref.template)); } - trimMeMessage () { + trimMeMessage() { if (this.offset === 0) { // Subtract `/me ` from 3rd person messages if (this.isMeCommand()) { @@ -265,11 +277,11 @@ export class RichText extends String { } /** - * Look for plaintext (i.e. non-templated) sections of this RichText + * Look for plaintext (i.e. non-templated) sections of this Texture * instance and add references via the passed in function. * @param { Function } func */ - addAnnotations (func) { + addAnnotations(func) { const payload = this.marshall(); let idx = 0; // The text index of the element in the payload for (const text of payload) { @@ -287,13 +299,13 @@ export class RichText extends String { /** * Parse the text and add template references for rendering the "rich" parts. **/ - async addTemplates () { + async addTemplates() { /** * Synchronous event which provides a hook for transforming a chat message's body text * before the default transformations have been applied. * @event _converse#beforeMessageBodyTransformed - * @param { RichText } text - A {@link RichText } instance. You - * can call {@link RichText#addTemplateResult } on it in order to + * @param { Texture } text - A {@link Texture } instance. You + * can call {@link Texture#addTemplateResult } on it in order to * add TemplateResult objects meant to render rich parts of the message. * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... }); */ @@ -311,8 +323,8 @@ export class RichText extends String { * Synchronous event which provides a hook for transforming a chat message's body text * after the default transformations have been applied. * @event _converse#afterMessageBodyTransformed - * @param { RichText } text - A {@link RichText } instance. You - * can call {@link RichText#addTemplateResult} on it in order to + * @param { Texture } text - A {@link Texture } instance. You + * can call {@link Texture#addTemplateResult} on it in order to * add TemplateResult objects meant to render rich parts of the message. * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... }); */ @@ -320,7 +332,7 @@ export class RichText extends String { this.payload = this.marshall(); this.options.show_me_message && this.trimMeMessage(); - this.payload = this.payload.map(item => (isString(item) ? item : item.template)); + this.payload = this.payload.map((item) => (isString(item) ? item : item.template)); } /** @@ -330,18 +342,18 @@ export class RichText extends String { * This method can be used to add new template results to this message's * text. * - * @method RichText.addTemplateResult - * @param { Number } begin - The starting index of the plain message text + * @method Texture.addTemplateResult + * @param {Number} begin - The starting index of the plain message text * which is being replaced with markup. - * @param { Number } end - The ending index of the plain message text + * @param {Number} end - The ending index of the plain message text * which is being replaced with markup. - * @param { Object } template - The lit TemplateResult instance + * @param {Object} template - The lit TemplateResult instance */ - addTemplateResult (begin, end, template) { + addTemplateResult(begin, end, template) { this.references.push({ begin, end, template }); } - isMeCommand () { + isMeCommand() { const text = this.toString(); if (!text) { return false; @@ -352,13 +364,13 @@ export class RichText extends String { /** * Take the annotations and return an array of text and TemplateResult * instances to be rendered to the DOM. - * @method RichText#marshall + * @method Texture#marshall */ - marshall () { + marshall() { let list = [this.toString()]; this.references .sort((a, b) => b.begin - a.begin) - .forEach(ref => { + .forEach((ref) => { const text = list.shift(); list = [text.slice(0, ref.begin), ref, text.slice(ref.end), ...list]; }); @@ -369,194 +381,69 @@ export class RichText extends String { } } -const isString = (s) => typeof s === 'string'; - -// We don't render more than two line-breaks, replace extra line-breaks with -// the zero-width whitespace character -// This takes into account other characters that may have been removed by -// being replaced with a zero-width space, such as '> ' in the case of -// multi-line quotes. -const collapseLineBreaks = (text) => text.replace(/\n(\u200B*\n)+/g, m => `\n${'\u200B'.repeat(m.length - 2)}\n`); - -const tplMentionWithNick = (o) => - html`${o.mention}`; - -const tplMention = (o) => html`${o.mention}`; - -async function transform (t) { - try { - await t.addTemplates(); - } catch (e) { - log.error(e); +// Kept here to avoid circular dependencies +class StylingDirective extends Directive { + /** + * @param {Texture} t + */ + static async transform(t) { + try { + await t.addTemplates(); + } catch (e) { + console.error(e); + } + return t.payload; } - return t.payload; -} -class StylingDirective extends Directive { - render (txt, offset, options) { - const t = new RichText( + /** + * @param {string} txt + * @param {number} offset + * @param {object} options + */ + render(txt, offset, options) { + const t = new Texture( txt, offset, Object.assign(options, { 'show_images': false, 'embed_videos': false, 'embed_audio': false }) ); - return html`${until(transform(t), html`${t}`)}`; + return html`${until(StylingDirective.transform(t), html`${t}`)}`; } } -const renderStylingDirectiveBody = directive(StylingDirective); -const bracketing_directives = ['*', '_', '~', '`']; -const styling_directives = [...bracketing_directives, '```', '>']; -const styling_map = { - '*': {'name': 'strong', 'type': 'span'}, - '_': {'name': 'emphasis', 'type': 'span'}, - '~': {'name': 'strike', 'type': 'span'}, - '`': {'name': 'preformatted', 'type': 'span'}, - '```': {'name': 'preformatted_block', 'type': 'block'}, - '>': {'name': 'quote', 'type': 'block'} -}; - -const dont_escape = ['_', '>', '`', '~']; +const renderStyling = directive(StylingDirective); // prettier-ignore /* eslint-disable max-len */ const styling_templates = { // m is the chatbox model // i is the offset of this directive relative to the start of the original message - 'emphasis': (txt, i, options) => html`_${renderStylingDirectiveBody(txt, i, options)}_`, - 'preformatted': txt => html`\`${txt}\``, - 'preformatted_block': txt => html`
\`\`\`
${txt}
\`\`\`
`, - 'quote': (txt, i, options) => html`
${renderStylingDirectiveBody(txt, i, options)}
`, - 'strike': (txt, i, options) => html`~${renderStylingDirectiveBody(txt, i, options)}~`, - 'strong': (txt, i, options) => html`*${renderStylingDirectiveBody(txt, i, options)}*`, + emphasis: (txt, i, options) => html`_${renderStyling(txt, i, options)}_`, + preformatted: (txt) => html`\`${txt}\``, + preformatted_block: (txt) => html`
\`\`\`
${txt}
\`\`\`
`, + quote: (txt, i, options) => html`
${renderStyling(txt, i, options)}
`, + strike: (txt, i, options) => html`~${renderStyling(txt, i, options)}~`, + strong: (txt, i, options) => html`*${renderStyling(txt, i, options)}*`, }; /** - * Checks whether a given character "d" at index "i" of "text" is a valid opening or closing directive. - * @param { String } d - The potential directive - * @param { String } text - The text in which the directive appears - * @param { Number } i - The directive index - * @param { Boolean } opening - Check for a valid opening or closing directive + * @param {string} d + * @param {string} text + * @param {number} offset + * @param {object} options */ -function isValidDirective (d, text, i, opening) { - // Ignore directives that are parts of words - // More info on the Regexes used here: https://javascript.info/regexp-unicode#unicode-properties-p - if (opening) { - const regex = RegExp(dont_escape.includes(d) ? `^(\\p{L}|\\p{N})${d}` : `^(\\p{L}|\\p{N})\\${d}`, 'u'); - if (i > 1 && regex.test(text.slice(i-1))) { - return false; - } - const is_quote = isQuoteDirective(d); - if (is_quote && i > 0 && text[i-1] !== '\n') { - // Quote directives must be on newlines - return false; - } else if (bracketing_directives.includes(d) && (text[i+1] === d)) { - // Don't consider empty bracketing directives as valid (e.g. **, `` etc.) - return false; - } - } else { - const regex = RegExp(dont_escape.includes(d) ? `^${d}(\\p{L}|\\p{N})` : `^\\${d}(\\p{L}|\\p{N})`, 'u'); - if (i < text.length-1 && regex.test(text.slice(i))) { - return false; - } - if (bracketing_directives.includes(d) && (text[i-1] === d)) { - // Don't consider empty directives as valid (e.g. **, `` etc.) - return false; - } - } - return true; -} - -/** - * Given a specific index "i" of "text", return the directive it matches or null otherwise. - * @param { String } text - The text in which the directive appears - * @param { Number } i - The directive index - * @param { Boolean } opening - Whether we're looking for an opening or closing directive - */ -function getDirective (text, i, opening=true) { - let d; - - if ( - (/(^```[\s,\u200B]*\n)|(^```[\s,\u200B]*$)/).test(text.slice(i)) && - (i === 0 || text[i-1] === '>' || (/\n\u200B{0,2}$/).test(text.slice(0, i))) - ) { - d = text.slice(i, i+3); - } else if (styling_directives.includes(text.slice(i, i+1))) { - d = text.slice(i, i+1); - if (!isValidDirective(d, text, i, opening)) return null; - } else { - return null; - } - return d; -} - -/** - * Given a directive "d", which occurs in "text" at index "i", check that it - * has a valid closing directive and return the length from start to end of the - * directive. - * @param { String } d -The directive - * @param { Number } i - The directive index - * @param { String } text -The text in which the directive appears - */ -function getDirectiveLength (d, text, i) { - if (!d) return 0; - - const begin = i; - i += d.length; - if (isQuoteDirective(d)) { - i += text.slice(i).split(/\n\u200B*[^>\u200B]/).shift().length; - return i-begin; - } else if (styling_map[d].type === 'span') { - const line = text.slice(i).split('\n').shift(); - let j = 0; - let idx = line.indexOf(d); - while (idx !== -1) { - if (getDirective(text, i+idx, false) === d) { - return idx+2*d.length; - } - idx = line.indexOf(d, j++); - } - return 0; - } else { - // block directives - const substring = text.slice(i+1); - let j = 0; - let idx = substring.indexOf(d); - while (idx !== -1) { - if (getDirective(text, i+1+idx, false) === d) { - return idx+1+2*d.length; - } - idx = substring.indexOf(d, j++); - } - return 0; - } -} - -function getDirectiveAndLength (text, i) { - const d = getDirective(text, i); - const length = d ? getDirectiveLength(d, text, i) : 0; - return length > 0 ? { d, length } : {}; -} - -const isQuoteDirective = (d) => ['>', '>'].includes(d); - -function getDirectiveTemplate (d, text, offset, options) { +export function getDirectiveTemplate(d, text, offset, options) { const template = styling_templates[styling_map[d].name]; if (isQuoteDirective(d)) { const newtext = text // Don't show the directive itself // This big [] corresponds to \s without newlines, to avoid issues when the > is the last character of the line - .replace(/\n\u200B*>[ \f\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?/g, m => `\n${'\u200B'.repeat(m.length - 1)}`) + .replace( + /\n\u200B*>[ \f\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]?/g, + (m) => `\n${'\u200B'.repeat(m.length - 1)}` + ) .replace(/\n$/, ''); // Trim line-break at the end return template(newtext, offset, options); } else { return template(text, offset, options); } } - -function containsDirectives (text) { - for (let i=0; i ' in the case of + * multi-line quotes. + * @param {string} text + */ +export function collapseLineBreaks(text) { + return text.replace(/\n(\u200B*\n)+/g, (m) => `\n${'\u200B'.repeat(m.length - 2)}\n`); +} + +export const tplMentionWithNick = (o) => + html`${o.mention}`; + +export function tplMention(o) { + return html`${o.mention}`; +} + +/** + * Checks whether a given character "d" at index "i" of "text" is a valid opening or closing directive. + * @param {String} d - The potential directive + * @param {import('./texture').Texture} text - The text in which the directive appears + * @param {Number} i - The directive index + * @param {Boolean} opening - Check for a valid opening or closing directive + * @returns {boolean} + */ +function isValidDirective(d, text, i, opening) { + // Ignore directives that are parts of words + // More info on the Regexes used here: https://javascript.info/regexp-unicode#unicode-properties-p + if (opening) { + const regex = RegExp(dont_escape.includes(d) ? `^(\\p{L}|\\p{N})${d}` : `^(\\p{L}|\\p{N})\\${d}`, 'u'); + if (i > 1 && regex.test(text.slice(i - 1))) { + return false; + } + const is_quote = isQuoteDirective(d); + if (is_quote && i > 0 && text[i - 1] !== '\n') { + // Quote directives must be on newlines + return false; + } else if (bracketing_directives.includes(d) && text[i + 1] === d) { + // Don't consider empty bracketing directives as valid (e.g. **, `` etc.) + return false; + } + } else { + const regex = RegExp(dont_escape.includes(d) ? `^${d}(\\p{L}|\\p{N})` : `^\\${d}(\\p{L}|\\p{N})`, 'u'); + if (i < text.length - 1 && regex.test(text.slice(i))) { + return false; + } + if (bracketing_directives.includes(d) && text[i - 1] === d) { + // Don't consider empty directives as valid (e.g. **, `` etc.) + return false; + } + } + return true; +} + +/** + * Given a specific index "i" of "text", return the directive it matches or null otherwise. + * @param {import('./texture').Texture} text - The text in which the directive appears + * @param {Number} i - The directive index + * @param {Boolean} opening - Whether we're looking for an opening or closing directive + * @returns {string|null} + */ +function getDirective(text, i, opening = true) { + let d; + + if ( + /(^```[\s,\u200B]*\n)|(^```[\s,\u200B]*$)/.test(text.slice(i)) && + (i === 0 || text[i - 1] === '>' || /\n\u200B{0,2}$/.test(text.slice(0, i))) + ) { + d = text.slice(i, i + 3); + } else if (styling_directives.includes(text.slice(i, i + 1))) { + d = text.slice(i, i + 1); + if (!isValidDirective(d, text, i, opening)) return null; + } else { + return null; + } + return d; +} + +/** + * @param {import('./texture').Texture} text + * @param {number} i + */ +export function getDirectiveAndLength(text, i) { + const d = getDirective(text, i); + const length = d ? getDirectiveLength(d, text, i) : 0; + return length > 0 ? { d, length } : {}; +} + +/** + * Given a directive "d", which occurs in "text" at index "i", check that it + * has a valid closing directive and return the length from start to end of the + * directive. + * @param {String} d -The directive + * @param {Number} i - The directive index + * @param {import('./texture').Texture} text -The text in which the directive appears + */ +function getDirectiveLength(d, text, i) { + if (!d) return 0; + + const begin = i; + i += d.length; + if (isQuoteDirective(d)) { + i += text + .slice(i) + .split(/\n\u200B*[^>\u200B]/) + .shift().length; + return i - begin; + } else if (styling_map[d].type === 'span') { + const line = text.slice(i).split('\n').shift(); + let j = 0; + let idx = line.indexOf(d); + while (idx !== -1) { + if (getDirective(text, i + idx, false) === d) { + return idx + 2 * d.length; + } + idx = line.indexOf(d, j++); + } + return 0; + } else { + // block directives + const substring = text.slice(i + 1); + let j = 0; + let idx = substring.indexOf(d); + while (idx !== -1) { + if (getDirective(text, i + 1 + idx, false) === d) { + return idx + 1 + 2 * d.length; + } + idx = substring.indexOf(d, j++); + } + return 0; + } +} + +/** + * @param {string} d + */ +export function isQuoteDirective(d) { + return ['>', '>'].includes(d); +} + +/** + * @param {import('./texture').Texture} text + * @returns {boolean} + */ +export function containsDirectives(text) { + for (let i = 0; i < styling_directives.length; i++) { + if (text.includes(styling_directives[i])) { + return true; + } + } + return false; +} diff --git a/src/types/shared/chat/message-body.d.ts b/src/types/shared/chat/message-body.d.ts index 298662a290..426ab26753 100644 --- a/src/types/shared/chat/message-body.d.ts +++ b/src/types/shared/chat/message-body.d.ts @@ -21,7 +21,7 @@ export default class MessageBody extends CustomElement { onImgLoad(): void; render(): import("lit/directive").DirectiveResult<{ new (_partInfo: import("lit/directive").PartInfo): { - render(text: any, offset: any, options: any, callback: any): import("lit").TemplateResult<1>; + render(text: string, offset: number, options: object, callback?: Function): import("lit").TemplateResult<1>; readonly _$isConnected: boolean; update(_part: import("lit").Part, props: Array): unknown; }; diff --git a/src/types/shared/directives/rich-text.d.ts b/src/types/shared/directives/rich-text.d.ts deleted file mode 100644 index 9d8c13a310..0000000000 --- a/src/types/shared/directives/rich-text.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default renderRichText; -declare const renderRichText: (text?: any, offset?: any, options?: any, callback?: any) => import("lit/directive.js").DirectiveResult; -declare class RichTextDirective extends Directive { - render(text: any, offset: any, options: any, callback: any): import("lit").TemplateResult<1>; -} -import { Directive } from 'lit/directive.js'; -//# sourceMappingURL=rich-text.d.ts.map \ No newline at end of file diff --git a/src/types/shared/components/rich-text.d.ts b/src/types/shared/texture/component.d.ts similarity index 70% rename from src/types/shared/components/rich-text.d.ts rename to src/types/shared/texture/component.d.ts index 8e85fb52fe..e4f81f0c10 100644 --- a/src/types/shared/components/rich-text.d.ts +++ b/src/types/shared/texture/component.d.ts @@ -1,8 +1,8 @@ /** - * The RichText custom element allows you to parse transform text into rich DOM elements. - * @example + * The Texture custom element allows you to parse transform text into rich DOM elements. + * @example */ -export default class RichText extends CustomElement { +export default class Texture extends LitElement { static get properties(): { embed_audio: { type: BooleanConstructor; @@ -41,6 +41,7 @@ export default class RichText extends CustomElement { type: StringConstructor; }; }; + createRenderRoot(): this; nick: any; onImgClick: any; onImgLoad: any; @@ -54,13 +55,13 @@ export default class RichText extends CustomElement { show_image_urls: boolean; show_images: boolean; show_me_message: boolean; - render(): import("lit/directive").DirectiveResult<{ - new (_partInfo: import("lit/directive").PartInfo): { - render(text: any, offset: any, options: any, callback: any): import("lit").TemplateResult<1>; + render(): import("lit/directive.js").DirectiveResult<{ + new (_partInfo: import("lit/directive.js").PartInfo): { + render(text: string, offset: number, options: object, callback?: Function): import("lit").TemplateResult<1>; readonly _$isConnected: boolean; update(_part: import("lit").Part, props: Array): unknown; }; }>; } -import { CustomElement } from 'shared/components/element.js'; -//# sourceMappingURL=rich-text.d.ts.map \ No newline at end of file +import { LitElement } from 'lit'; +//# sourceMappingURL=component.d.ts.map \ No newline at end of file diff --git a/src/types/shared/texture/constants.d.ts b/src/types/shared/texture/constants.d.ts new file mode 100644 index 0000000000..00a0befea6 --- /dev/null +++ b/src/types/shared/texture/constants.d.ts @@ -0,0 +1,30 @@ +export const bracketing_directives: string[]; +export const styling_directives: string[]; +export const styling_map: { + '*': { + name: string; + type: string; + }; + _: { + name: string; + type: string; + }; + '~': { + name: string; + type: string; + }; + '`': { + name: string; + type: string; + }; + '```': { + name: string; + type: string; + }; + '>': { + name: string; + type: string; + }; +}; +export const dont_escape: string[]; +//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/src/types/shared/texture/directive.d.ts b/src/types/shared/texture/directive.d.ts new file mode 100644 index 0000000000..e2bcc54a34 --- /dev/null +++ b/src/types/shared/texture/directive.d.ts @@ -0,0 +1,13 @@ +export default renderTexture; +declare const renderTexture: (text: string, offset: number, options: any, callback?: Function) => import("lit/directive.js").DirectiveResult; +declare class TextureDirective extends Directive { + /** + * @param {string} text + * @param {number} offset + * @param {object} options + * @param {Function} [callback] + */ + render(text: string, offset: number, options: object, callback?: Function): import("lit").TemplateResult<1>; +} +import { Directive } from 'lit/directive.js'; +//# sourceMappingURL=directive.d.ts.map \ No newline at end of file diff --git a/src/types/shared/rich-text.d.ts b/src/types/shared/texture/texture.d.ts similarity index 77% rename from src/types/shared/rich-text.d.ts rename to src/types/shared/texture/texture.d.ts index de777a99ed..7b64b27c79 100644 --- a/src/types/shared/rich-text.d.ts +++ b/src/types/shared/texture/texture.d.ts @@ -1,10 +1,17 @@ /** - * @class RichText + * @param {string} d + * @param {string} text + * @param {number} offset + * @param {object} options + */ +export function getDirectiveTemplate(d: string, text: string, offset: number, options: object): any; +/** + * @class Texture * A String subclass that is used to render rich text (i.e. text that contains * hyperlinks, images, mentions, styling etc.). * * The "rich" parts of the text is represented by lit TemplateResult - * objects which are added via the {@link RichText.addTemplateResult} + * objects which are added via the {@link Texture.addTemplateResult} * method and saved as metadata. * * By default Converse adds TemplateResults to support emojis, hyperlinks, @@ -12,17 +19,17 @@ * * 3rd party plugins can listen for the `beforeMessageBodyTransformed` * and/or `afterMessageBodyTransformed` events and then call - * `addTemplateResult` on the RichText instance in order to add their own + * `addTemplateResult` on the Texture instance in order to add their own * rich features. */ -export class RichText extends String { +export class Texture extends String { /** - * Create a new {@link RichText} instance. + * Create a new {@link Texture} instance. * @param {string} text - The text to be annotated * @param {number} offset - The offset of this particular piece of text * from the start of the original message text. This is necessary because - * RichText instances can be nested when templates call directives - * which create new RichText instances (as happens with XEP-393 styling directives). + * Texture instances can be nested when templates call directives + * which create new Texture instances (as happens with XEP-393 styling directives). * @param {Object} [options] * @param {string} [options.nick] - The current user's nickname (only relevant if the message is in a XEP-0045 MUC) * @param {boolean} [options.render_styling] - Whether XEP-0393 message styling should be applied to the message @@ -46,6 +53,8 @@ export class RichText extends String { * @param {Function} [options.onImgClick] - Callback for when an inline rendered image has been clicked * @param {Function} [options.onImgLoad] - Callback for when an inline rendered image has been loaded * @param {boolean} [options.hide_media_urls] - Callback for when an inline rendered image has been loaded + * + * @typedef {module:headless-shared-parsers.MediaURLMetadata} MediaURLMetadata */ constructor(text: string, offset?: number, options?: { nick?: string; @@ -53,7 +62,7 @@ export class RichText extends String { embed_audio?: boolean; embed_videos?: boolean; mentions?: any[]; - media_urls?: MediaURLMetadata[]; + media_urls?: any[]; show_images?: boolean; show_me_message?: boolean; onImgClick?: Function; @@ -74,7 +83,7 @@ export class RichText extends String { embed_audio?: boolean; embed_videos?: boolean; mentions?: any[]; - media_urls?: MediaURLMetadata[]; + media_urls?: any[]; show_images?: boolean; show_me_message?: boolean; onImgClick?: Function; @@ -86,19 +95,25 @@ export class RichText extends String { render_styling: boolean; show_images: boolean; hide_media_urls: boolean; - shouldRenderMedia(url_text: any, type: any): any; + /** + * @param {string} url - The URL to be checked + * @param {'audio'|'image'|'video'} type - The type of media + */ + shouldRenderMedia(url: string, type: "audio" | "image" | "video"): any; /** * Look for `http` URIs and return templates that render them as URL links * @param {string} text * @param {number} local_offset - The index of the passed in text relative to - * the start of this RichText instance (which is not necessarily the same as the + * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). + * + * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData */ addHyperlinks(text: string, local_offset: number): void; /** * Look for `geo` URIs and return templates that render them as URL links - * @param { String } text - * @param { number } offset - The index of the passed in text relative to + * @param {String} text + * @param {number} offset - The index of the passed in text relative to * the start of the message body text. */ addMapURLs(text: string, offset: number): void; @@ -112,9 +127,9 @@ export class RichText extends String { /** * Look for mentions included as XEP-0372 references and add templates for * rendering them. - * @param { String } text - * @param { number } local_offset - The index of the passed in text relative to - * the start of this RichText instance (which is not necessarily the same as the + * @param {String} text + * @param {number} local_offset - The index of the passed in text relative to + * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). */ addMentions(text: string, local_offset: number): void; @@ -124,7 +139,7 @@ export class RichText extends String { addStyling(): void; trimMeMessage(): void; /** - * Look for plaintext (i.e. non-templated) sections of this RichText + * Look for plaintext (i.e. non-templated) sections of this Texture * instance and add references via the passed in function. * @param { Function } func */ @@ -140,22 +155,20 @@ export class RichText extends String { * This method can be used to add new template results to this message's * text. * - * @method RichText.addTemplateResult - * @param { Number } begin - The starting index of the plain message text + * @method Texture.addTemplateResult + * @param {Number} begin - The starting index of the plain message text * which is being replaced with markup. - * @param { Number } end - The ending index of the plain message text + * @param {Number} end - The ending index of the plain message text * which is being replaced with markup. - * @param { Object } template - The lit TemplateResult instance + * @param {Object} template - The lit TemplateResult instance */ addTemplateResult(begin: number, end: number, template: any): void; isMeCommand(): boolean; /** * Take the annotations and return an array of text and TemplateResult * instances to be rendered to the DOM. - * @method RichText#marshall + * @method Texture#marshall */ marshall(): any[]; } -export type MediaURLMetadata = any; -export type MediaURLData = any; -//# sourceMappingURL=rich-text.d.ts.map \ No newline at end of file +//# sourceMappingURL=texture.d.ts.map \ No newline at end of file diff --git a/src/types/shared/texture/utils.d.ts b/src/types/shared/texture/utils.d.ts new file mode 100644 index 0000000000..3f9d925908 --- /dev/null +++ b/src/types/shared/texture/utils.d.ts @@ -0,0 +1,37 @@ +/** + * @param {any} s + * @returns {boolean} - Returns true if the input is a string, otherwise false. + */ +export function isString(s: any): boolean; +/** + * We don't render more than two line-breaks, replace extra line-breaks with + * the zero-width whitespace character + * This takes into account other characters that may have been removed by + * being replaced with a zero-width space, such as '> ' in the case of + * multi-line quotes. + * @param {string} text + */ +export function collapseLineBreaks(text: string): string; +export function tplMention(o: any): import("lit").TemplateResult<1>; +/** + * @param {import('./texture').Texture} text + * @param {number} i + */ +export function getDirectiveAndLength(text: import("./texture").Texture, i: number): { + d: string; + length: number; +} | { + d?: undefined; + length?: undefined; +}; +/** + * @param {string} d + */ +export function isQuoteDirective(d: string): boolean; +/** + * @param {import('./texture').Texture} text + * @returns {boolean} + */ +export function containsDirectives(text: import("./texture").Texture): boolean; +export function tplMentionWithNick(o: any): import("lit").TemplateResult<1>; +//# sourceMappingURL=utils.d.ts.map \ No newline at end of file From fcce14269343c591d33f1311e1ca76f9cd3b4923 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 11 Jan 2025 00:54:01 +0200 Subject: [PATCH 4/6] Identify Audio streams and render them Updates #317 --- CHANGES.md | 4 +- docs/source/configuration.rst | 8 ++++ src/plugins/chatview/index.js | 1 + src/plugins/chatview/tests/message-audio.js | 45 ++++++++++++++++++++- src/plugins/chatview/tests/messages.js | 23 ++++++++++- src/plugins/chatview/tests/oob.js | 11 ++--- src/plugins/chatview/tests/styling.js | 22 ++++++++++ src/plugins/chatview/tests/xss.js | 7 +++- src/plugins/muc-views/templates/muc-head.js | 2 +- src/shared/tests/mock.js | 6 +-- src/shared/texture/texture.js | 39 ++++++++++-------- src/shared/texture/utils.js | 14 +++++++ src/templates/audio.js | 17 ++++++-- src/templates/styles/audio.scss | 8 ++++ 14 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 src/templates/styles/audio.scss diff --git a/CHANGES.md b/CHANGES.md index 8318e8067d..c7fb2b5cfc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ ### Github Issues - #122: Set horizontal layout direction based on the language +- #317: Add the ability to render audio streams. New config option [show_self_in_roster](https://conversejs.org/docs/html/configuration.html#show-self-in-roster) - #698: Add support for MUC private messages - #1021: Message from non-roster contacts don't appear in fullscreen view_mode - #1038: Support setting node config manually @@ -19,9 +20,8 @@ - #2980: Allow setting an avatar for MUCs - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups - #3038: Message to self from other client is ignored -- #3038: Support showing yourself in the MUC sidebar. Adds new config option `muc_show_self`. +- #3038: Support showing yourself in the left sidebar. Adds new config option `[show_self_in_roster](https://conversejs.org/docs/html/configuration.html#show-self-in-roster)`. - #3100: fixed width `.box-flyout` breaks responsive design in embedded, mobile viewport mode. -- #3038: Support showing yourself in the MUC sidebar. Adds new config option `muc_show_self`. - #3155: Some ad-hoc commands not working - #3155: Some adhoc commands aren't working - #3299: Registration fails when a password contains an & diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 6d83759417..55e24c648a 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -917,6 +917,14 @@ support is turned on or not. Recommended to set to ``true`` if a websocket connection is used. Please see the :ref:`websocket-url` configuration setting. +fetch_url_headers +----------------- + +* Default: ``true`` + +If set to ``false``, then Converse won't fetch the headers of URLs to determine +whether they link to media that can be embedded (e.g. streaming audio). + filter_by_resource ------------------ diff --git a/src/plugins/chatview/index.js b/src/plugins/chatview/index.js index d52202aed7..ba881990f5 100644 --- a/src/plugins/chatview/index.js +++ b/src/plugins/chatview/index.js @@ -33,6 +33,7 @@ converse.plugins.add('converse-chatview', { * loaded by converse.js's plugin machinery. */ api.settings.extend({ + 'fetch_url_headers': true, 'allowed_audio_domains': null, 'allowed_image_domains': null, 'allowed_video_domains': null, diff --git a/src/plugins/chatview/tests/message-audio.js b/src/plugins/chatview/tests/message-audio.js index 2d4dbf3cc8..5c80235ac7 100644 --- a/src/plugins/chatview/tests/message-audio.js +++ b/src/plugins/chatview/tests/message-audio.js @@ -5,12 +5,55 @@ const { sizzle, u } = converse.env; describe("A Chat Message", function () { it("will render audio files from their URLs", - mock.initConverse(['chatBoxesFetched'], {}, + mock.initConverse(['chatBoxesFetched'], + { fetch_url_headers: true }, async function (_converse) { await mock.waitForRoster(_converse, 'current'); const base_url = 'https://conversejs.org'; const message = base_url+"/logo/audio.mp3"; + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000) + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.querySelector('audio').src).toEqual(message); + })); + + it("will render audio streams", + mock.initConverse(['chatBoxesFetched'], + { fetch_url_headers: true }, + async function (_converse) { + + spyOn(window, 'fetch').and.callFake(async () => { + return new Response('', { + status: 200, + headers: { + 'Content-Type': 'audio/mpeg' + } + }); + }); + + await mock.waitForRoster(_converse, 'current'); + const message = 'http://foo.bar/stream'; + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000) + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.querySelector('audio').src).toEqual(message); + })); + + xit("will render audio stream", + mock.initConverse(['chatBoxesFetched'], + { fetch_url_headers: true }, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const message = 'https://differentdrumz.radioca.st/stream/1/'; + 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); diff --git a/src/plugins/chatview/tests/messages.js b/src/plugins/chatview/tests/messages.js index 46785cd0ae..877e640725 100644 --- a/src/plugins/chatview/tests/messages.js +++ b/src/plugins/chatview/tests/messages.js @@ -547,9 +547,19 @@ describe("A Chat Message", function () { })); it("will remove url query parameters from hyperlinks as set", - mock.initConverse(['chatBoxesFetched'], {'filter_url_query_params': ['utm_medium', 'utm_content', 's']}, + mock.initConverse(['chatBoxesFetched'], { filter_url_query_params: ['utm_medium', 'utm_content', 's']}, async function (_converse) { + const originalFetch = window.fetch; + spyOn(window, 'fetch').and.callFake(async (...args) => { + if (args[1].method === 'HEAD') { + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' } + }); + } + return await originalFetch(...args); + }); await mock.waitForRoster(_converse, 'current'); await mock.openControlBox(_converse); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; @@ -575,7 +585,16 @@ describe("A Chat Message", function () { })); it("properly renders URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - + const originalFetch = window.fetch; + spyOn(window, 'fetch').and.callFake(async (...args) => { + if (args[1].method === 'HEAD') { + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' } + }); + } + return await originalFetch(...args); + }); await mock.waitForRoster(_converse, 'current'); await mock.openControlBox(_converse); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; diff --git a/src/plugins/chatview/tests/oob.js b/src/plugins/chatview/tests/oob.js index 054fed0acb..cbabad362b 100644 --- a/src/plugins/chatview/tests/oob.js +++ b/src/plugins/chatview/tests/oob.js @@ -32,9 +32,7 @@ describe("A Chat Message", function () { expect(u.hasClass('chat-msg__text', msg)).toBe(true); expect(msg.textContent).toEqual('Have you heard this funny audio?'); const media = view.querySelector('.chat-msg .chat-msg__media'); - expect(media.innerHTML.replace(//g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( - ``+ - `${url}`); + expect(media.querySelector('audio').getAttribute('src')).toBe(url); // If the and contents is the same, don't duplicate. stanza = u.toStanza(` @@ -54,10 +52,9 @@ describe("A Chat Message", function () { expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null); // But we do render the body - const msg_el = view.querySelector('converse-chat-message:last-child .chat-msg__text'); - await u.waitUntil(() => msg_el.innerHTML.replace(//g, '').replace(/(\r\n|\n|\r)/gm, "").trim() === - ``+ - `${url}`); + const audio = await await u.waitUntil( + () => view.querySelector('converse-chat-message:last-child .chat-msg__text audio')); + expect(audio.getAttribute('src')).toBe(url); })); it("will render video from oob mp4 URLs", diff --git a/src/plugins/chatview/tests/styling.js b/src/plugins/chatview/tests/styling.js index c61c1ffcda..febe285e20 100644 --- a/src/plugins/chatview/tests/styling.js +++ b/src/plugins/chatview/tests/styling.js @@ -477,6 +477,17 @@ describe("An XEP-0393 styled message ", function () { mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const originalFetch = window.fetch; + spyOn(window, 'fetch').and.callFake(async (...args) => { + if (args[1].method === 'HEAD') { + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' } + }); + } + return await originalFetch(...args); + }); + const { api } = _converse; await mock.waitForRoster(_converse, 'current', 1); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; @@ -522,6 +533,17 @@ describe("An XEP-0393 styled message ", function () { it("can be sent as a correction by using the up arrow", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const originalFetch = window.fetch; + spyOn(window, 'fetch').and.callFake(async (...args) => { + if (args[1].method === 'HEAD') { + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' } + }); + } + return await originalFetch(...args); + }); + await mock.waitForRoster(_converse, 'current', 1); await mock.openControlBox(_converse); const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; diff --git a/src/plugins/chatview/tests/xss.js b/src/plugins/chatview/tests/xss.js index 8e543827ad..5eb71da607 100644 --- a/src/plugins/chatview/tests/xss.js +++ b/src/plugins/chatview/tests/xss.js @@ -115,7 +115,10 @@ describe("XSS", function () { expect(window.alert).not.toHaveBeenCalled(); })); - it("will have properly escaped URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + it("will have properly escaped URLs", mock.initConverse( + ['chatBoxesFetched'], + { render_media: false }, + async function (_converse) { await mock.waitForRoster(_converse, 'current'); await mock.openControlBox(_converse); @@ -173,7 +176,7 @@ describe("XSS", function () { })); it("will avoid malformed and unsafe urls urls from rendering as anchors", - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + mock.initConverse(['chatBoxesFetched'], { render_media: false }, async function (_converse) { await mock.waitForRoster(_converse, 'current'); await mock.openControlBox(_converse); diff --git a/src/plugins/muc-views/templates/muc-head.js b/src/plugins/muc-views/templates/muc-head.js index 48b568d6e9..2892a120e4 100644 --- a/src/plugins/muc-views/templates/muc-head.js +++ b/src/plugins/muc-views/templates/muc-head.js @@ -52,7 +52,7 @@ export default (el) => { ${ show_subject ? html`

- +

` : '' } `; } diff --git a/src/shared/tests/mock.js b/src/shared/tests/mock.js index 67d060fd7a..3830d55502 100644 --- a/src/shared/tests/mock.js +++ b/src/shared/tests/mock.js @@ -757,20 +757,20 @@ async function _initConverse (settings) { _converse = await converse.initialize(Object.assign({ animate: false, - disable_effects: true, auto_subscribe: false, bosh_service_url: 'montague.lit/http-bind', + disable_effects: true, discover_connection_methods: false, enable_smacks: false, + fetch_url_headers: false, i18n: 'en', loglevel: window.location.pathname === '/debug.html' ? 'debug' : 'error', no_trimming: true, persistent_store: 'localStorage', play_sounds: false, - use_emojione: false, theme, + use_emojione: false, view_mode, - no_trimming: true, }, settings || {})); window._converse = _converse; diff --git a/src/shared/texture/texture.js b/src/shared/texture/texture.js index 17c94124be..76026c2e8c 100644 --- a/src/shared/texture/texture.js +++ b/src/shared/texture/texture.js @@ -13,6 +13,7 @@ import { collapseLineBreaks, containsDirectives, getDirectiveAndLength, + getHeaders, isQuoteDirective, isString, tplMention, @@ -130,14 +131,14 @@ export class Texture extends String { * * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData */ - addHyperlinks(text, local_offset) { + async addHyperlinks(text, local_offset) { const full_offset = local_offset + this.offset; const urls_meta = this.media_urls || getMediaURLsMetadata(text, local_offset).media_urls || []; const media_urls = /** @type {MediaURLData[]} */ (getMediaURLs(urls_meta, text, full_offset)); - media_urls + await Promise.all(media_urls .filter((o) => !o.is_encrypted) - .forEach((url_obj) => { + .map(async (url_obj) => { const url_text = url_obj.url; const filtered_url = filterQueryParamsFromURL(url_text); let template; @@ -145,21 +146,27 @@ export class Texture extends String { template = tplGif(filtered_url, this.hide_media_urls); } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { template = tplImage({ - 'src': filtered_url, + src: filtered_url, // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here - 'href': this.hide_media_urls ? null : filtered_url, - 'onClick': this.onImgClick, - 'onLoad': this.onImgLoad, + href: this.hide_media_urls ? null : filtered_url, + onClick: this.onImgClick, + onLoad: this.onImgLoad, }); } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) { template = tplVideo(filtered_url, this.hide_media_urls); } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) { template = tplAudio(filtered_url, this.hide_media_urls); } else { - template = getHyperlinkTemplate(filtered_url); + if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) { + const headers = await getHeaders(url_text); + if (headers.get('content-type')?.startsWith('audio')) { + template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name')); + } + } } + template = template || getHyperlinkTemplate(filtered_url); this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template); - }); + })); } /** @@ -279,16 +286,16 @@ export class Texture extends String { /** * Look for plaintext (i.e. non-templated) sections of this Texture * instance and add references via the passed in function. - * @param { Function } func + * @param {Function} func */ - addAnnotations(func) { + async addAnnotations(func) { const payload = this.marshall(); let idx = 0; // The text index of the element in the payload for (const text of payload) { if (!text) { continue; } else if (isString(text)) { - func.call(this, text, idx); + await func.call(this, text, idx); idx += text.length; } else { idx = text.end; @@ -312,12 +319,12 @@ export class Texture extends String { await api.trigger('beforeMessageBodyTransformed', this, { 'Synchronous': true }); this.render_styling && this.addStyling(); - this.addAnnotations(this.addMentions); - this.addAnnotations(this.addHyperlinks); - this.addAnnotations(this.addMapURLs); + await this.addAnnotations(this.addMentions); + await this.addAnnotations(this.addHyperlinks); + await this.addAnnotations(this.addMapURLs); await api.emojis.initialize(); - this.addAnnotations(this.addEmojis); + await this.addAnnotations(this.addEmojis); /** * Synchronous event which provides a hook for transforming a chat message's body text diff --git a/src/shared/texture/utils.js b/src/shared/texture/utils.js index 8a5257542a..6d2df8a6d6 100644 --- a/src/shared/texture/utils.js +++ b/src/shared/texture/utils.js @@ -9,6 +9,20 @@ export function isString(s) { return typeof s === 'string'; } +/** + * @param {string} url + * @returns {Promise} + */ +export async function getHeaders(url) { + try { + const response = await fetch(url, { method: 'HEAD' }); + return response.headers; + } catch (e) { + console.warn(`Error calling HEAD on url ${url}: ${e}`); + return null; + } +} + /** * We don't render more than two line-breaks, replace extra line-breaks with * the zero-width whitespace character diff --git a/src/templates/audio.js b/src/templates/audio.js index 7b083d0855..c6e0c2dcab 100644 --- a/src/templates/audio.js +++ b/src/templates/audio.js @@ -1,10 +1,19 @@ import { html } from 'lit'; +import './styles/audio.scss'; + /** * @param {string} url * @param {boolean} [hide_url] + * @param {string} [title] */ -export default (url, hide_url) => - html`${hide_url - ? '' - : html`${url}`}`; +export default (url, hide_url, title) => { + const { hostname } = new URL(url); + return html`
+ ${title || !hide_url ? html`
+ ${title ? html`${title}
` : ''} + ${hide_url ? '' : html`${hostname}`} +
` : ''} + +
`; +}; diff --git a/src/templates/styles/audio.scss b/src/templates/styles/audio.scss new file mode 100644 index 0000000000..4ce4ea3158 --- /dev/null +++ b/src/templates/styles/audio.scss @@ -0,0 +1,8 @@ +.conversejs { + .audio-element { + figcaption { + padding-bottom: 0.5em; + padding-inline-start: 1em; + } + } +} From e40c38987d97451b31409221aba595a163a3f1d4 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 26 Oct 2022 10:20:37 +0200 Subject: [PATCH 5/6] Embed Spotify player for links to Spotify tracks New config option: `embed_3rd_party_media_players` --- CHANGES.md | 1 + docs/source/configuration.rst | 8 ++ src/headless/shared/settings/constants.js | 1 + .../types/shared/settings/constants.d.ts | 1 + src/plugins/chatview/tests/message-audio.js | 23 ++++- src/shared/styles/messages.scss | 2 +- src/shared/tests/mock.js | 1 + src/shared/texture/texture.js | 85 +++++++++++-------- src/shared/texture/utils.js | 14 +++ src/templates/spotify.js | 22 +++++ src/types/shared/components/gif.d.ts | 4 +- src/types/shared/texture/texture.d.ts | 14 +-- src/types/shared/texture/utils.d.ts | 10 +++ src/types/templates/audio.d.ts | 2 +- src/types/templates/spotify.d.ts | 3 + src/types/utils/html.d.ts | 3 +- src/utils/html.js | 1 + 17 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 src/templates/spotify.js create mode 100644 src/types/templates/spotify.d.ts diff --git a/CHANGES.md b/CHANGES.md index c7fb2b5cfc..451f0828aa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -49,6 +49,7 @@ - Fix: renaming getEmojisByAtrribute to getEmojisByAttribute. ### Changes and features +- Embed the Spotify player for links to Spotify tracks. New config option [embed_3rd_party_media_players](https://conversejs.org/docs/html/configuration.html#embed-3rd-party-media-players). - Add support for XEP-0191 Blocking Command - Upgrade to Bootstrap 5 - Add an occupants filter to the MUC sidebar diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 55e24c648a..04e6e49932 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -809,6 +809,14 @@ domain_placeholder The placeholder text shown in the domain input on the registration form. +embed_3rd_party_media_players +----------------------------- + +* Default: ``true`` + +If ``true``, links to 3rd party media sites, such as Spotify will be turned +into embedded media players from those sites (if supported by Converse). + emoji_categories ---------------- diff --git a/src/headless/shared/settings/constants.js b/src/headless/shared/settings/constants.js index e972a4a077..fcca140e2b 100644 --- a/src/headless/shared/settings/constants.js +++ b/src/headless/shared/settings/constants.js @@ -45,6 +45,7 @@ export const DEFAULT_SETTINGS = { credentials_url: null, // URL from where login credentials can be fetched disable_effects: false, // Disabled UI transition effects. Mainly used for tests. discover_connection_methods: true, + embed_3rd_party_media_players: true, geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g, geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2', i18n: undefined, diff --git a/src/headless/types/shared/settings/constants.d.ts b/src/headless/types/shared/settings/constants.d.ts index 81ed955412..4b1f46e40f 100644 --- a/src/headless/types/shared/settings/constants.d.ts +++ b/src/headless/types/shared/settings/constants.d.ts @@ -11,6 +11,7 @@ export namespace DEFAULT_SETTINGS { let credentials_url: any; let disable_effects: boolean; let discover_connection_methods: boolean; + let embed_3rd_party_media_players: boolean; let geouri_regex: RegExp; let geouri_replacement: string; let i18n: any; diff --git a/src/plugins/chatview/tests/message-audio.js b/src/plugins/chatview/tests/message-audio.js index 5c80235ac7..b266835aab 100644 --- a/src/plugins/chatview/tests/message-audio.js +++ b/src/plugins/chatview/tests/message-audio.js @@ -8,7 +8,7 @@ describe("A Chat Message", function () { mock.initConverse(['chatBoxesFetched'], { fetch_url_headers: true }, async function (_converse) { - await mock.waitForRoster(_converse, 'current'); + await mock.waitForRoster(_converse, 'current', 1); const base_url = 'https://conversejs.org'; const message = base_url+"/logo/audio.mp3"; @@ -35,7 +35,7 @@ describe("A Chat Message", function () { }); }); - await mock.waitForRoster(_converse, 'current'); + await mock.waitForRoster(_converse, 'current', 1); const message = 'http://foo.bar/stream'; const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; await mock.openChatBoxFor(_converse, contact_jid); @@ -51,7 +51,7 @@ describe("A Chat Message", function () { { fetch_url_headers: true }, async function (_converse) { - await mock.waitForRoster(_converse, 'current'); + await mock.waitForRoster(_converse, 'current', 1); const message = 'https://differentdrumz.radioca.st/stream/1/'; const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; @@ -64,4 +64,21 @@ describe("A Chat Message", function () { ``+ `${message}`); })); + + it("will render Spotify player for Spotify URLs", + mock.initConverse(['chatBoxesFetched'], + { embed_3rd_party_media_players: true, view_mode: 'fullscreen' }, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const message = 'https://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6'; + + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content iframe').length, 1000) + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.querySelector('iframe').src).toContain('https://open.spotify.com/embed/track/6rqhFgbbKwnb9MLmUQDhG6'); + })); }); diff --git a/src/shared/styles/messages.scss b/src/shared/styles/messages.scss index 5d7fd58bd1..99c6470ce9 100644 --- a/src/shared/styles/messages.scss +++ b/src/shared/styles/messages.scss @@ -108,7 +108,7 @@ display: inline-flex; width: 100%; flex-direction: row; - padding: 0.25em 1rem; + padding: 0 1rem; &.onload { animation: colorchange-chatmessage 1s; diff --git a/src/shared/tests/mock.js b/src/shared/tests/mock.js index 3830d55502..092c26989c 100644 --- a/src/shared/tests/mock.js +++ b/src/shared/tests/mock.js @@ -761,6 +761,7 @@ async function _initConverse (settings) { bosh_service_url: 'montague.lit/http-bind', disable_effects: true, discover_connection_methods: false, + embed_3rd_party_media_players: false, enable_smacks: false, fetch_url_headers: false, i18n: 'en', diff --git a/src/shared/texture/texture.js b/src/shared/texture/texture.js index 76026c2e8c..2d45849dc9 100644 --- a/src/shared/texture/texture.js +++ b/src/shared/texture/texture.js @@ -6,6 +6,7 @@ import tplAudio from 'templates/audio.js'; import tplGif from 'templates/gif.js'; import tplImage from 'templates/image.js'; import tplVideo from 'templates/video.js'; +import tplSpotify from 'templates/spotify.js'; import { getEmojiMarkup } from '../chat/utils.js'; import { getHyperlinkTemplate } from '../../utils/html.js'; import { shouldRenderMediaFromURL } from 'utils/url.js'; @@ -15,6 +16,7 @@ import { getDirectiveAndLength, getHeaders, isQuoteDirective, + isSpotifyTrack, isString, tplMention, tplMentionWithNick, @@ -122,51 +124,62 @@ export class Texture extends String { return shouldRenderMediaFromURL(url, type); } + /** + * Look for `http` URIs and return templates that render them as URL links + * @param {import('utils/url').MediaURLData} url_obj + * @returns {Promise} + */ + async addHyperlinkTemplate(url_obj) { + const url_text = url_obj.url; + const filtered_url = filterQueryParamsFromURL(url_text); + let template; + if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { + template = tplGif(filtered_url, this.hide_media_urls); + } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { + template = tplImage({ + src: filtered_url, + // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here + href: this.hide_media_urls ? null : filtered_url, + onClick: this.onImgClick, + onLoad: this.onImgLoad, + }); + } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) { + template = tplVideo(filtered_url, this.hide_media_urls); + } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) { + template = tplAudio(filtered_url, this.hide_media_urls); + } else if (api.settings.get('embed_3rd_party_media_players') && isSpotifyTrack(url_text)) { + const song_id = url_text.split('/track/')[1]; + template = tplSpotify(song_id, url_text, this.hide_media_urls); + } else { + if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) { + const headers = await getHeaders(url_text); + if (headers.get('content-type')?.startsWith('audio')) { + template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name')); + } + } + } + return template || getHyperlinkTemplate(filtered_url); + } + /** * Look for `http` URIs and return templates that render them as URL links * @param {string} text * @param {number} local_offset - The index of the passed in text relative to * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). - * - * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData */ async addHyperlinks(text, local_offset) { const full_offset = local_offset + this.offset; const urls_meta = this.media_urls || getMediaURLsMetadata(text, local_offset).media_urls || []; - const media_urls = /** @type {MediaURLData[]} */ (getMediaURLs(urls_meta, text, full_offset)); - - await Promise.all(media_urls - .filter((o) => !o.is_encrypted) - .map(async (url_obj) => { - const url_text = url_obj.url; - const filtered_url = filterQueryParamsFromURL(url_text); - let template; - if (isGIFURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { - template = tplGif(filtered_url, this.hide_media_urls); - } else if (isImageURL(url_text) && this.shouldRenderMedia(url_text, 'image')) { - template = tplImage({ - src: filtered_url, - // XXX: bit of an abuse of `hide_media_urls`, might want a dedicated option here - href: this.hide_media_urls ? null : filtered_url, - onClick: this.onImgClick, - onLoad: this.onImgLoad, - }); - } else if (isVideoURL(url_text) && this.shouldRenderMedia(url_text, 'video')) { - template = tplVideo(filtered_url, this.hide_media_urls); - } else if (isAudioURL(url_text) && this.shouldRenderMedia(url_text, 'audio')) { - template = tplAudio(filtered_url, this.hide_media_urls); - } else { - if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) { - const headers = await getHeaders(url_text); - if (headers.get('content-type')?.startsWith('audio')) { - template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name')); - } - } - } - template = template || getHyperlinkTemplate(filtered_url); - this.addTemplateResult(url_obj.start + local_offset, url_obj.end + local_offset, template); - })); + const media_urls = getMediaURLs(urls_meta, text, full_offset); + await Promise.all( + media_urls + .filter((o) => !o.is_encrypted) + .map(async (o) => { + const template = await this.addHyperlinkTemplate(o); + this.addTemplateResult(o.start + local_offset, o.end + local_offset, template); + }) + ); } /** @@ -311,7 +324,7 @@ export class Texture extends String { * Synchronous event which provides a hook for transforming a chat message's body text * before the default transformations have been applied. * @event _converse#beforeMessageBodyTransformed - * @param { Texture } text - A {@link Texture } instance. You + * @param {Texture} text - A {@link Texture } instance. You * can call {@link Texture#addTemplateResult } on it in order to * add TemplateResult objects meant to render rich parts of the message. * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... }); diff --git a/src/shared/texture/utils.js b/src/shared/texture/utils.js index 6d2df8a6d6..129ef25a63 100644 --- a/src/shared/texture/utils.js +++ b/src/shared/texture/utils.js @@ -9,6 +9,20 @@ export function isString(s) { return typeof s === 'string'; } +/** + * @param {string} url + * @returns {boolean} + */ +export function isSpotifyTrack(url) { + try { + const { hostname, pathname } = new URL(url); + return hostname === 'open.spotify.com' && pathname.startsWith('/track/'); + } catch (e) { + console.warn(`Could not create URL object from ${url}`); + return false; + } +} + /** * @param {string} url * @returns {Promise} diff --git a/src/templates/spotify.js b/src/templates/spotify.js new file mode 100644 index 0000000000..ce2b4ee527 --- /dev/null +++ b/src/templates/spotify.js @@ -0,0 +1,22 @@ +import { html } from 'lit'; + +/** + * @param {string} song_id - The ID of the song to embed. + * @param {string} url - The URL to link to (if not hidden). + * @param {boolean} hide_url - Flag to determine if the URL should be hidden. + * @returns {import('lit').TemplateResult} + */ +export default (song_id, url, hide_url) => { + const { hostname } = new URL(url); + return html`
+ + ${hide_url ? '' : html`${hostname}`} +
`; +} diff --git a/src/types/shared/components/gif.d.ts b/src/types/shared/components/gif.d.ts index 8aafb1bc1f..82d285a70f 100644 --- a/src/types/shared/components/gif.d.ts +++ b/src/types/shared/components/gif.d.ts @@ -24,8 +24,8 @@ export default class ConverseGIFElement extends CustomElement { initGIF(): void; supergif: ConverseGif; updated(changed: any): void; - render(): string | import("lit").TemplateResult<1>; - renderErrorFallback(): string | import("lit").TemplateResult<1>; + render(): string | import("utils/html.js").TemplateResult; + renderErrorFallback(): string | import("utils/html.js").TemplateResult; setHover(): void; hover_timeout: NodeJS.Timeout; unsetHover(): void; diff --git a/src/types/shared/texture/texture.d.ts b/src/types/shared/texture/texture.d.ts index 7b64b27c79..991944dc52 100644 --- a/src/types/shared/texture/texture.d.ts +++ b/src/types/shared/texture/texture.d.ts @@ -100,16 +100,20 @@ export class Texture extends String { * @param {'audio'|'image'|'video'} type - The type of media */ shouldRenderMedia(url: string, type: "audio" | "image" | "video"): any; + /** + * Look for `http` URIs and return templates that render them as URL links + * @param {import('utils/url').MediaURLData} url_obj + * @returns {Promise} + */ + addHyperlinkTemplate(url_obj: import("utils/url").MediaURLData): Promise; /** * Look for `http` URIs and return templates that render them as URL links * @param {string} text * @param {number} local_offset - The index of the passed in text relative to * the start of this Texture instance (which is not necessarily the same as the * offset from the start of the original message stanza's body text). - * - * @typedef {module:headless-shared-parsers.MediaURLData} MediaURLData */ - addHyperlinks(text: string, local_offset: number): void; + addHyperlinks(text: string, local_offset: number): Promise; /** * Look for `geo` URIs and return templates that render them as URL links * @param {String} text @@ -141,9 +145,9 @@ export class Texture extends String { /** * Look for plaintext (i.e. non-templated) sections of this Texture * instance and add references via the passed in function. - * @param { Function } func + * @param {Function} func */ - addAnnotations(func: Function): void; + addAnnotations(func: Function): Promise; /** * Parse the text and add template references for rendering the "rich" parts. **/ diff --git a/src/types/shared/texture/utils.d.ts b/src/types/shared/texture/utils.d.ts index 3f9d925908..619f57f8f1 100644 --- a/src/types/shared/texture/utils.d.ts +++ b/src/types/shared/texture/utils.d.ts @@ -3,6 +3,16 @@ * @returns {boolean} - Returns true if the input is a string, otherwise false. */ export function isString(s: any): boolean; +/** + * @param {string} url + * @returns {boolean} + */ +export function isSpotifyTrack(url: string): boolean; +/** + * @param {string} url + * @returns {Promise} + */ +export function getHeaders(url: string): Promise; /** * We don't render more than two line-breaks, replace extra line-breaks with * the zero-width whitespace character diff --git a/src/types/templates/audio.d.ts b/src/types/templates/audio.d.ts index 1cde727e6f..283d0c6716 100644 --- a/src/types/templates/audio.d.ts +++ b/src/types/templates/audio.d.ts @@ -1,3 +1,3 @@ -declare function _default(url: string, hide_url?: boolean): import("lit").TemplateResult<1>; +declare function _default(url: string, hide_url?: boolean, title?: string): import("lit").TemplateResult<1>; export default _default; //# sourceMappingURL=audio.d.ts.map \ No newline at end of file diff --git a/src/types/templates/spotify.d.ts b/src/types/templates/spotify.d.ts new file mode 100644 index 0000000000..e95fcc5d63 --- /dev/null +++ b/src/types/templates/spotify.d.ts @@ -0,0 +1,3 @@ +declare function _default(song_id: string, url: string, hide_url: boolean): import("lit").TemplateResult; +export default _default; +//# sourceMappingURL=spotify.d.ts.map \ No newline at end of file diff --git a/src/types/utils/html.d.ts b/src/types/utils/html.d.ts index 5917940179..68e11c2f4b 100644 --- a/src/types/utils/html.d.ts +++ b/src/types/utils/html.d.ts @@ -49,8 +49,9 @@ export function removeElement(el: Element): Element; export function ancestor(el: HTMLElement, selector: string): HTMLElement; /** * @param {string} url + * @returns {TemplateResult|string} */ -export function getHyperlinkTemplate(url: string): string | import("lit").TemplateResult<1>; +export function getHyperlinkTemplate(url: string): TemplateResult | string; /** * Shows/expands an element by sliding it out of itself * @method slideOut diff --git a/src/utils/html.js b/src/utils/html.js index b209a98b08..2714413637 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -321,6 +321,7 @@ function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOL /** * @param {string} url + * @returns {TemplateResult|string} */ export function getHyperlinkTemplate (url) { const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url; From 466842cd2051ac30d44b91012a3492f75635ef9a Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 12 Jan 2025 21:39:29 +0200 Subject: [PATCH 6/6] Various changes. - Add docstrings. - Render only hostname below video element - Handle no headers being returned --- CHANGES.md | 2 +- src/plugins/chatview/tests/message-audio.js | 19 -------- src/plugins/chatview/tests/message-videos.js | 12 ++--- src/plugins/chatview/tests/oob.js | 4 +- src/shared/directives/image.js | 47 ++++++++++++++------ src/shared/texture/texture.js | 2 +- src/templates/video.js | 11 +++-- src/types/shared/directives/image.d.ts | 29 ++++++++++-- 8 files changed, 72 insertions(+), 54 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 451f0828aa..6d8f5401f5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ ### Github Issues - #122: Set horizontal layout direction based on the language -- #317: Add the ability to render audio streams. New config option [show_self_in_roster](https://conversejs.org/docs/html/configuration.html#show-self-in-roster) +- #317: Add the ability to render audio streams. New config option [fetch_url_headers](https://conversejs.org/docs/html/configuration.html#fetch-url-headers) - #698: Add support for MUC private messages - #1021: Message from non-roster contacts don't appear in fullscreen view_mode - #1038: Support setting node config manually diff --git a/src/plugins/chatview/tests/message-audio.js b/src/plugins/chatview/tests/message-audio.js index b266835aab..95a943e9bd 100644 --- a/src/plugins/chatview/tests/message-audio.js +++ b/src/plugins/chatview/tests/message-audio.js @@ -46,25 +46,6 @@ describe("A Chat Message", function () { expect(msg.querySelector('audio').src).toEqual(message); })); - xit("will render audio stream", - mock.initConverse(['chatBoxesFetched'], - { fetch_url_headers: true }, - async function (_converse) { - - await mock.waitForRoster(_converse, 'current', 1); - const message = 'https://differentdrumz.radioca.st/stream/1/'; - - 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); - await mock.sendMessage(view, message); - await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000) - const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); - expect(msg.innerHTML.replace(//g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( - ``+ - `${message}`); - })); - it("will render Spotify player for Spotify URLs", mock.initConverse(['chatBoxesFetched'], { embed_3rd_party_media_players: true, view_mode: 'fullscreen' }, diff --git a/src/plugins/chatview/tests/message-videos.js b/src/plugins/chatview/tests/message-videos.js index dfa388e5bc..509f5e4304 100644 --- a/src/plugins/chatview/tests/message-videos.js +++ b/src/plugins/chatview/tests/message-videos.js @@ -16,17 +16,13 @@ describe("A chat message containing video URLs", function () { await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000) let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); - expect(msg.innerHTML.replace(//g, '').trim()).toEqual( - ``+ - `${message}`); + expect(msg.querySelector('video').src).toEqual(message); message += "?param1=val1¶m2=val2"; await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content video').length === 2, 1000); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); - expect(msg.innerHTML.replace(//g, '').trim()).toEqual( - ``+ - `${Strophe.xmlescape(message)}`); + expect(msg.querySelector('video').src).toEqual(message); })); it("will not render videos if render_media is false", @@ -64,9 +60,7 @@ describe("A chat message containing video URLs", function () { await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000) const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); - expect(msg.innerHTML.replace(//g, '').trim()).toEqual( - ``+ - `${message}`); + expect(msg.querySelector('video').src).toEqual(message); })); it("will allow the user to toggle visibility of rendered videos", diff --git a/src/plugins/chatview/tests/oob.js b/src/plugins/chatview/tests/oob.js index cbabad362b..8a9de76e30 100644 --- a/src/plugins/chatview/tests/oob.js +++ b/src/plugins/chatview/tests/oob.js @@ -82,9 +82,7 @@ describe("A Chat Message", function () { expect(msg.classList.length).toBe(1); expect(msg.textContent).toEqual('Have you seen this funny video?'); const media = view.querySelector('.chat-msg .chat-msg__media'); - expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(//g, '')).toEqual( - ``+ - `${Strophe.xmlescape(url)}`); + expect(media.querySelector('video').getAttribute('src')).toBe(url); // If the and contents is the same, don't duplicate. stanza = u.toStanza(` diff --git a/src/shared/directives/image.js b/src/shared/directives/image.js index 14f7a21660..cabb5d641d 100644 --- a/src/shared/directives/image.js +++ b/src/shared/directives/image.js @@ -7,25 +7,46 @@ import { getHyperlinkTemplate } from 'utils/html.js'; const { URI } = converse.env; const { isURLWithImageExtension } = u; - class ImageDirective extends AsyncDirective { - - render (src, href, onLoad, onClick) { - return href ? - html`${ this.renderImage(src, href, onLoad, onClick) }` : - this.renderImage(src, href, onLoad, onClick); + /** + * @param {string} src - The source URL of the image. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + * @returns {import('lit').TemplateResult} + */ + render(src, href, onLoad, onClick) { + return href + ? html`${this.renderImage(src, href, onLoad, onClick)}` + : this.renderImage(src, href, onLoad, onClick); } - renderImage (src, href, onLoad, onClick) { + /** + * @param {string} src - The source URL of the image. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + * @returns {import('lit').TemplateResult} + */ + renderImage(src, href, onLoad, onClick) { return html` this.onError(src, href, onLoad, onClick)} - @load="${onLoad}"/>`; + loading="lazy" + src="${src}" + @click=${onClick} + @error=${() => this.onError(src, href, onLoad, onClick)} + @load="${onLoad}"/>`; } - onError (src, href, onLoad, onClick) { + /** + * Handles errors that occur during image loading. + * @param {string} src - The source URL of the image that failed to load. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + */ + onError(src, href, onLoad, onClick) { if (isURLWithImageExtension(src)) { href && this.setValue(getHyperlinkTemplate(href)); } else { diff --git a/src/shared/texture/texture.js b/src/shared/texture/texture.js index 2d45849dc9..fcf170fa33 100644 --- a/src/shared/texture/texture.js +++ b/src/shared/texture/texture.js @@ -153,7 +153,7 @@ export class Texture extends String { } else { if (this.shouldRenderMedia(url_text, 'audio') && api.settings.get('fetch_url_headers')) { const headers = await getHeaders(url_text); - if (headers.get('content-type')?.startsWith('audio')) { + if (headers?.get('content-type')?.startsWith('audio')) { template = tplAudio(filtered_url, this.hide_media_urls, headers.get('Icy-Name')); } } diff --git a/src/templates/video.js b/src/templates/video.js index fd80311ed0..caa68eb65f 100644 --- a/src/templates/video.js +++ b/src/templates/video.js @@ -4,7 +4,10 @@ import { html } from 'lit'; * @param {string} url * @param {boolean} [hide_url] */ -export default (url, hide_url) => - html`${hide_url - ? '' - : html`${url}`}`; +export default (url, hide_url) => { + const { hostname } = new URL(url); + return html`
+ + ${hide_url ? '' : html`${hostname}`} +
`; +} diff --git a/src/types/shared/directives/image.d.ts b/src/types/shared/directives/image.d.ts index f30c5f4382..378225a5c5 100644 --- a/src/types/shared/directives/image.d.ts +++ b/src/types/shared/directives/image.d.ts @@ -7,11 +7,32 @@ * @param { Function } onLoad - A callback function to be called once the image has loaded. * @param { Function } onClick - A callback function to be called once the image has been clicked. */ -export const renderImage: (src?: any, href?: any, onLoad?: any, onClick?: any) => import("lit/async-directive.js").DirectiveResult; +export const renderImage: (src: string, href?: string, onLoad?: Function, onClick?: Function) => import("lit/async-directive.js").DirectiveResult; declare class ImageDirective extends AsyncDirective { - render(src: any, href: any, onLoad: any, onClick: any): import("lit").TemplateResult<1>; - renderImage(src: any, href: any, onLoad: any, onClick: any): import("lit").TemplateResult<1>; - onError(src: any, href: any, onLoad: any, onClick: any): void; + /** + * @param {string} src - The source URL of the image. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + * @returns {import('lit').TemplateResult} + */ + render(src: string, href?: string, onLoad?: Function, onClick?: Function): import("lit").TemplateResult; + /** + * @param {string} src - The source URL of the image. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + * @returns {import('lit').TemplateResult} + */ + renderImage(src: string, href?: string, onLoad?: Function, onClick?: Function): import("lit").TemplateResult; + /** + * Handles errors that occur during image loading. + * @param {string} src - The source URL of the image that failed to load. + * @param {string} [href] - The optional hyperlink for the image. + * @param {Function} [onLoad] - Callback function to be called once the image has loaded. + * @param {Function} [onClick] - Callback function to be called once the image has been clicked. + */ + onError(src: string, href?: string, onLoad?: Function, onClick?: Function): void; } import { AsyncDirective } from 'lit/async-directive.js'; export {};