diff --git a/about.json b/about.json index 4c01f75..5a031be 100644 --- a/about.json +++ b/about.json @@ -3,6 +3,6 @@ "component": true, "modifiers": { "csp_extensions": ["script_src: https://meet.jit.si/external_api.js"], - "svg_icons": ["video"] + "svg_icons": ["video", "copy"] } } diff --git a/common/common.scss b/common/common.scss index 442f3ce..dac78de 100644 --- a/common/common.scss +++ b/common/common.scss @@ -1,8 +1,81 @@ .insert-jitsi-input { + position: relative; + margin-bottom: 1em; .desc { font-size: $font-down-1; color: $primary-medium; max-width: 260px; } + + .desc--bottom { + margin-top: 0rem; + } +} + +.insert-jitsi-copied { + position: absolute; + top: 0; + right: 0; + background: $primary-high; + padding: 0.5rem; + color: $secondary; + font-size: $font-down-1; +} + +.discourse-local-date { + > * { + pointer-events: none; + } + + &.cooked-date { + color: var(--primary); + cursor: pointer; + border-bottom: 1px dashed var(--primary-medium); + white-space: nowrap; + + .d-icon { + color: var(--primary); + } + + &.past { + border-bottom-color: var(--primary-low-mid); + } + + &.past[data-countdown] { + color: var(--primary-medium); + } + } +} + +.locale-dates-previews { + max-width: 250px; + + .preview { + display: flex; + flex-direction: column; + padding: 5px; + margin: 0; + + .timezone { + font-weight: 700; + } + + &.current { + background: var(--tertiary-low); + } + } +} + +.download-calendar { + text-align: right; + cursor: pointer; + margin-top: 0.5em; +} + +@media screen and (max-width: 350px) { + .header-icon-meeting, + .chat-composer-button.-insertChatJitsiInline { + display: none; + } } diff --git a/javascripts/discourse/components/modal/insert-date-time.hbs b/javascripts/discourse/components/modal/insert-date-time.hbs new file mode 100644 index 0000000..9f90f61 --- /dev/null +++ b/javascripts/discourse/components/modal/insert-date-time.hbs @@ -0,0 +1,262 @@ + + <:body> + +
+ {{#if this.isValid}} + {{#if this.timezoneIsDifferentFromUserTimezone}} +
+ {{i18n "discourse_local_dates.create.form.current_timezone"}} + {{this.formattedCurrentUserTimezone}}{{this.currentPreview}} +
+ {{/if}} + {{else}} +
+ {{i18n "discourse_local_dates.create.form.invalid_date"}} +
+ {{/if}} + + {{this.computeDate}} + +
+
+
+ {{d-icon "calendar-alt"}} + +
+ +
+ {{d-icon "calendar-alt"}} + + {{#if this.toFilled}} + + {{/if}} +
+ + {{#unless this.site.mobileView}} + + {{/unless}} +
+ +
+ +
+ + {{#if this.site.mobileView}} + + {{/if}} +
+ + {{#if this.canCreateEvent}} +
+ +

{{theme-i18n "create_event_description"}}

+
+ +
+
+ + {{#if this.willCreateEvent}} +
+ +

{{theme-i18n "include_jitsi_description"}}

+
+ +
+
+ + {{#if this.includeJitsi}} +
+ +

{{theme-i18n "modal.room_field_description"}}

+
+ +
+
+ {{/if}} + +
+ +

+ {{i18n + "discourse_calendar.discourse_post_event.builder_modal.minimal.checkbox_label" + }} +

+
+ +
+
+ {{/if}} + {{/if}} + + {{#if this.advancedMode}} +
+ {{#unless this.isRange}} +
+ +

{{html-safe + (i18n + "discourse_local_dates.create.form.recurring_description" + ) + }}

+
+ +
+
+ {{/unless}} + + {{#unless this.willCreateEvent}} + +
+ +

{{i18n + "discourse_local_dates.create.form.timezones_description" + }}

+
+ +
+
+ +
+ +

+ {{i18n + "discourse_local_dates.create.form.format_description" + }} + + {{d-icon "question-circle"}} + +

+
+ +
+
+
+ +
+ + {{/unless}} +
+ {{/if}} +
+
+ + <:footer> + + + + + + +
\ No newline at end of file diff --git a/javascripts/discourse/components/modal/insert-date-time.js b/javascripts/discourse/components/modal/insert-date-time.js new file mode 100644 index 0000000..24eb669 --- /dev/null +++ b/javascripts/discourse/components/modal/insert-date-time.js @@ -0,0 +1,134 @@ +/* global settings */ + +// import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import LocalDatesCreate from "discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create"; +import { getRandomID, purifyRoomID } from "../../lib/jitsi"; +// Lazy import in case the plugin is not installed +let buildParams; +try { + buildParams = + require("discourse/plugins/discourse-calendar/discourse/lib/raw-event-helper").buildParams; +} catch (e) {} + +export default class InsertDateTime extends LocalDatesCreate { + @service dialog; + @service siteSettings; + @service store; + @service composer; + + @tracked jitsiRoom = ""; + @tracked includeJitsi = false; + @tracked willCreateEvent = false; + + // Randomly generated meeting ID + randomRoomID = getRandomID(); + + get roomID() { + let roomID = ""; + + // User specified room ID + if (this.jitsiRoom.length > 0) { + roomID = purifyRoomID(this.jitsiRoom); + } + + // If a custom room ID is empty or not specified use a random one + if (roomID.length === 0) { + roomID = this.randomRoomID; + } + + return roomID; + } + + get canCreateEvent() { + const composerModel = this.composer.model; + return ( + composerModel && + !composerModel.replyingToTopic && + (composerModel.topicFirstPost || + composerModel.creatingPrivateMessage || + (composerModel.editingPost && + composerModel.post && + composerModel.post.post_number === 1)) + ); + } + + get roomURL() { + const domain = settings.meet_jitsi_domain; + const roomURL = new URL(this.roomID, `https://${domain}`); + return roomURL.toString(); + } + + get recurringOptions() { + if (this.willCreateEvent) { + return [ + { id: "every_day", name: "Every Day" }, + { id: "every_month", name: "Every Month" }, + { id: "every_weekday", name: "Every Weekday" }, + { id: "every_week", name: "Every Week" }, + { id: "every_two_weeks", name: "Every Two Weeks" }, + { id: "every_four_weeks", name: "Every Four Weeks" }, + ]; + } else { + return super.recurringOptions; + } + } + + saveEvent() { + const config = this.computedConfig; + + if (!config.from) { + this.closeModal(); + return; + } + + const startsAt = config.from.dateTime; + const endsAt = config.to.range ? config.to.dateTime : null; + const url = this.includeJitsi ? this.roomURL : null; + + // Ensure recurrence exists within current type + let recurrence = null; + let recurringOptions = this.recurringOptions; + if ( + recurringOptions.find((option) => option.id === this.recurring) !== + undefined + ) { + recurrence = this.recurring; + } + + this.model.event.setProperties({ + starts_at: startsAt, + ends_at: endsAt, + url, + recurrence, + timezone: this.timezone, + }); + + const eventParams = buildParams( + startsAt, + endsAt, + this.model.event, + this.siteSettings + ); + const markdownParams = []; + Object.keys(eventParams).forEach((key) => { + let value = eventParams[key]; + markdownParams.push(`${key}="${value}"`); + }); + + this.model.insertMeeting(`[event ${markdownParams.join(" ")}]\n[/event]`); + + this.closeModal(); + } + + @action + createEvent() { + if (this.willCreateEvent) { + this.saveEvent(); + } else { + this.save(); + } + } +} diff --git a/javascripts/discourse/components/modal/insert-jitsi.hbs b/javascripts/discourse/components/modal/insert-jitsi.hbs index a345dc5..0ec4cdc 100644 --- a/javascripts/discourse/components/modal/insert-jitsi.hbs +++ b/javascripts/discourse/components/modal/insert-jitsi.hbs @@ -13,6 +13,7 @@ @value={{this.jitsiRoom}} @autofocus="autofocus" @autocomplete="off" + @minlength="6" />
{{theme-i18n "modal.room_field_description"}}
@@ -45,14 +46,50 @@ {{/unless}} {{/unless}} + + {{#if @model.createOnly}} +
+ +
+ {{#if this.copied}} + + {{theme-i18n "modal.copied"}} + + {{/if}} + + + +
+

+ {{theme-i18n "modal.url_description"}} +

+
+ {{/if}} <:footer> - + {{#if @model.createOnly}} + + {{else}} + + {{/if}} \ No newline at end of file diff --git a/javascripts/discourse/components/modal/insert-jitsi.js b/javascripts/discourse/components/modal/insert-jitsi.js index 2606447..47d98e9 100644 --- a/javascripts/discourse/components/modal/insert-jitsi.js +++ b/javascripts/discourse/components/modal/insert-jitsi.js @@ -1,14 +1,41 @@ -/*eslint no-undef:0 */ +/* global settings */ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; +import { copyText, getRandomID, purifyRoomID } from "../../lib/jitsi"; -export default class InsertJitsiComponent extends Component { +export default class InsertJitsi extends Component { @tracked mobileIframe = settings.default_mobile_iframe; @tracked desktopIframe = settings.default_desktop_iframe; @tracked jitsiRoom = ""; @tracked buttonText = ""; + @tracked copied = false; + + // Randomly generated meeting ID + randomRoomID = getRandomID(); + + get roomID() { + let roomID = ""; + + // User specified room ID + if (this.jitsiRoom.length > 0) { + roomID = purifyRoomID(this.jitsiRoom); + } + + // If a custom room ID is empty or not specified use a random one + if (roomID.length === 0) { + roomID = this.randomRoomID; + } + + return roomID; + } + + get roomURL() { + const domain = settings.meet_jitsi_domain; + const roomURL = new URL(this.roomID, `https://${domain}`); + return roomURL.toString(); + } keyDown(e) { if (e.keyCode === 13) { @@ -18,21 +45,15 @@ export default class InsertJitsiComponent extends Component { } } - randomID() { - return Math.random().toString(36).slice(-8); - } - @action insert() { - const btnTxt = this.buttonText ? ` label="${this.buttonText}"` : ""; - const roomID = this.jitsiRoom || this.randomID(); - let text; if (this.args.model.plainText) { - const domain = settings.meet_jitsi_domain; - text = `https://${domain}/${roomID}`; + text = this.roomURL; } else { + const roomID = this.roomID; + const btnTxt = this.buttonText ? ` label="${this.buttonText}"` : ""; text = `[wrap=discourse-jitsi room="${roomID}"${btnTxt} mobileIframe="${this.mobileIframe}" desktopIframe="${this.desktopIframe}"][/wrap]`; } @@ -40,6 +61,22 @@ export default class InsertJitsiComponent extends Component { this.args.closeModal(); } + @action + openRoomURL() { + this.args.closeModal(); + window.open(this.roomURL, "_blank"); + } + + @action + copyRoomURL() { + this.copied = true; + copyText(this.roomURL); + + setTimeout(() => { + this.copied = false; + }, 1000); + } + @action cancel() { this.args.closeModal(); diff --git a/javascripts/discourse/initializers/insert-jitsi.js b/javascripts/discourse/initializers/insert-jitsi.js index ec3896c..91635a0 100644 --- a/javascripts/discourse/initializers/insert-jitsi.js +++ b/javascripts/discourse/initializers/insert-jitsi.js @@ -1,10 +1,11 @@ -/*eslint no-undef:0 */ -/* global JitsiMeetExternalAPI */ +/* global JitsiMeetExternalAPI, settings, themePrefix */ +import EmberObject from "@ember/object"; import loadScript from "discourse/lib/load-script"; import { withPluginApi } from "discourse/lib/plugin-api"; -import { iconHTML } from "discourse-common/lib/icon-library"; +import { iconHTML, iconNode } from "discourse-common/lib/icon-library"; import I18n from "I18n"; -import InsertJitsiComponent from "../components/modal/insert-jitsi"; +import InsertDateTime from "../components/modal/insert-date-time"; +import InsertJitsi from "../components/modal/insert-jitsi"; /* eslint-disable */ // prettier-ignore @@ -67,8 +68,48 @@ function attachJitsi($elem, helper) { } } +function createChatButton(chat, modal, id, position) { + return { + title: themePrefix("composer_title"), + id, + group: "insertions", + position, + icon: settings.button_icon, + label: themePrefix("composer_title"), + action: () => { + modal.show(InsertJitsi, { + model: { + insertMeeting: (text) => { + // Get the active channel ensuring one is present + const activeChannel = chat.activeChannel; + if (activeChannel === null) { + return; + } + + // Append the meeting link to the draft + activeChannel.draft.message += text; + }, + plainText: true, + }, + }); + }, + }; +} + /* eslint-enable */ +function removeToolbarItem(toolbar, group, id) { + const targetGroup = toolbar.groups.find((value) => value.group === group); + if (targetGroup !== undefined) { + const buttonIndex = targetGroup.buttons.findIndex( + (button) => button.id === id + ); + if (buttonIndex !== -1) { + targetGroup.buttons.splice(buttonIndex, 1); + } + } +} + export default { name: "insert-jitsi", @@ -76,6 +117,7 @@ export default { withPluginApi("0.8.31", (api) => { const currentUser = api.getCurrentUser(); const modal = api.container.lookup("service:modal"); + const store = api.container.lookup("service:store"); api.decorateCooked(attachJitsi, { id: "discourse-jitsi" }); @@ -88,48 +130,77 @@ export default { return; } - // Chat plugin integration - if (settings.chat_button && api.registerChatComposerButton) { - const chat = api.container.lookup("service:chat"); + const locations = settings.locations.split("|").map((value) => + value + // Remove extra spaces + .trim() + // Convert to lowercase + .toLowerCase() + // Replace spaces with a single dash + .replace(/\s/g, "-") + ); - api.registerChatComposerButton({ - title: themePrefix("composer_title"), - id: "insertChatJitsi", - group: "insertions", - position: settings.chat_button_position, - icon: settings.button_icon, - label: themePrefix("composer_title"), - action: () => { - modal.show(InsertJitsiComponent, { - model: { - insertMeeting: (text) => { - // Get the active channel ensuring one is present - const activeChannel = chat.activeChannel; - if (activeChannel === null) { - return; - } - - // Append the meeting link to the draft - activeChannel.draft.message += text; + // Header button + if (locations.includes("header-icon")) { + // Decorate the header icons section + api.decorateWidget("header-icons:before", (helper) => { + // Create the new header icon + return helper.h("li.header-icon-meeting", [ + helper.h( + "a.icon.btn-flat", + { + onclick: () => { + modal.show(InsertJitsi, { + model: { + // This model cannot insert + insertMeeting: () => {}, + createOnly: true, + plainText: true, + }, + }); + }, + attributes: { + target: "_blank", + title: I18n.t(themePrefix("launch_jitsi")), }, - plainText: true, }, - }); - }, + // Use the video icon + iconNode(settings.button_icon) + ), + ]); }); } - if (settings.show_in_options_dropdown) { + // Chat plugin integration + if (api.registerChatComposerButton) { + const chat = api.container.lookup("service:chat"); + + if (locations.includes("chat-icon")) { + api.registerChatComposerButton( + createChatButton(chat, modal, "insertChatJitsiInline", "inline") + ); + } + + if (locations.includes("chat-toolbar")) { + api.registerChatComposerButton( + createChatButton(chat, modal, "insertChatJitsi", "dropdown") + ); + } + } + + if (locations.includes("composer-gear-menu")) { api.addComposerToolbarPopupMenuOption({ icon: settings.button_icon, label: themePrefix("composer_title"), action: (toolbarEvent) => { - modal.show(InsertJitsiComponent, { + modal.show(InsertJitsi, { model: { insertMeeting: toolbarEvent.addText }, }); }, }); - } else { + } + + if (locations.includes("composer-toolbar")) { api.onToolbarCreate((toolbar) => { toolbar.addButton({ title: themePrefix("composer_title"), @@ -137,12 +208,49 @@ export default { group: "insertions", icon: settings.button_icon, perform: (toolbarEvent) => - modal.show(InsertJitsiComponent, { + modal.show(InsertJitsi, { model: { insertMeeting: toolbarEvent.addText }, }), }); }); } + + // Calendar event integration (Replace "Insert date/time") + if (currentUser && currentUser.can_create_discourse_post_event) { + if (settings.replace_date_time) { + api.onToolbarCreate((toolbar) => { + // Remove the existing toolbar item + removeToolbarItem(toolbar, "extras", "local-dates"); + + // Add our new toolbar item + toolbar.addButton({ + title: "discourse_local_dates.title", + id: "insertJitsiEvent", + group: "insertions", + icon: settings.event_button_icon, + perform: (toolbarEvent) => { + const eventModel = store.createRecord( + "discourse-post-event-event" + ); + eventModel.setProperties({ + status: "public", + custom_fields: EmberObject.create({}), + starts_at: moment(), + timezone: moment.tz.guess(), + }); + + modal.show(InsertDateTime, { + model: { + event: eventModel, + insertMeeting: toolbarEvent.addText, + insertDate: toolbarEvent.addText, + }, + }); + }, + }); + }); + } + } }); }, }; diff --git a/javascripts/discourse/lib/jitsi.js b/javascripts/discourse/lib/jitsi.js new file mode 100644 index 0000000..8aab679 --- /dev/null +++ b/javascripts/discourse/lib/jitsi.js @@ -0,0 +1,59 @@ +export function getRandomID() { + return Math.random().toString(36).slice(-8); +} + +function fallbackCopyText(text) { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand("copy"); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Unable to copy text", err); + + // Final fallback using a prompt + // eslint-disable-next-line no-alert + prompt("Copy to clipboard: Ctrl+C, Enter", text); + } finally { + document.body.removeChild(textArea); + } +} + +export function copyText(text) { + // Fallback for outdated browsers + if (!navigator.clipboard) { + fallbackCopyText(text); + return; + } + + navigator.clipboard.writeText(text).catch((err) => { + // eslint-disable-next-line no-console + console.error("Failed to copy to clipboard", err); + fallbackCopyText(text); + }); +} + +export function purifyRoomID(roomID) { + let purifiedRoomID = roomID.trim(); + + // Strip non-alphanumeric characters for better URL safety and encoding + purifiedRoomID = purifiedRoomID.replace(/[^a-zA-Z0-9 ]/g, ""); + + // Collapse spaces into camel case for better URL encoding + purifiedRoomID = purifiedRoomID + .replace(/\w+/g, function (txt) { + // uppercase first letter and add rest unchanged + return txt.charAt(0).toUpperCase() + txt.substring(1); + }) + // remove any spaces + .replace(/\s/g, ""); + return purifiedRoomID; +} diff --git a/locales/en.yml b/locales/en.yml index 1ce91b2..e0f431e 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,12 +1,22 @@ en: composer_title: Discourse Jitsi room_label: Jitsi room ID + room_url_label: Room URL button_text_label: Button label launch_jitsi: Start Video Conference + create_event_label: Create an event + create_event_description: Create an event for this date/time + include_jitsi_label: Include Jitsi URL + include_jitsi_description: Include a Jitsi room URL with this event modal: room_field_description: Enter an ID for your room (or leave empty to use a randomly generated ID). + url_description: You can connect to and share the room using the URL below insert: Insert cancel: Cancel title: Add Jitsi Integration + copy: Copy Room URL + copied: Copied + title_create: Launch Jitsi Integration mobile_iframe: Show in an iframe on mobile devices desktop_iframe: Show in an iframe on desktop devices + launch: Start Video Conference diff --git a/settings.yml b/settings.yml index 8df59ba..4873852 100644 --- a/settings.yml +++ b/settings.yml @@ -1,20 +1,19 @@ -show_in_options_dropdown: - default: false - description: "When unchecked, icon is shown in composer toolbar." +locations: + type: "list" + list_type: "compact" + description: 'The various locations where a video button should be added, when adding manually ensure the names match the following options: "Header Icon","Composer Toolbar","Composer Gear Menu","Chat Icon","Chat Toolbar"' + default: "Header Icon|Composer Toolbar|Composer Gear Menu|Chat Icon|Chat Toolbar" only_available_to_staff: default: false +replace_date_time: + default: false + description: 'Replace the "Insert date/time" button with a version that allows creating jitsi events' meet_jitsi_domain: default: "meet.jit.si" description: "Domain only (no protocol, no trailing slash)." jitsi_script_src: default: "https://meet.jit.si/external_api.js" description: "URL of external API javascript file. If you change this, you will also need to add the URL to the 'content security script src' site setting." -button_icon: - default: "video" - description: "Enter the name of the FontAwesome 5 icon to display in the Jitsi button. " -chat_button: - default: true - description: "Integrate with Discourse Chat" hide_iframe_buttons: default: false description: "Hide the choice for iframe settings from users, the default settings below will be used" @@ -24,15 +23,17 @@ default_mobile_iframe: default_desktop_iframe: default: true description: "Enable iframe mode by default for desktop" -chat_button_position: - description: "Position of the button within the chat window" - default: dropdown - type: enum - choices: - - dropdown - - inline +button_icon: + default: "video" + description: "Enter the name of the FontAwesome 5 icon to display in the Jitsi button. " +event_button_icon: + default: "calendar-alt" + description: "Enter the name of the FontAwesome 5 icon to display in the Jitsi event button. " +copy_button_icon: + default: "copy" + description: "Enter the name of the FontAwesome 5 icon to display as the copy button. " svg_icons: default: "video" type: "list" list_type: "compact" - description: "If using a custom icon, add it to this field too (in addition to 'button_icon' above." + description: "If using a custom icon, add it to this field too (in addition to 'button_icon', 'copy_button_icon', and 'event_button_icon' above.)"