From 04a63cfaaa445f66c2d3d5309191abe9d36c2371 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 20 Feb 2019 15:42:44 +0100 Subject: [PATCH] [WIP] FEATURE: merge share and invite actions together (#7021) This commit also: - removes [+ New Topic] behaviour from share, this feature has been duplicated in composer actions, months ago - introduces our new experimental spacing standard for css: eg: `s(2)` - introduces a new panel UI for modals --- .../discourse/components/d-modal.js.es6 | 10 +- .../components/group-selector.js.es6 | 1 + .../invite-panel.js.es6} | 256 ++++++++++-------- .../discourse/components/modal-panel.js.es6 | 11 + .../discourse/components/modal-tab.js.es6 | 17 ++ .../discourse/components/share-button.js.es6 | 13 - .../discourse/components/share-panel.js.es6 | 105 +++++++ .../discourse/components/share-popup.js.es6 | 195 ------------- .../discourse/components/user-selector.js.es6 | 4 +- .../initializers/topic-footer-buttons.js.es6 | 113 ++++++-- .../discourse/lib/autocomplete.js.es6 | 5 +- .../discourse/lib/pwa-utils.js.es6 | 12 + .../discourse/lib/show-modal.js.es6 | 18 ++ .../mixins/modal-functionality.js.es6 | 5 + .../javascripts/discourse/routes/topic.js.es6 | 5 - .../discourse/routes/user-invited-show.js.es6 | 15 +- .../templates/components/d-modal.hbs | 24 +- .../templates/components/invite-panel.hbs | 93 +++++++ .../templates/components/modal-panel.hbs | 1 + .../templates/components/modal-tab.hbs | 1 + .../templates/components/share-panel.hbs | 14 + .../templates/components/share-popup.hbs | 28 -- .../javascripts/discourse/templates/modal.hbs | 5 +- .../discourse/templates/modal/invite.hbs | 52 ---- .../templates/modal/share-and-invite.hbs | 3 + .../javascripts/discourse/templates/topic.hbs | 7 +- .../javascripts/discourse/widgets/post.js.es6 | 84 ++++-- app/assets/stylesheets/common.scss | 1 + app/assets/stylesheets/common/base/modal.scss | 44 ++- .../stylesheets/common/base/share_link.scss | 86 ------ .../components/share-and-invite-modal.scss | 86 ++++++ .../common/foundation/spacing.scss | 17 ++ .../stylesheets/desktop/topic-post.scss | 5 - app/assets/stylesheets/mobile/modal.scss | 1 - app/assets/stylesheets/mobile/topic-post.scss | 6 - config/locales/client.en.yml | 11 +- .../share-and-invite-desktop-test.js.es6 | 88 ++++++ .../share-and-invite-mobile-test.js.es6 | 77 ++++++ test/javascripts/acceptance/topic-test.js.es6 | 86 ------ .../components/share-button-test.js.es6 | 15 - test/javascripts/fixtures/topic.js.es6 | 1 + 41 files changed, 938 insertions(+), 683 deletions(-) rename app/assets/javascripts/discourse/{controllers/invite.js.es6 => components/invite-panel.js.es6} (66%) create mode 100644 app/assets/javascripts/discourse/components/modal-panel.js.es6 create mode 100644 app/assets/javascripts/discourse/components/modal-tab.js.es6 delete mode 100644 app/assets/javascripts/discourse/components/share-button.js.es6 create mode 100644 app/assets/javascripts/discourse/components/share-panel.js.es6 delete mode 100644 app/assets/javascripts/discourse/components/share-popup.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/pwa-utils.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/invite-panel.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/modal-panel.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/modal-tab.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/share-panel.hbs delete mode 100644 app/assets/javascripts/discourse/templates/components/share-popup.hbs delete mode 100644 app/assets/javascripts/discourse/templates/modal/invite.hbs create mode 100644 app/assets/javascripts/discourse/templates/modal/share-and-invite.hbs delete mode 100644 app/assets/stylesheets/common/base/share_link.scss create mode 100644 app/assets/stylesheets/common/components/share-and-invite-modal.scss create mode 100644 app/assets/stylesheets/common/foundation/spacing.scss create mode 100644 test/javascripts/acceptance/share-and-invite-desktop-test.js.es6 create mode 100644 test/javascripts/acceptance/share-and-invite-mobile-test.js.es6 delete mode 100644 test/javascripts/components/share-button-test.js.es6 diff --git a/app/assets/javascripts/discourse/components/d-modal.js.es6 b/app/assets/javascripts/discourse/components/d-modal.js.es6 index 44792ecfc06..51c5824e519 100644 --- a/app/assets/javascripts/discourse/components/d-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal.js.es6 @@ -1,9 +1,17 @@ import { on } from "ember-addons/ember-computed-decorators"; export default Ember.Component.extend({ - classNameBindings: [":modal", ":d-modal", "modalClass", "modalStyle"], + classNameBindings: [ + ":modal", + ":d-modal", + "modalClass", + "modalStyle", + "hasPanels" + ], attributeBindings: ["data-keyboard"], dismissable: true, + title: null, + subtitle: null, init() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/components/group-selector.js.es6 b/app/assets/javascripts/discourse/components/group-selector.js.es6 index 8746b01fd34..dc3db235679 100644 --- a/app/assets/javascripts/discourse/components/group-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/group-selector.js.es6 @@ -30,6 +30,7 @@ export default Ember.Component.extend({ ? [] : [groupNames], single: this.get("single"), + fullWidthWrap: this.get("fullWidthWrap"), updateData: opts && opts.updateData ? opts.updateData : false, onChangeItems: items => { selectedGroups = items; diff --git a/app/assets/javascripts/discourse/controllers/invite.js.es6 b/app/assets/javascripts/discourse/components/invite-panel.js.es6 similarity index 66% rename from app/assets/javascripts/discourse/controllers/invite.js.es6 rename to app/assets/javascripts/discourse/components/invite-panel.js.es6 index b6144dd7b0e..2f0fc235393 100644 --- a/app/assets/javascripts/discourse/controllers/invite.js.es6 +++ b/app/assets/javascripts/discourse/components/invite-panel.js.es6 @@ -1,34 +1,30 @@ -import ModalFunctionality from "discourse/mixins/modal-functionality"; import { emailValid } from "discourse/lib/utilities"; import computed from "ember-addons/ember-computed-decorators"; import Group from "discourse/models/group"; import Invite from "discourse/models/invite"; +import { i18n } from "discourse/lib/computed"; -export default Ember.Controller.extend(ModalFunctionality, { - userInvitedShow: Ember.inject.controller("user-invited-show"), +export default Ember.Component.extend({ + tagName: null, - // If this isn't defined, it will proxy to the user model on the preferences + inviteModel: Ember.computed.alias("panel.model.inviteModel"), + userInvitedShow: Ember.computed.alias("panel.model.userInvitedShow"), + + // If this isn't defined, it will proxy to the user topic on the preferences // page which is wrong. emailOrUsername: null, hasCustomMessage: false, + hasCustomMessage: false, customMessage: null, inviteIcon: "envelope", invitingExistingUserToTopic: false, - @computed("isMessage", "invitingToTopic") - title(isMessage, invitingToTopic) { - if (isMessage) { - return "topic.invite_private.title"; - } else if (invitingToTopic) { - return "topic.invite_reply.title"; - } else { - return "user.invited.create"; - } - }, + isAdmin: Ember.computed.alias("currentUser.admin"), - @computed - isAdmin() { - return this.currentUser.admin; + willDestroyElement() { + this._super(...arguments); + + this.reset(); }, @computed( @@ -36,9 +32,9 @@ export default Ember.Controller.extend(ModalFunctionality, { "emailOrUsername", "invitingToTopic", "isPrivateTopic", - "model.groupNames", - "model.saving", - "model.details.can_invite_to" + "topic.groupNames", + "topic.saving", + "topic.details.can_invite_to" ) disabled( isAdmin, @@ -51,26 +47,39 @@ export default Ember.Controller.extend(ModalFunctionality, { ) { if (saving) return true; if (Ember.isEmpty(emailOrUsername)) return true; + const emailTrimmed = emailOrUsername.trim(); // when inviting to forum, email must be valid - if (!invitingToTopic && !emailValid(emailTrimmed)) return true; - // normal users (not admin) can't invite users to private topic via email - if (!isAdmin && isPrivateTopic && emailValid(emailTrimmed)) return true; - // when inviting to private topic via email, group name must be specified - if (isPrivateTopic && Ember.isEmpty(groupNames) && emailValid(emailTrimmed)) + if (!invitingToTopic && !emailValid(emailTrimmed)) { return true; + } + + // normal users (not admin) can't invite users to private topic via email + if (!isAdmin && isPrivateTopic && emailValid(emailTrimmed)) { + return true; + } + + // when inviting to private topic via email, group name must be specified + if ( + isPrivateTopic && + Ember.isEmpty(groupNames) && + emailValid(emailTrimmed) + ) { + return true; + } if (can_invite_to) return false; + return false; }, @computed( "isAdmin", "emailOrUsername", - "model.saving", + "inviteModel.saving", "isPrivateTopic", - "model.groupNames", + "inviteModel.groupNames", "hasCustomMessage" ) disabledCopyLink( @@ -84,54 +93,65 @@ export default Ember.Controller.extend(ModalFunctionality, { if (hasCustomMessage) return true; if (saving) return true; if (Ember.isEmpty(emailOrUsername)) return true; + const email = emailOrUsername.trim(); + // email must be valid - if (!emailValid(email)) return true; - // normal users (not admin) can't invite users to private topic via email - if (!isAdmin && isPrivateTopic && emailValid(email)) return true; - // when inviting to private topic via email, group name must be specified - if (isPrivateTopic && Ember.isEmpty(groupNames) && emailValid(email)) + if (!emailValid(email)) { return true; + } + + // normal users (not admin) can't invite users to private topic via email + if (!isAdmin && isPrivateTopic && emailValid(email)) { + return true; + } + + // when inviting to private topic via email, group name must be specified + if (isPrivateTopic && Ember.isEmpty(groupNames) && emailValid(email)) { + return true; + } + return false; }, - @computed("model.saving") + @computed("inviteModel.saving") buttonTitle(saving) { return saving ? "topic.inviting" : "topic.invite_reply.action"; }, - // We are inviting to a topic if the model isn't the current user. + // We are inviting to a topic if the topic isn't the current user. // The current user would mean we are inviting to the forum in general. - @computed("model") - invitingToTopic(model) { - return model !== this.currentUser; + @computed("inviteModel") + invitingToTopic(inviteModel) { + return inviteModel !== this.currentUser; }, - @computed("model", "model.details.can_invite_via_email") - canInviteViaEmail(model, can_invite_via_email) { - return this.get("model") === this.currentUser ? true : can_invite_via_email; + @computed("inviteModel", "inviteModel.details.can_invite_via_email") + canInviteViaEmail(inviteModel, canInviteViaEmail) { + return this.get("inviteModel") === this.currentUser + ? true + : canInviteViaEmail; }, - @computed("isMessage", "canInviteViaEmail") - showCopyInviteButton(isMessage, canInviteViaEmail) { - return canInviteViaEmail && !isMessage; + @computed("isPM", "canInviteViaEmail") + showCopyInviteButton(isPM, canInviteViaEmail) { + return canInviteViaEmail && !isPM; }, - topicId: Ember.computed.alias("model.id"), + topicId: Ember.computed.alias("inviteModel.id"), - // Is Private Topic? (i.e. visible only to specific group members) + // eg: visible only to specific group members isPrivateTopic: Ember.computed.and( "invitingToTopic", - "model.category.read_restricted" + "inviteModel.category.read_restricted" ), - // Is Private Message? - isMessage: Ember.computed.equal("model.archetype", "private_message"), + isPM: Ember.computed.equal("inviteModel.archetype", "private_message"), - // Allow Existing Members? (username autocomplete) + // scope to allowed usernames allowExistingMembers: Ember.computed.alias("invitingToTopic"), - @computed("isAdmin", "model.group_users") + @computed("isAdmin", "inviteModel.group_users") isGroupOwnerOrAdmin(isAdmin, groupUsers) { return ( isAdmin || (groupUsers && groupUsers.some(groupUser => groupUser.owner)) @@ -143,7 +163,7 @@ export default Ember.Controller.extend(ModalFunctionality, { "isGroupOwnerOrAdmin", "emailOrUsername", "isPrivateTopic", - "isMessage", + "isPM", "invitingToTopic", "canInviteViaEmail" ) @@ -151,14 +171,14 @@ export default Ember.Controller.extend(ModalFunctionality, { isGroupOwnerOrAdmin, emailOrUsername, isPrivateTopic, - isMessage, + isPM, invitingToTopic, canInviteViaEmail ) { return ( isGroupOwnerOrAdmin && canInviteViaEmail && - !isMessage && + !isPM && (emailValid(emailOrUsername) || isPrivateTopic || !invitingToTopic) ); }, @@ -166,13 +186,14 @@ export default Ember.Controller.extend(ModalFunctionality, { @computed("emailOrUsername") showCustomMessage(emailOrUsername) { return ( - this.get("model") === this.currentUser || emailValid(emailOrUsername) + this.get("inviteModel") === this.currentUser || + emailValid(emailOrUsername) ); }, // Instructional text for the modal. @computed( - "isMessage", + "isPM", "invitingToTopic", "emailOrUsername", "isPrivateTopic", @@ -180,7 +201,7 @@ export default Ember.Controller.extend(ModalFunctionality, { "canInviteViaEmail" ) inviteInstructions( - isMessage, + isPM, invitingToTopic, emailOrUsername, isPrivateTopic, @@ -190,7 +211,7 @@ export default Ember.Controller.extend(ModalFunctionality, { if (!canInviteViaEmail) { // can't invite via email, only existing users return I18n.t("topic.invite_reply.sso_enabled"); - } else if (isMessage) { + } else if (isPM) { // inviting to a message return I18n.t("topic.invite_private.email_or_username"); } else if (invitingToTopic) { @@ -222,14 +243,14 @@ export default Ember.Controller.extend(ModalFunctionality, { }, groupFinder(term) { - return Group.findAll({ term: term, ignore_automatic: true }); + return Group.findAll({ term, ignore_automatic: true }); }, - @computed("isMessage", "emailOrUsername", "invitingExistingUserToTopic") - successMessage(isMessage, emailOrUsername, invitingExistingUserToTopic) { + @computed("isPM", "emailOrUsername", "invitingExistingUserToTopic") + successMessage(isPM, emailOrUsername, invitingExistingUserToTopic) { if (this.get("hasGroups")) { return I18n.t("topic.invite_private.success_group"); - } else if (isMessage) { + } else if (isPM) { return I18n.t("topic.invite_private.success"); } else if (invitingExistingUserToTopic) { return I18n.t("topic.invite_reply.success_existing_email", { @@ -242,9 +263,9 @@ export default Ember.Controller.extend(ModalFunctionality, { } }, - @computed("isMessage") - errorMessage(isMessage) { - return isMessage + @computed("isPM") + errorMessage(isPM) { + return isPM ? I18n.t("topic.invite_private.error") : I18n.t("topic.invite_reply.error"); }, @@ -256,18 +277,18 @@ export default Ember.Controller.extend(ModalFunctionality, { : "topic.invite_reply.username_placeholder"; }, - @computed - customMessagePlaceholder() { - return I18n.t("invite.custom_message_placeholder"); - }, + customMessagePlaceholder: i18n("invite.custom_message_placeholder"), // Reset the modal to allow a new user to be invited. reset() { - this.set("emailOrUsername", null); - this.set("hasCustomMessage", false); - this.set("customMessage", null); - this.set("invitingExistingUserToTopic", false); - this.get("model").setProperties({ + this.setProperties({ + emailOrUsername: null, + hasCustomMessage: false, + customMessage: null, + invitingExistingUserToTopic: false + }); + + this.get("inviteModel").setProperties({ groupNames: null, error: false, saving: false, @@ -278,24 +299,23 @@ export default Ember.Controller.extend(ModalFunctionality, { actions: { createInvite() { - const self = this; if (this.get("disabled")) { return; } - const groupNames = this.get("model.groupNames"), - userInvitedController = this.get("userInvitedShow"), - model = this.get("model"); + const groupNames = this.get("inviteModel.groupNames"); + const userInvitedController = this.get("userInvitedShow"); + const model = this.get("inviteModel"); model.setProperties({ saving: true, error: false }); const onerror = e => { if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { - self.set("errorMessage", e.jqXHR.responseJSON.errors[0]); + this.set("errorMessage", e.jqXHR.responseJSON.errors[0]); } else { - self.set( + this.set( "errorMessage", - self.get("isMessage") + this.get("isPM") ? I18n.t("topic.invite_private.error") : I18n.t("topic.invite_reply.error") ); @@ -304,18 +324,18 @@ export default Ember.Controller.extend(ModalFunctionality, { }; if (this.get("hasGroups")) { - return this.get("model") + return this.get("inviteModel") .createGroupInvite(this.get("emailOrUsername").trim()) .then(data => { model.setProperties({ saving: false, finished: true }); - this.get("model.details.allowed_groups").pushObject( + this.get("inviteModel.details.allowed_groups").pushObject( Ember.Object.create(data.group) ); this.appEvents.trigger("post-stream:refresh"); }) .catch(onerror); } else { - return this.get("model") + return this.get("inviteModel") .createInvite( this.get("emailOrUsername").trim(), groupNames, @@ -323,19 +343,18 @@ export default Ember.Controller.extend(ModalFunctionality, { ) .then(result => { model.setProperties({ saving: false, finished: true }); - if (!this.get("invitingToTopic")) { + if (!this.get("invitingToTopic") && userInvitedController) { Invite.findInvitedBy( this.currentUser, userInvitedController.get("filter") - ).then(invite_model => { - userInvitedController.set("model", invite_model); - userInvitedController.set( - "totalInvites", - invite_model.invites.length - ); + ).then(inviteModel => { + userInvitedController.setProperties({ + model: inviteModel, + totalInvites: inviteModel.invites.length + }); }); - } else if (this.get("isMessage") && result && result.user) { - this.get("model.details.allowed_users").pushObject( + } else if (this.get("isPM") && result && result.user) { + this.get("inviteModel.details.allowed_users").pushObject( Ember.Object.create(result.user) ); this.appEvents.trigger("post-stream:refresh"); @@ -353,24 +372,21 @@ export default Ember.Controller.extend(ModalFunctionality, { }, generateInvitelink() { - const self = this; - if (this.get("disabled")) { return; } - const groupNames = this.get("model.groupNames"), - userInvitedController = this.get("userInvitedShow"), - model = this.get("model"); - - var topicId = null; - if (this.get("invitingToTopic")) { - topicId = this.get("model.id"); - } - + const groupNames = this.get("inviteModel.groupNames"); + const userInvitedController = this.get("userInvitedShow"); + const model = this.get("inviteModel"); model.setProperties({ saving: true, error: false }); - return this.get("model") + let topicId; + if (this.get("invitingToTopic")) { + topicId = this.get("inviteModel.id"); + } + + return model .generateInviteLink( this.get("emailOrUsername").trim(), groupNames, @@ -382,24 +398,26 @@ export default Ember.Controller.extend(ModalFunctionality, { finished: true, inviteLink: result }); - Invite.findInvitedBy( - this.currentUser, - userInvitedController.get("filter") - ).then(invite_model => { - userInvitedController.set("model", invite_model); - userInvitedController.set( - "totalInvites", - invite_model.invites.length - ); - }); + + if (userInvitedController) { + Invite.findInvitedBy( + this.currentUser, + userInvitedController.get("filter") + ).then(inviteModel => { + userInvitedController.setProperties({ + model: inviteModel, + totalInvites: inviteModel.invites.length + }); + }); + } }) - .catch(function(e) { + .catch(e => { if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { - self.set("errorMessage", e.jqXHR.responseJSON.errors[0]); + this.set("errorMessage", e.jqXHR.responseJSON.errors[0]); } else { - self.set( + this.set( "errorMessage", - self.get("isMessage") + this.get("isPM") ? I18n.t("topic.invite_private.error") : I18n.t("topic.invite_reply.error") ); @@ -411,7 +429,7 @@ export default Ember.Controller.extend(ModalFunctionality, { showCustomMessageBox() { this.toggleProperty("hasCustomMessage"); if (this.get("hasCustomMessage")) { - if (this.get("model") === this.currentUser) { + if (this.get("inviteModel") === this.currentUser) { this.set( "customMessage", I18n.t("invite.custom_message_template_forum") diff --git a/app/assets/javascripts/discourse/components/modal-panel.js.es6 b/app/assets/javascripts/discourse/components/modal-panel.js.es6 new file mode 100644 index 00000000000..b441457a7dd --- /dev/null +++ b/app/assets/javascripts/discourse/components/modal-panel.js.es6 @@ -0,0 +1,11 @@ +import { fmt } from "discourse/lib/computed"; + +export default Ember.Component.extend({ + panel: null, + + panelComponent: fmt("panel.id", "%@-panel"), + + classNameBindings: ["panel.id"], + + classNames: ["modal-panel"] +}); diff --git a/app/assets/javascripts/discourse/components/modal-tab.js.es6 b/app/assets/javascripts/discourse/components/modal-tab.js.es6 new file mode 100644 index 00000000000..275581a2936 --- /dev/null +++ b/app/assets/javascripts/discourse/components/modal-tab.js.es6 @@ -0,0 +1,17 @@ +import { propertyEqual } from "discourse/lib/computed"; + +export default Ember.Component.extend({ + tagName: "li", + classNames: ["modal-tab"], + panel: null, + selectedPanel: null, + panelsLength: null, + classNameBindings: ["isActive", "singleTab", "panel.id"], + singleTab: Ember.computed.equal("panelsLength", 1), + title: Ember.computed.alias("panel.title"), + isActive: propertyEqual("panel.id", "selectedPanel.id"), + + click() { + this.onSelectPanel(this.get("panel")); + } +}); diff --git a/app/assets/javascripts/discourse/components/share-button.js.es6 b/app/assets/javascripts/discourse/components/share-button.js.es6 deleted file mode 100644 index 958f821ce6a..00000000000 --- a/app/assets/javascripts/discourse/components/share-button.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -import Button from "discourse/components/d-button"; - -export default Button.extend({ - classNames: ["btn-default", "share"], - icon: "link", - title: "topic.share.help", - label: "topic.share.title", - attributeBindings: ["url:data-share-url"], - - click() { - return true; - } -}); diff --git a/app/assets/javascripts/discourse/components/share-panel.js.es6 b/app/assets/javascripts/discourse/components/share-panel.js.es6 new file mode 100644 index 00000000000..2ac3a16cfe1 --- /dev/null +++ b/app/assets/javascripts/discourse/components/share-panel.js.es6 @@ -0,0 +1,105 @@ +import { escapeExpression } from "discourse/lib/utilities"; +import { longDateNoYear } from "discourse/lib/formatter"; +import { default as computed } from "ember-addons/ember-computed-decorators"; +import Sharing from "discourse/lib/sharing"; + +export default Ember.Component.extend({ + tagName: null, + + date: Ember.computed.alias("panel.model.date"), + type: Ember.computed.alias("panel.model.type"), + postNumber: Ember.computed.alias("panel.model.postNumber"), + postId: Ember.computed.alias("panel.model.postId"), + topic: Ember.computed.alias("panel.model.topic"), + + @computed + sources() { + return Sharing.activeSources(this.siteSettings.share_links); + }, + + @computed("date") + postDate(date) { + return date ? longDateNoYear(new Date(date)) : null; + }, + + @computed("type", "postNumber", "postDate", "topic.title") + shareTitle(type, postNumber, postDate, topicTitle) { + topicTitle = escapeExpression(topicTitle); + + if (type === "topic") { + return I18n.t("share.topic", { topicTitle }); + } + if (postNumber) { + return I18n.t("share.post", { postNumber, postDate }); + } + return I18n.t("share.topic", { topicTitle }); + }, + + @computed("topic.shareUrl") + shareUrl(shareUrl) { + if (Ember.isEmpty(shareUrl)) { + return; + } + + // Relative urls + if (shareUrl.indexOf("/") === 0) { + const location = window.location; + shareUrl = `${location.protocol}//${location.host}${shareUrl}`; + } + + return encodeURI(shareUrl); + }, + + didInsertElement() { + this._super(...arguments); + + const shareUrl = this.get("shareUrl"); + const $linkInput = this.$(".topic-share-url"); + const $linkForTouch = this.$(".topic-share-url-for-touch a"); + + Ember.run.schedule("afterRender", () => { + if (!this.capabilities.touch) { + $linkForTouch.parent().remove(); + + $linkInput + .val(shareUrl) + .select() + .focus(); + } else { + $linkInput.remove(); + + $linkForTouch.attr("href", shareUrl).text(shareUrl); + + const range = window.document.createRange(); + range.selectNode($linkForTouch[0]); + window.getSelection().addRange(range); + } + }); + }, + + actions: { + share(source) { + const url = source.generateUrl( + this.get("shareUrl"), + this.get("topic.title") + ); + const options = { + menubar: "no", + toolbar: "no", + resizable: "yes", + scrollbars: "yes", + width: 600, + height: source.popupHeight || 315 + }; + const stringOptions = Object.keys(options) + .map(k => `${k}=${options[k]}`) + .join(","); + + if (source.shouldOpenInPopup) { + window.open(url, "", stringOptions); + } else { + window.open(url, "_blank"); + } + } + } +}); diff --git a/app/assets/javascripts/discourse/components/share-popup.js.es6 b/app/assets/javascripts/discourse/components/share-popup.js.es6 deleted file mode 100644 index 29f3f482ce9..00000000000 --- a/app/assets/javascripts/discourse/components/share-popup.js.es6 +++ /dev/null @@ -1,195 +0,0 @@ -import { wantsNewWindow } from "discourse/lib/intercept-click"; -import { longDateNoYear } from "discourse/lib/formatter"; -import computed from "ember-addons/ember-computed-decorators"; -import Sharing from "discourse/lib/sharing"; - -export default Ember.Component.extend({ - elementId: "share-link", - classNameBindings: ["visible"], - link: null, - visible: null, - - @computed - sources() { - return Sharing.activeSources(this.siteSettings.share_links); - }, - - @computed("type", "postNumber") - shareTitle(type, postNumber) { - if (type === "topic") { - return I18n.t("share.topic"); - } - if (postNumber) { - return I18n.t("share.post", { postNumber }); - } - return I18n.t("share.topic"); - }, - - @computed("date") - displayDate(date) { - return longDateNoYear(new Date(date)); - }, - - _focusUrl() { - const link = this.get("link"); - if (!this.capabilities.touch) { - const $linkInput = $("#share-link input"); - $linkInput.val(link); - - // Wait for the fade-in transition to finish before selecting the link: - window.setTimeout(() => $linkInput.select().focus(), 160); - } else { - const $linkForTouch = $("#share-link .share-for-touch a"); - $linkForTouch.attr("href", link); - $linkForTouch.text(link); - const range = window.document.createRange(); - range.selectNode($linkForTouch[0]); - window.getSelection().addRange(range); - } - }, - - _showUrl($target, url) { - const $currentTargetOffset = $target.offset(); - const $this = this.$(); - - if (Ember.isEmpty(url)) { - return; - } - - // Relative urls - if (url.indexOf("/") === 0) { - url = window.location.protocol + "//" + window.location.host + url; - } - - const shareLinkWidth = $this.width(); - let x = $currentTargetOffset.left - shareLinkWidth / 2; - if (x < 25) { - x = 25; - } - if (x + shareLinkWidth > $(window).width()) { - x -= shareLinkWidth / 2; - } - - const header = $(".d-header"); - let y = $currentTargetOffset.top - ($this.height() + 20); - if (y < header.offset().top + header.height()) { - y = $currentTargetOffset.top + 10; - } - - $this.css({ top: "" + y + "px" }); - - if (!this.site.mobileView) { - $this.css({ left: "" + x + "px" }); - } - this.set("link", encodeURI(url)); - this.set("visible", true); - - Ember.run.scheduleOnce("afterRender", this, this._focusUrl); - }, - - _webShare(url) { - // We can pass title and text too, but most share targets do their own oneboxing - return navigator.share({ url }); - }, - - didInsertElement() { - this._super(...arguments); - - const $html = $("html"); - $html.on("mousedown.outside-share-link", e => { - // Use mousedown instead of click so this event is handled before routing occurs when a - // link is clicked (which is a click event) while the share dialog is showing. - if (this.$().has(e.target).length !== 0) { - return; - } - this.send("close"); - return true; - }); - - $html.on( - "click.discourse-share-link", - "button[data-share-url], .post-info .post-date[data-share-url]", - e => { - // if they want to open in a new tab, let it so - if (wantsNewWindow(e)) { - return true; - } - - e.preventDefault(); - - const $currentTarget = $(e.currentTarget); - const url = $currentTarget.data("share-url"); - const postNumber = $currentTarget.data("post-number"); - const postId = $currentTarget.closest("article").data("post-id"); - const date = $currentTarget.children().data("time"); - - this.setProperties({ postNumber, date, postId }); - - // use native webshare only when the user clicks on the "chain" icon - // navigator.share needs HTTPS, returns undefined on HTTP - if (navigator.share && !$currentTarget.hasClass("post-date")) { - this._webShare(url).catch(() => { - // if navigator fails for unexpected reason fallback to popup - this._showUrl($currentTarget, url); - }); - } else { - this._showUrl($currentTarget, url); - } - - return false; - } - ); - - $html.on("keydown.share-view", e => { - if (e.keyCode === 27) { - this.send("close"); - } - }); - - this.appEvents.on("share:url", (url, $target) => - this._showUrl($target, url) - ); - }, - - willDestroyElement() { - this._super(...arguments); - $("html") - .off("click.discourse-share-link") - .off("mousedown.outside-share-link") - .off("keydown.share-view"); - }, - - actions: { - replyAsNewTopic() { - const postStream = this.get("topic.postStream"); - const postId = - this.get("postId") || postStream.findPostIdForPostNumber(1); - const post = postStream.findLoadedPost(postId); - this.get("replyAsNewTopic")(post); - this.send("close"); - }, - - close() { - this.setProperties({ - link: null, - postNumber: null, - postId: null, - visible: false - }); - }, - - share(source) { - const url = source.generateUrl(this.get("link"), this.get("topic.title")); - if (source.shouldOpenInPopup) { - window.open( - url, - "", - "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=600,height=" + - (source.popupHeight || 315) - ); - } else { - window.open(url, "_blank"); - } - } - } -}); diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index e07f06ccf4a..a25b3f65e9c 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -34,7 +34,8 @@ export default TextField.extend({ single = bool("single"), allowAny = bool("allowAny"), disabled = bool("disabled"), - disallowEmails = bool("disallowEmails"); + disallowEmails = bool("disallowEmails"), + fullWidthWrap = bool("fullWidthWrap"); function excludedUsernames() { // hack works around some issues with allowAny eventing @@ -54,6 +55,7 @@ export default TextField.extend({ single: single, allowAny: allowAny, updateData: opts && opts.updateData ? opts.updateData : false, + fullWidthWrap, dataSource(term) { var results = userSearch({ diff --git a/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6 index 9c3ba80f809..812ac3f7d4c 100644 --- a/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6 +++ b/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6 @@ -1,3 +1,5 @@ +import showModal from "discourse/lib/show-modal"; +import { share } from "discourse/lib/pwa-utils"; import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-button"; export default { @@ -5,25 +7,100 @@ export default { initialize() { registerTopicFooterButton({ - id: "share", + id: "native-share", icon: "link", priority: 999, label: "topic.share.title", title: "topic.share.help", action() { - this.appEvents.trigger( - "share:url", - this.get("topic.shareUrl"), - $("#topic-footer-buttons") + share({ url: this.get("topic.shareUrl") }).catch(() => + showModal("share-and-invite", { + modalClass: "share-and-invite", + panels: [ + { + id: "share", + title: "topic.share.title", + model: { topic: this.get("topic") } + } + ] + }) ); }, + dropdown: true, + classNames: ["native-share"], + dependentKeys: ["topic.shareUrl", "topic.isPrivateMessage"], + displayed() { + return window.navigator.share; + } + }); + + registerTopicFooterButton({ + id: "share-and-invite", + icon: "link", + priority: 999, + label: "topic.share.title", + title: "topic.share.help", + action() { + const modal = () => { + const panels = [ + { + id: "share", + title: "topic.share.extended_title", + model: { + topic: this.get("topic") + } + } + ]; + + if (this.get("canInviteTo") && !this.get("inviteDisabled")) { + let invitePanelTitle; + + if (this.get("isPM")) { + invitePanelTitle = "topic.invite_private.title"; + } else if (this.get("invitingToTopic")) { + invitePanelTitle = "topic.invite_reply.title"; + } else { + invitePanelTitle = "user.invited.create"; + } + + panels.push({ + id: "invite", + title: invitePanelTitle, + model: { + inviteModel: this.get("topic") + } + }); + } + + showModal("share-and-invite", { + model: this.get("topic"), + modalClass: "share-and-invite", + panels + }); + }; + + if (window.navigator.share) { + window.navigator + .share({ url: this.get("topic.shareUrl") }) + .catch(() => modal()); + } else { + modal(); + } + }, dropdown() { return this.site.mobileView; }, - classNames: ["share"], - dependentKeys: ["topic.shareUrl", "topic.isPrivateMessage"], + classNames: ["share-and-invite"], + dependentKeys: [ + "topic.shareUrl", + "topic.isPrivateMessage", + "canInviteTo", + "inviteDisabled", + "isPM", + "invitingToTopic" + ], displayed() { - return !this.get("topic.isPrivateMessage"); + return !(this.site.mobileView && window.navigator.share); } }); @@ -47,26 +124,6 @@ export default { } }); - registerTopicFooterButton({ - id: "invite", - icon: "users", - priority: 997, - label: "topic.invite_reply.title", - title: "topic.invite_reply.help", - action: "showInvite", - dropdown() { - return this.site.mobileView; - }, - classNames: ["invite-topic"], - dependentKeys: ["canInviteTo", "inviteDisabled"], - displayed() { - return this.get("canInviteTo"); - }, - disabled() { - return this.get("inviteDisabled"); - } - }); - registerTopicFooterButton({ dependentKeys: ["topic.bookmarked", "topic.isPrivateMessage"], id: "bookmark", diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 939eef6a90e..a75568731f6 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -201,7 +201,10 @@ export default function(options) { wrap = this.wrap( "
" ).parent(); - wrap.width(width); + + if (!options.fullWidthWrap) { + wrap.width(width); + } } if (options.single && !options.width) { diff --git a/app/assets/javascripts/discourse/lib/pwa-utils.js.es6 b/app/assets/javascripts/discourse/lib/pwa-utils.js.es6 new file mode 100644 index 00000000000..fc4d896e688 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/pwa-utils.js.es6 @@ -0,0 +1,12 @@ +export function share(data) { + return new Ember.RSVP.Promise((resolve, reject) => { + if (window.location.protocol === "https:" && window.navigator.share) { + window.navigator + .share(data) + .catch(reject) + .then(resolve); + } else { + reject(); + } + }); +} diff --git a/app/assets/javascripts/discourse/lib/show-modal.js.es6 b/app/assets/javascripts/discourse/lib/show-modal.js.es6 index 675a0e37f84..3fa2b31fc11 100644 --- a/app/assets/javascripts/discourse/lib/show-modal.js.es6 +++ b/app/assets/javascripts/discourse/lib/show-modal.js.es6 @@ -64,6 +64,24 @@ export default function(name, opts) { modalController.set("title", I18n.t(opts.title)); } + if (opts.panels) { + modalController.setProperties({ + panels: opts.panels, + selectedPanel: opts.panels[0] + }); + + if (controller.actions.onSelectPanel) { + modalController.set("onSelectPanel", controller.actions.onSelectPanel); + } + + modalController.set( + "modalClass", + `${modalController.get("modalClass")} has-tabs` + ); + } else { + modalController.setProperties({ panels: [], selectedPanel: null }); + } + controller.set("modal", modalController); const model = opts.model; if (model) { diff --git a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 index ac024919eea..0caef439430 100644 --- a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 +++ b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 @@ -16,6 +16,11 @@ export default Ember.Mixin.create({ actions: { closeModal() { this.get("modal").send("closeModal"); + this.set("panels", []); + }, + + onSelectPanel(panel) { + this.set("selectedPanel", panel); } } }); diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index c6e4cf42080..78673ca69c4 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -92,11 +92,6 @@ const TopicRoute = Discourse.Route.extend({ this.controllerFor("feature_topic").reset(); }, - showInvite() { - showModal("invite", { model: this.modelFor("topic") }); - this.controllerFor("invite").reset(); - }, - showHistory(model, revision) { showModal("history", { model }); const historyController = this.controllerFor("history"); diff --git a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 index 31005cd3d97..d9e2d4ba715 100644 --- a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 @@ -30,8 +30,19 @@ export default Discourse.Route.extend({ actions: { showInvite() { - showModal("invite", { model: this.currentUser }); - this.controllerFor("invite").reset(); + showModal("share-and-invite", { + modalClass: "share-and-invite", + panels: [ + { + id: "invite", + title: "user.invited.create", + model: { + inviteModel: this.currentUser, + userInvitedShow: this.controllerFor("user-invited-show") + } + } + ] + }); } } }); diff --git a/app/assets/javascripts/discourse/templates/components/d-modal.hbs b/app/assets/javascripts/discourse/templates/components/d-modal.hbs index 374868f5e8c..4e7cd0dca26 100644 --- a/app/assets/javascripts/discourse/templates/components/d-modal.hbs +++ b/app/assets/javascripts/discourse/templates/components/d-modal.hbs @@ -10,13 +10,25 @@
{{/if}} -
-

{{title}}

+ {{#if panels}} + + {{else}} +
+

{{title}}

- {{#if subtitle}} -

{{subtitle}}

- {{/if}} -
+ {{#if subtitle}} +

{{subtitle}}

+ {{/if}} +
+ {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/invite-panel.hbs b/app/assets/javascripts/discourse/templates/components/invite-panel.hbs new file mode 100644 index 00000000000..3b14b5bf793 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/invite-panel.hbs @@ -0,0 +1,93 @@ +{{#if inviteModel.error}} +
+ +
+ {{{errorMessage}}} +
+
+{{/if}} + +
+ {{#if inviteModel.finished}} + {{#if inviteModel.inviteLink}} + {{generated-invite-link link=inviteModel.inviteLink email=emailOrUsername}} + {{else}} +
+ {{{successMessage}}} +
+ {{/if}} + {{else}} +
+ + {{#if allowExistingMembers}} + {{user-selector + fullWidthWrap=true + single=true + allowAny=true + excludeCurrentUser=true + includeMessageableGroups=isMessage + hasGroups=hasGroups + usernames=emailOrUsername + placeholderKey=placeholderKey + class="invite-user-input" + autocomplete="off"}} + {{else}} + {{text-field + class="email-or-username-input" + value=emailOrUsername + placeholderKey="topic.invite_reply.email_placeholder"}} + {{/if}} +
+ + {{#if showGroups}} +
+ + {{group-selector + fullWidthWrap=true + groupFinder=groupFinder + groupNames=inviteModel.groupNames + placeholderKey="topic.invite_private.group_name"}} +
+ {{/if}} + + {{#if showCustomMessage}} +
+ + {{#if hasCustomMessage}} + {{textarea value=customMessage placeholder=customMessagePlaceholder}} + {{/if}} +
+ {{/if}} + {{/if}} +
+ + diff --git a/app/assets/javascripts/discourse/templates/components/modal-panel.hbs b/app/assets/javascripts/discourse/templates/components/modal-panel.hbs new file mode 100644 index 00000000000..52c0d9abda5 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/modal-panel.hbs @@ -0,0 +1 @@ +{{component panelComponent panel=panel close=(route-action "closeModal")}} diff --git a/app/assets/javascripts/discourse/templates/components/modal-tab.hbs b/app/assets/javascripts/discourse/templates/components/modal-tab.hbs new file mode 100644 index 00000000000..c598795d8d8 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/modal-tab.hbs @@ -0,0 +1 @@ +{{i18n title}} diff --git a/app/assets/javascripts/discourse/templates/components/share-panel.hbs b/app/assets/javascripts/discourse/templates/components/share-panel.hbs new file mode 100644 index 00000000000..dd2e4f7af14 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/share-panel.hbs @@ -0,0 +1,14 @@ +
+

{{{shareTitle}}}

+
+ +
+ {{input value=shareUrl class="topic-share-url"}} + + +
+ {{#each sources as |source|}} + {{share-source source=source title=topic.title action=(action "share")}} + {{/each}} +
+
diff --git a/app/assets/javascripts/discourse/templates/components/share-popup.hbs b/app/assets/javascripts/discourse/templates/components/share-popup.hbs deleted file mode 100644 index 6b4adbe0ca5..00000000000 --- a/app/assets/javascripts/discourse/templates/components/share-popup.hbs +++ /dev/null @@ -1,28 +0,0 @@ -

{{shareTitle}}

- -{{#if date}} - {{displayDate}} -{{/if}} - -
- - -
- -{{#each sources as |s|}} - {{share-source source=s title=model.title action=(action "share")}} -{{/each}} - -{{#if topic.details.can_reply_as_new_topic}} -
- {{#if topic.isPrivateMessage}} - {{d-icon "plus"}}{{i18n 'user.new_private_message'}} - {{else}} - {{d-icon "plus"}}{{i18n 'topic.create'}} - {{/if}} -
-{{/if}} - - diff --git a/app/assets/javascripts/discourse/templates/modal.hbs b/app/assets/javascripts/discourse/templates/modal.hbs index 953578f6efe..d266b5fc1f0 100644 --- a/app/assets/javascripts/discourse/templates/modal.hbs +++ b/app/assets/javascripts/discourse/templates/modal.hbs @@ -2,10 +2,11 @@ modalClass=modalClass title=title subtitle=subtitle + panels=panels + selectedPanel=selectedPanel + onSelectPanel=onSelectPanel class="hidden" errors=errors closeModal=(route-action "closeModal")}} - {{outlet "modalBody"}} - {{/d-modal}} diff --git a/app/assets/javascripts/discourse/templates/modal/invite.hbs b/app/assets/javascripts/discourse/templates/modal/invite.hbs deleted file mode 100644 index d8f44663ea4..00000000000 --- a/app/assets/javascripts/discourse/templates/modal/invite.hbs +++ /dev/null @@ -1,52 +0,0 @@ -{{#d-modal-body id="invite-modal" title=title}} - {{#if model.error}} -
- - {{{errorMessage}}} -
- {{/if}} - {{#if model.finished}} - {{#if model.inviteLink}} - {{generated-invite-link link=model.inviteLink email=emailOrUsername}} - {{else}} - {{{successMessage}}} - {{/if}} - {{else}} - - {{#if allowExistingMembers}} - {{user-selector - single=true - allowAny=true - excludeCurrentUser=true - includeMessageableGroups=isMessage - hasGroups=hasGroups - usernames=emailOrUsername - placeholderKey=placeholderKey - autocomplete="discourse"}} - {{else}} - {{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}} - {{/if}} - - {{#if showGroups}} - - {{group-selector groupFinder=groupFinder groupNames=model.groupNames placeholderKey="topic.invite_private.group_name"}} - {{/if}} - - {{#if showCustomMessage}} - - {{#if hasCustomMessage}}{{textarea value=customMessage placeholder=customMessagePlaceholder}}{{/if}} - {{/if}} - - {{/if}} -{{/d-modal-body}} - - diff --git a/app/assets/javascripts/discourse/templates/modal/share-and-invite.hbs b/app/assets/javascripts/discourse/templates/modal/share-and-invite.hbs new file mode 100644 index 00000000000..5ebdcfb42a4 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/share-and-invite.hbs @@ -0,0 +1,3 @@ +{{#d-modal-body}} + {{modal-panel panel=modal.selectedPanel}} +{{/d-modal-body}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 4a64ecf3856..ea74abed05e 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -192,7 +192,6 @@ toggleSummary=(action "toggleSummary") removeAllowedUser=(action "removeAllowedUser") removeAllowedGroup=(action "removeAllowedGroup") - showInvite=(route-action "showInvite") topVisibleChanged=(action "topVisibleChanged") currentPostChanged=(action "currentPostChanged") currentPostScrolled=(action "currentPostScrolled") @@ -264,11 +263,9 @@ convertToPrivateMessage=(action "convertToPrivateMessage") toggleBookmark=(action "toggleBookmark") showFlagTopic=(route-action "showFlagTopic") - showInvite=(route-action "showInvite") toggleArchiveMessage=(action "toggleArchiveMessage") editFirstPost=(action "editFirstPost") - replyToPost=(action "replyToPost") - }} + replyToPost=(action "replyToPost")}} {{else}} {{/if}} - {{share-popup topic=model replyAsNewTopic=(action "replyAsNewTopic")}} - {{#if embedQuoteButton}} {{quote-button quoteState=quoteState selectText=(action "selectText")}} {{/if}} diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index abc5d7a300c..b076c125e07 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -2,6 +2,7 @@ import PostCooked from "discourse/widgets/post-cooked"; import DecoratorHelper from "discourse/widgets/decorator-helper"; import { createWidget, applyDecorators } from "discourse/widgets/widget"; import { iconNode } from "discourse-common/lib/icon-library"; +import { share } from "discourse/lib/pwa-utils"; import { transformBasicPost } from "discourse/lib/transform-post"; import { postTransformCallbacks } from "discourse/widgets/post-stream"; import { h } from "virtual-dom"; @@ -13,6 +14,7 @@ import { formatUsername } from "discourse/lib/utilities"; import hbs from "discourse/widgets/hbs-compiler"; +import showModal from "discourse/lib/show-modal"; function transformWithCallbacks(post) { let transformed = transformBasicPost(post); @@ -219,6 +221,71 @@ function showReplyTab(attrs, siteSettings) { ); } +createWidget("post-date", { + tagName: "div.post-info.post-date", + + buildClasses(attrs) { + let classes = "post-date"; + + const lastWikiEdit = + attrs.wiki && attrs.lastWikiEdit && new Date(attrs.lastWikiEdit); + + if (lastWikiEdit) { + classes = `${classes} last-wiki-edit`; + } + + return classes; + }, + + html(attrs) { + return h( + "a", + { + attributes: { + class: "post-date", + "data-share-url": attrs.shareUrl, + "data-post-number": attrs.post_number + } + }, + dateNode(this._date(attrs)) + ); + }, + + _date(attrs) { + const lastWikiEdit = + attrs.wiki && attrs.lastWikiEdit && new Date(attrs.lastWikiEdit); + const createdAt = new Date(attrs.created_at); + return lastWikiEdit ? lastWikiEdit : createdAt; + }, + + click() { + const post = this.findAncestorModel(); + + const modalFallback = () => { + showModal("share-and-invite", { + modalClass: "share-and-invite", + panels: [ + { + id: "share", + title: "topic.share.extended_title", + model: { + postNumber: this.attrs.post_number, + shareUrl: this.attrs.shareUrl, + date: this._date(this.attrs), + postId: post.get("id"), + topic: post.get("topic") + } + } + ] + }); + }; + + // use native webshare when available + // navigator.share needs HTTPS, returns undefined on HTTP + share({ url: this.attrs.shareUrl }).catch(modalFallback); + } +}); + createWidget("post-meta-data", { tagName: "div.topic-meta-data", @@ -241,21 +308,6 @@ createWidget("post-meta-data", { ); } - const lastWikiEdit = - attrs.wiki && attrs.lastWikiEdit && new Date(attrs.lastWikiEdit); - const createdAt = new Date(attrs.created_at); - const date = lastWikiEdit ? dateNode(lastWikiEdit) : dateNode(createdAt); - const attributes = { - class: "post-date", - href: attrs.shareUrl, - "data-share-url": attrs.shareUrl, - "data-post-number": attrs.post_number - }; - - if (lastWikiEdit) { - attributes["class"] += " last-wiki-edit"; - } - if (attrs.via_email) { postInfo.push(this.attach("post-email-indicator", attrs)); } @@ -276,7 +328,7 @@ createWidget("post-meta-data", { postInfo.push(this.attach("reply-to-tab", attrs)); } - postInfo.push(h("div.post-info.post-date", h("a", { attributes }, date))); + postInfo.push(this.attach("post-date", attrs)); postInfo.push( h( diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 21b84a51a66..050d1c394b4 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -4,6 +4,7 @@ @import "common/foundation/base"; @import "common/foundation/mixins"; @import "common/foundation/variables"; +@import "common/foundation/spacing"; @import "common/select-kit/admin-agree-flag-dropdown"; @import "common/select-kit/admin-delete-flag-dropdown"; @import "common/select-kit/categories-admin-dropdown"; diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index e2911066dad..70f5dc997ea 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -27,9 +27,9 @@ .modal-header { display: flex; - align-items: flex-start; padding: 10px 15px; border-bottom: 1px solid $primary-low; + align-items: center; .title { h3 { @@ -42,6 +42,7 @@ } .modal-close { + align-self: flex-start; order: 2; margin-left: auto; } @@ -104,6 +105,7 @@ background-clip: padding-box; box-shadow: shadow("modal"); padding: 1px; + box-sizing: border-box; @media screen and (min-width: 475px) { min-width: 475px; @@ -617,7 +619,41 @@ } } -.modal-tab { - position: absolute; - width: 95%; +.modal:not(.has-tabs) { + .modal-tab { + position: absolute; + width: 95%; + } +} + +.modal { + &.has-tabs { + .modal-tabs { + display: inline-flex; + flex-wrap: wrap; + width: calc(100% - 20px); + flex: 1 0 auto; + margin: 0; + + .modal-tab { + list-style: none; + padding: s(1 2); + margin-right: s(1); + cursor: pointer; + + &.is-active { + color: $secondary; + background: $danger; + + &.single-tab { + color: $primary; + background: none; + padding: s(1 0); + font-weight: 700; + font-size: $font-up-3; + } + } + } + } + } } diff --git a/app/assets/stylesheets/common/base/share_link.scss b/app/assets/stylesheets/common/base/share_link.scss deleted file mode 100644 index 2f1b11b090a..00000000000 --- a/app/assets/stylesheets/common/base/share_link.scss +++ /dev/null @@ -1,86 +0,0 @@ -// styles that apply to the "share" popup when sharing a link to a post or topic - -#share-link { - position: absolute; - left: 20px; - z-index: z("dropdown"); - box-shadow: shadow("card"); - background-color: $secondary; - padding: 6px 10px 10px 10px; - width: 300px; - display: none; - &.visible { - display: block; - } - input[type="text"] { - width: 96%; - } - .share-for-touch .overflow-ellipsis { - clear: both; - } - .share-for-touch { - margin: 14px 0; - } - h3 { - font-size: $font-0; - } - .copy-text { - display: inline-block; - position: absolute; - margin: 5px 5px 5px 15px; - color: $success; - opacity: 1; - transition: opacity 0.25s; - font-size: $font-0; - &:not(.success) { - opacity: 0; - } - } - .social-link { - margin-left: 2px; - margin-right: 8px; - float: left; - font-size: $font-up-4; - } - .reply-as-new-topic { - float: left; - line-height: $line-height-large; - margin-left: 8px; - margin-top: 0.5em; - .fa { - margin-right: 5px; - } - } - .link { - margin-right: 2px; - float: right; - font-size: $font-up-3; - a { - color: dark-light-choose($primary-medium, $secondary-medium); - } - } - - h3 { - margin: 5px 0; - float: left; - } - - .date { - float: right; - margin: 5px; - color: dark-light-choose($primary-medium, $secondary-medium); - } - - input[type="text"] { - font-size: $font-up-1; - margin-bottom: 10px; - } -} - -.discourse-no-touch #share-link .share-for-touch { - display: none; -} - -.discourse-touch #share-link input[type="text"] { - display: none; -} diff --git a/app/assets/stylesheets/common/components/share-and-invite-modal.scss b/app/assets/stylesheets/common/components/share-and-invite-modal.scss new file mode 100644 index 00000000000..b5839dc0118 --- /dev/null +++ b/app/assets/stylesheets/common/components/share-and-invite-modal.scss @@ -0,0 +1,86 @@ +.share-and-invite.modal { + .modal-body { + max-width: 475px; + } +} + +.share-and-invite.modal .share.modal-panel { + .header { + display: flex; + flex-direction: row; + align-items: center; + + .title { + font-size: $font-0; + font-weight: normal; + margin-bottom: s(2); + } + } + + .body { + display: flex; + flex-direction: column; + + .topic-share-url { + width: 100%; + } + + .topic-share-url-for-touch { + width: 290px; + + @extend .overflow-ellipsis; + } + + .topic-share-url-for-touch, + .topic-share-url { + margin-bottom: s(2); + } + + .sources { + display: flex; + align-items: center; + flex-wrap: wrap; + flex-direction: row; + + .social-link { + font-size: $font-up-4; + } + } + } +} + +.share-and-invite.modal .invite.modal-panel { + .error-message, + .success-message { + margin-bottom: s(2); + } + + .body { + .invite-user-control, + .group-access-control, + .show-custom-message-control { + margin-bottom: s(4); + } + + .instructions { + margin-bottom: s(2); + } + + .email-or-username-input { + width: 100%; + } + } + + .footer { + display: flex; + align-items: center; + + .btn-primary { + margin-right: s(2); + + &:last-child { + margin-right: 0; + } + } + } +} diff --git a/app/assets/stylesheets/common/foundation/spacing.scss b/app/assets/stylesheets/common/foundation/spacing.scss new file mode 100644 index 00000000000..88ec00eaf8d --- /dev/null +++ b/app/assets/stylesheets/common/foundation/spacing.scss @@ -0,0 +1,17 @@ +$base-space: 4px; + +@function s( + $sizes: ( + 0 + ) +) { + $spaces: (); + @each $size in $sizes { + @if ($size == 0) { + // strip units from 0 values + @return $size / ($size * 0 + 1); + } + $spaces: append($spaces, ($size * $base-space)); + } + @return $spaces; +} diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 02fa91bd872..d1c1d1ae8f1 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -587,11 +587,6 @@ video { } } -#share-link { - width: 365px; - margin-left: -4px; -} - .post-select { float: right; margin-right: 20px; diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index d8dc8152707..7928653602a 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -42,7 +42,6 @@ .close { font-size: $font-up-4; - padding: 10px 15px 5px 5px; color: $primary; } diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index 5186aba5d62..69e58dbeee0 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -350,12 +350,6 @@ iframe { position: relative; } -#share-link { - width: 290px; - left: auto; - right: 4px; -} - .selected-posts { padding: 0.1em 0.7em; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c32cada067a..dcfb5454d63 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -135,12 +135,12 @@ en: next_month: "Next Month" placeholder: date share: - topic: "share a link to this topic" - post: "post #%{postNumber}" + topic: "Topic: %{topicTitle}" + post: "Post #%{postNumber}, %{postDate}" close: "close" - twitter: "share this link on Twitter" - facebook: "share this link on Facebook" - email: "send this link in an email" + twitter: "Share this link on Twitter" + facebook: "Share this link on Facebook" + email: "Send this link in an email" action_codes: public_topic: "made this topic public %{when}" @@ -1948,6 +1948,7 @@ en: share: title: "Share" + extended_title: "Share a link" help: "share a link to this topic" print: diff --git a/test/javascripts/acceptance/share-and-invite-desktop-test.js.es6 b/test/javascripts/acceptance/share-and-invite-desktop-test.js.es6 new file mode 100644 index 00000000000..137a8279a45 --- /dev/null +++ b/test/javascripts/acceptance/share-and-invite-desktop-test.js.es6 @@ -0,0 +1,88 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Share and Invite modal - desktop", { + loggedIn: true +}); + +QUnit.test("Topic footer button", async assert => { + await visit("/t/internationalization-localization/280"); + + assert.ok( + exists("#topic-footer-button-share-and-invite"), + "the button exists" + ); + + await click("#topic-footer-button-share-and-invite"); + + assert.ok(exists(".share-and-invite.modal"), "it shows the modal"); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share"), + "it shows the share tab" + ); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share.is-active"), + "it activates the share tab by default" + ); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.invite"), + "it shows the invite tab" + ); + + assert.equal( + find(".share-and-invite.modal .modal-panel.share .title").text(), + "Topic: Internationalization / localization", + "it shows the topic title" + ); + + assert.ok( + find(".share-and-invite.modal .modal-panel.share .topic-share-url") + .val() + .includes("/t/internationalization-localization/280?u=eviltrout"), + "it shows the topic sharing url" + ); + + assert.equal( + find(".share-and-invite.modal .social-link").length, + 3, + "it shows social sources" + ); + + await click(".share-and-invite.modal .modal-tab.invite"); + + assert.ok( + exists(".share-and-invite.modal .modal-panel.invite .send-invite:disabled"), + "send invite button is disabled" + ); + + assert.ok( + exists( + ".share-and-invite.modal .modal-panel.invite .generate-invite-link:disabled" + ), + "generate invite button is disabled" + ); +}); + +QUnit.test("Post date link", async assert => { + await visit("/t/internationalization-localization/280"); + await click("#post_2 .post-info.post-date a"); + + assert.ok(exists(".share-and-invite.modal"), "it shows the modal"); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share"), + "it shows the share tab" + ); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share.single-tab"), + "it shows only one tab" + ); + + assert.ok( + !exists(".share-and-invite.modal .modal-tab.invite"), + "it doesn’t show the invite tab" + ); +}); diff --git a/test/javascripts/acceptance/share-and-invite-mobile-test.js.es6 b/test/javascripts/acceptance/share-and-invite-mobile-test.js.es6 new file mode 100644 index 00000000000..f9e039ee9ce --- /dev/null +++ b/test/javascripts/acceptance/share-and-invite-mobile-test.js.es6 @@ -0,0 +1,77 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Share and Invite modal - mobile", { + loggedIn: true, + mobileView: true +}); + +QUnit.test("Topic footer mobile button", async assert => { + await visit("/t/internationalization-localization/280"); + + assert.ok( + !exists("#topic-footer-button-share-and-invite"), + "the button doesn’t exist" + ); + + const subject = selectKit(".topic-footer-mobile-dropdown"); + await subject.expand(); + await subject.selectRowByValue("share-and-invite"); + + assert.ok(exists(".share-and-invite.modal"), "it shows the modal"); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share"), + "it shows the share tab" + ); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share.is-active"), + "it activates the share tab by default" + ); + + assert.ok( + !exists(".share-and-invite.modal .modal-tab.invite"), + "it doesn’t show the invite tab" + ); + + assert.equal( + find(".share-and-invite.modal .modal-panel.share .title").text(), + "Topic: Internationalization / localization", + "it shows the topic title" + ); + + assert.ok( + find(".share-and-invite.modal .modal-panel.share .topic-share-url") + .val() + .includes("/t/internationalization-localization/280?u=eviltrout"), + "it shows the topic sharing url" + ); + + assert.equal( + find(".share-and-invite.modal .social-link").length, + 3, + "it shows social sources" + ); +}); + +QUnit.test("Post date link", async assert => { + await visit("/t/internationalization-localization/280"); + await click("#post_2 .post-info.post-date a"); + + assert.ok(exists(".share-and-invite.modal"), "it shows the modal"); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share"), + "it shows the share tab" + ); + + assert.ok( + exists(".share-and-invite.modal .modal-tab.share.single-tab"), + "it shows only one tab" + ); + + assert.ok( + !exists(".share-and-invite.modal .modal-tab.invite"), + "it doesn’t show the invite tab" + ); +}); diff --git a/test/javascripts/acceptance/topic-test.js.es6 b/test/javascripts/acceptance/topic-test.js.es6 index fc689253f1e..b63b5d12704 100644 --- a/test/javascripts/acceptance/topic-test.js.es6 +++ b/test/javascripts/acceptance/topic-test.js.es6 @@ -22,29 +22,6 @@ acceptance("Topic", { } }); -QUnit.test("Share Popup", async assert => { - await visit("/t/internationalization-localization/280"); - assert.ok(!exists("#share-link.visible"), "it is not visible"); - - await click("button[data-share-url]"); - assert.ok(exists("#share-link.visible"), "it shows the popup"); - - await click("#share-link .close-share"); - assert.ok(!exists("#share-link.visible"), "it closes the popup"); - - // TODO tgxworld This fails on Travis but we need to push the security fix out - // first. - // click('#topic-footer-buttons .btn.create'); - // fillIn('.d-editor-input', '

Click

'); - // - // click('#reply-control .btn.create'); - // click('h2 div[data-share-url]'); - // - // andThen(() => { - // ok(!exists('#share-link.visible'), 'it does not show the popup'); - // }); -}); - QUnit.test("Showing and hiding the edit controls", async assert => { await visit("/t/internationalization-localization/280"); @@ -98,69 +75,6 @@ QUnit.test("Marking a topic as wiki", async assert => { assert.ok(find("a.wiki").length === 1, "it shows the wiki icon"); }); -QUnit.test("Reply as new topic", async assert => { - await visit("/t/internationalization-localization/280"); - await click("button.share:eq(0)"); - await click(".reply-as-new-topic a"); - - assert.ok(exists(".d-editor-input"), "the composer input is visible"); - - assert.equal( - find(".d-editor-input") - .val() - .trim(), - `Continuing the discussion from [Internationalization / localization](${ - window.location.origin - }/t/internationalization-localization/280):`, - "it fills composer with the ring string" - ); - assert.equal( - selectKit(".category-chooser") - .header() - .value(), - "2", - "it fills category selector with the right category" - ); -}); - -QUnit.test("Reply as new message", async assert => { - await visit("/t/pm-for-testing/12"); - await click("button.share:eq(0)"); - await click(".reply-as-new-topic a"); - - assert.ok(exists(".d-editor-input"), "the composer input is visible"); - - assert.equal( - find(".d-editor-input") - .val() - .trim(), - `Continuing the discussion from [PM for testing](${ - window.location.origin - }/t/pm-for-testing/12):`, - "it fills composer with the ring string" - ); - - const targets = find(".item span", ".composer-fields"); - - assert.equal( - $(targets[0]).text(), - "someguy", - "it fills up the composer with the right user to start the PM to" - ); - - assert.equal( - $(targets[1]).text(), - "test", - "it fills up the composer with the right user to start the PM to" - ); - - assert.equal( - $(targets[2]).text(), - "Group", - "it fills up the composer with the right group to start the PM to" - ); -}); - QUnit.test("Visit topic routes", async assert => { await visit("/t/12"); diff --git a/test/javascripts/components/share-button-test.js.es6 b/test/javascripts/components/share-button-test.js.es6 deleted file mode 100644 index 95861c305a7..00000000000 --- a/test/javascripts/components/share-button-test.js.es6 +++ /dev/null @@ -1,15 +0,0 @@ -import componentTest from "helpers/component-test"; -moduleForComponent("share-button", { integration: true }); - -componentTest("share button", { - template: '{{share-button url="https://eviltrout.com"}}', - - test(assert) { - assert.ok(this.$(`button.share`).length, "it has all the classes"); - - assert.ok( - this.$(`button[data-share-url="https://eviltrout.com"]`).length, - "it has the data attribute for sharing" - ); - } -}); diff --git a/test/javascripts/fixtures/topic.js.es6 b/test/javascripts/fixtures/topic.js.es6 index e0869104d4a..7fb9c4102b8 100644 --- a/test/javascripts/fixtures/topic.js.es6 +++ b/test/javascripts/fixtures/topic.js.es6 @@ -2136,6 +2136,7 @@ export default { pinned: false, pinned_at: null, details: { + can_invite_via_email: true, auto_close_at: null, auto_close_hours: null, auto_close_based_on_last_post: false,