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>
+
+
+
+
+ <: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}}
<: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.)"