diff --git a/.travis.yml b/.travis.yml index c134e72cea..7119b6cd2e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ env: addons: chrome: stable - postgresql: 9.6 + postgresql: "9.6" apt: update: true packages: @@ -40,10 +40,9 @@ services: - redis-server sudo: required -dist: trusty +dist: xenial cache: - apt: true yarn: true directories: - vendor/bundle diff --git a/Gemfile b/Gemfile index 1599efa815..98e165f3ac 100644 --- a/Gemfile +++ b/Gemfile @@ -16,13 +16,13 @@ if rails_master? else # until rubygems gives us optional dependencies we are stuck with this # bundle update actionmailer actionpack actionview activemodel activerecord activesupport railties - gem 'actionmailer', '5.2.3' - gem 'actionpack', '5.2.3' - gem 'actionview', '5.2.3' - gem 'activemodel', '5.2.3' - gem 'activerecord', '5.2.3' - gem 'activesupport', '5.2.3' - gem 'railties', '5.2.3' + gem 'actionmailer', '6.0.0' + gem 'actionpack', '6.0.0' + gem 'actionview', '6.0.0' + gem 'activemodel', '6.0.0' + gem 'activerecord', '6.0.0' + gem 'activesupport', '6.0.0' + gem 'railties', '6.0.0' gem 'sprockets-rails' end @@ -46,7 +46,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.9.12' +gem 'onebox', '1.9.13' gem 'http_accept_language', '~>2.0.5', require: false @@ -140,7 +140,7 @@ group :test, :development do gem 'mocha', require: false gem 'rb-fsevent', require: RUBY_PLATFORM =~ /darwin/i ? 'rb-fsevent' : false gem 'rb-inotify', '~> 0.9', require: RUBY_PLATFORM =~ /linux/i ? 'rb-inotify' : false - gem 'rspec-rails', require: false + gem 'rspec-rails', '4.0.0.beta2', require: false gem 'shoulda-matchers', '~> 3.1', '>= 3.1.3', require: false gem 'rspec-html-matchers' gem 'pry-nav' diff --git a/Gemfile.lock b/Gemfile.lock index 2f387b3729..e859be90ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,46 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (5.2.3) - actionpack (= 5.2.3) - actionview (= 5.2.3) - activejob (= 5.2.3) + actionmailer (6.0.0) + actionpack (= 6.0.0) + actionview (= 6.0.0) + activejob (= 6.0.0) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.3) - actionview (= 5.2.3) - activesupport (= 5.2.3) + actionpack (6.0.0) + actionview (= 6.0.0) + activesupport (= 6.0.0) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.3) - activesupport (= 5.2.3) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actionview (6.0.0) + activesupport (= 6.0.0) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) + rails-html-sanitizer (~> 1.1, >= 1.2.0) active_model_serializers (0.8.4) activemodel (>= 3.0) - activejob (5.2.3) - activesupport (= 5.2.3) + activejob (6.0.0) + activesupport (= 6.0.0) globalid (>= 0.3.6) - activemodel (5.2.3) - activesupport (= 5.2.3) - activerecord (5.2.3) - activemodel (= 5.2.3) - activesupport (= 5.2.3) - arel (>= 9.0) - activesupport (5.2.3) + activemodel (6.0.0) + activesupport (= 6.0.0) + activerecord (6.0.0) + activemodel (= 6.0.0) + activesupport (= 6.0.0) + activesupport (6.0.0) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + zeitwerk (~> 2.1, >= 2.1.8) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) annotate (2.7.5) activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 13.0) - arel (9.0.0) ast (2.4.0) aws-eventstream (1.0.3) aws-partitions (1.154.0) @@ -183,11 +182,11 @@ GEM rack (>= 1.1.3) metaclass (0.0.4) method_source (0.9.2) - mini_mime (1.0.1) + mini_mime (1.0.2) mini_portile2 (2.4.0) mini_racer (0.2.6) libv8 (>= 6.9.411) - mini_scheduler (0.12.1) + mini_scheduler (0.12.2) sidekiq mini_sql (0.2.2) mini_suffix (0.3.0) @@ -241,7 +240,7 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (1.9.12) + onebox (1.9.13) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -282,20 +281,20 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.0.4) + rails-html-sanitizer (1.2.0) loofah (~> 2.2, >= 2.2.2) rails_multisite (2.0.7) activerecord (> 4.2, < 7) railties (> 4.2, < 7) - railties (5.2.3) - actionpack (= 5.2.3) - activesupport (= 5.2.3) + railties (6.0.0) + actionpack (= 6.0.0) + activesupport (= 6.0.0) method_source rake (>= 0.8.7) - thor (>= 0.19.0, < 2.0) + thor (>= 0.20.3, < 2.0) rainbow (3.0.0) raindrops (0.19.0) - rake (12.3.2) + rake (12.3.3) rake-compiler (1.0.7) rake rb-fsevent (0.10.3) @@ -330,14 +329,14 @@ GEM rspec-mocks (3.8.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.8.0) - rspec-rails (3.8.2) - actionpack (>= 3.0) - activesupport (>= 3.0) - railties (>= 3.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-support (~> 3.8.0) + rspec-rails (4.0.0.beta2) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.8) + rspec-expectations (~> 3.8) + rspec-mocks (~> 3.8) + rspec-support (~> 3.8) rspec-support (3.8.0) rtlit (0.0.5) rubocop (0.69.0) @@ -417,18 +416,19 @@ GEM hkdf (~> 0.2) jwt (~> 2.0) yaml-lint (0.0.10) + zeitwerk (2.1.10) PLATFORMS ruby DEPENDENCIES - actionmailer (= 5.2.3) - actionpack (= 5.2.3) - actionview (= 5.2.3) + actionmailer (= 6.0.0) + actionpack (= 6.0.0) + actionview (= 6.0.0) active_model_serializers (~> 0.8.3) - activemodel (= 5.2.3) - activerecord (= 5.2.3) - activesupport (= 5.2.3) + activemodel (= 6.0.0) + activerecord (= 6.0.0) + activesupport (= 6.0.0) annotate aws-sdk-s3 aws-sdk-sns @@ -493,7 +493,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.9.12) + onebox (= 1.9.13) openid-redis-store parallel_tests pg @@ -504,7 +504,7 @@ DEPENDENCIES rack-mini-profiler rack-protection rails_multisite - railties (= 5.2.3) + railties (= 6.0.0) rake rb-fsevent rb-inotify (~> 0.9) @@ -517,7 +517,7 @@ DEPENDENCIES rqrcode rspec rspec-html-matchers - rspec-rails + rspec-rails (= 4.0.0.beta2) rtlit rubocop ruby-prof diff --git a/app/assets/javascripts/admin/models/backup.js.es6 b/app/assets/javascripts/admin/models/backup.js.es6 index 7dc945d1ae..7cd151378a 100644 --- a/app/assets/javascripts/admin/models/backup.js.es6 +++ b/app/assets/javascripts/admin/models/backup.js.es6 @@ -63,7 +63,7 @@ Backup.reopenClass({ bootbox.alert(result.message); } else { // redirect to homepage (session might be lost) - window.location.pathname = Discourse.getURL("/"); + window.location = Discourse.getURL("/"); } }); } diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 831fe8e765..c7967cc6d5 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -1,7 +1,11 @@ import { escapeExpression } from "discourse/lib/utilities"; import { ajax } from "discourse/lib/ajax"; import round from "discourse/lib/round"; -import { fillMissingDates, formatUsername } from "discourse/lib/utilities"; +import { + fillMissingDates, + formatUsername, + toNumber +} from "discourse/lib/utilities"; import computed from "ember-addons/ember-computed-decorators"; import { number, durationTiny } from "discourse/lib/formatter"; import { renderAvatar } from "discourse/helpers/user-avatar"; @@ -374,14 +378,14 @@ const Report = Discourse.Model.extend({ _secondsLabel(value) { return { - value, + value: toNumber(value), formatedValue: durationTiny(value) }; }, _percentLabel(value) { return { - value, + value: toNumber(value), formatedValue: value ? `${value}%` : "—" }; }, @@ -394,14 +398,14 @@ const Report = Discourse.Model.extend({ const formatedValue = () => (formatNumbers ? number(value) : value); return { - value, + value: toNumber(value), formatedValue: value ? formatedValue() : "—" }; }, _bytesLabel(value) { return { - value, + value: toNumber(value), formatedValue: I18n.toHumanSize(value) }; }, diff --git a/app/assets/javascripts/admin/models/version-check.js.es6 b/app/assets/javascripts/admin/models/version-check.js.es6 index ba0bdf018d..ce41d66713 100644 --- a/app/assets/javascripts/admin/models/version-check.js.es6 +++ b/app/assets/javascripts/admin/models/version-check.js.es6 @@ -19,16 +19,18 @@ const VersionCheck = Discourse.Model.extend({ @computed("git_branch", "installed_sha") gitLink(gitBranch, installedSHA) { - if (gitBranch) { + if (gitBranch && installedSHA) { return `https://github.com/discourse/discourse/compare/${installedSHA}...${gitBranch}`; - } else { + } else if (installedSHA) { return `https://github.com/discourse/discourse/tree/${installedSHA}`; } }, @computed("installed_sha") shortSha(installedSHA) { - return installedSHA.substr(0, 10); + if (installedSHA) { + return installedSHA.substr(0, 10); + } } }); diff --git a/app/assets/javascripts/admin/routes/admin-backups.js.es6 b/app/assets/javascripts/admin/routes/admin-backups.js.es6 index b06b3e8468..b70c978286 100644 --- a/app/assets/javascripts/admin/routes/admin-backups.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups.js.es6 @@ -36,7 +36,7 @@ export default Discourse.Route.extend({ ); if (log.operation === "restore") { // redirect to homepage when the restore is done (session might be lost) - window.location.pathname = Discourse.getURL("/"); + window.location = Discourse.getURL("/"); } } else { this.controllerFor("adminBackupsLogs") diff --git a/app/assets/javascripts/admin/templates/version-checks.hbs b/app/assets/javascripts/admin/templates/version-checks.hbs index 111d863412..ced3da8b79 100644 --- a/app/assets/javascripts/admin/templates/version-checks.hbs +++ b/app/assets/javascripts/admin/templates/version-checks.hbs @@ -5,7 +5,12 @@

{{i18n 'admin.dashboard.installed_version'}}

-

{{dash-if-empty versionCheck.installed_describe}}

+

{{dash-if-empty versionCheck.installed_version}}

+ {{#if versionCheck.gitLink}} + + {{/if}}
{{#if versionCheck.noCheckPerformed}} diff --git a/app/assets/javascripts/discourse-common/mixins/focus-event.js.es6 b/app/assets/javascripts/discourse-common/mixins/focus-event.js.es6 index e9e5690c5b..7aafebdcce 100644 --- a/app/assets/javascripts/discourse-common/mixins/focus-event.js.es6 +++ b/app/assets/javascripts/discourse-common/mixins/focus-event.js.es6 @@ -24,12 +24,16 @@ export default Ember.Mixin.create({ Discourse.set("hasFocus", true); document.addEventListener("visibilitychange", onchange); + document.addEventListener("resume", onchange); + document.addEventListener("freeze", onchange); }, reset() { this._super(...arguments); document.removeEventListener("visibilitychange", onchange); + document.removeEventListener("resume", onchange); + document.removeEventListener("freeze", onchange); onchange = undefined; } diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index 1d89f93f1a..b1af547ebb 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -131,6 +131,25 @@ export default Ember.Component.extend(KeyEnterEscape, { $document.on(DRAG_EVENTS, throttledPerformDrag); $document.on(END_EVENTS, endDrag); }); + + if (window.visualViewport !== undefined) { + this.viewportResize(); + window.visualViewport.addEventListener("resize", this.viewportResize); + } + }, + + viewportResize() { + const composerVH = window.visualViewport.height * 0.01; + + if (window.visualViewport.height !== window.innerHeight) { + document.documentElement.classList.add("keyboard-visible"); + } else { + document.documentElement.classList.remove("keyboard-visible"); + } + document.documentElement.style.setProperty( + "--composer-vh", + `${composerVH}px` + ); }, didInsertElement() { @@ -155,6 +174,9 @@ export default Ember.Component.extend(KeyEnterEscape, { willDestroyElement() { this._super(...arguments); this.appEvents.off("composer:resize", this, this.resize); + if (window.visualViewport !== undefined) { + window.visualViewport.removeEventListener("resize", this.viewportResize); + } }, click() { diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 9061181974..3c204ec0c7 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -1026,39 +1026,37 @@ export default Ember.Component.extend({ Ember.run.debounce( this, () => { - const inlineOneboxes = {}; const oneboxes = {}; + const inlineOneboxes = {}; - let oneboxLeft = - this.siteSettings.max_oneboxes_per_post - - $( - `aside.onebox, a.${INLINE_ONEBOX_CSS_CLASS}, a.${LOADING_ONEBOX_CSS_CLASS}` - ).length; + // Oneboxes = `a.onebox` -> `a.onebox-loading` -> `aside.onebox` + // Inline Oneboxes = `a.inline-onebox-loading` -> `a.inline-onebox` + + let loadedOneboxes = $preview.find( + `aside.onebox, a.${LOADING_ONEBOX_CSS_CLASS}, a.${INLINE_ONEBOX_CSS_CLASS}` + ).length; $preview - .find(`a.${INLINE_ONEBOX_LOADING_CSS_CLASS}, a.onebox`) - .each((_index, link) => { + .find(`a.onebox, a.${INLINE_ONEBOX_LOADING_CSS_CLASS}`) + .each((_, link) => { const $link = $(link); const text = $link.text(); - const isInline = $link.attr("class") === INLINE_ONEBOX_LOADING_CSS_CLASS; + const m = isInline ? inlineOneboxes : oneboxes; - const map = isInline ? inlineOneboxes : oneboxes; - - if (oneboxLeft <= 0) { - if (map[text] !== undefined) { - map[text].push(link); + if (loadedOneboxes < this.siteSettings.max_oneboxes_per_post) { + if (m[text] === undefined) { + m[text] = []; + loadedOneboxes++; + } + m[text].push(link); + } else { + if (m[text] !== undefined) { + m[text].push(link); } else if (isInline) { $link.removeClass(INLINE_ONEBOX_LOADING_CSS_CLASS); } - } else { - if (!map[text]) { - map[text] = []; - oneboxLeft--; - } - - map[text].push(link); } }); @@ -1072,6 +1070,7 @@ export default Ember.Component.extend({ }, 450 ); + // Short upload urls need resolution resolveAllShortUrls(ajax); diff --git a/app/assets/javascripts/discourse/components/footer-nav.js.es6 b/app/assets/javascripts/discourse/components/footer-nav.js.es6 index 2e932686c9..7ac143225a 100644 --- a/app/assets/javascripts/discourse/components/footer-nav.js.es6 +++ b/app/assets/javascripts/discourse/components/footer-nav.js.es6 @@ -2,7 +2,6 @@ import MountWidget from "discourse/components/mount-widget"; import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction"; import Scrolling from "discourse/mixins/scrolling"; import { observes } from "ember-addons/ember-computed-decorators"; -import { isiPad } from "discourse/lib/utilities"; import { isAppWebview, postRNWebviewMessage } from "discourse/lib/utilities"; const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 150; @@ -37,7 +36,7 @@ const FooterNavComponent = MountWidget.extend( this.appEvents.on("modal:body-dismissed", this, "_modalOff"); } - if (isiPad()) { + if (this.capabilities.isIpadOS) { $("body").addClass("footer-nav-ipad"); } else { this.bindScrolling({ name: "footer-nav" }); @@ -56,7 +55,7 @@ const FooterNavComponent = MountWidget.extend( this.appEvents.off("modal:body-removed", this, "_modalOff"); } - if (isiPad()) { + if (this.capabilities.isIpadOS) { $("body").removeClass("footer-nav-ipad"); } else { this.unbindScrolling("footer-nav"); diff --git a/app/assets/javascripts/discourse/components/share-popup.js.es6 b/app/assets/javascripts/discourse/components/share-popup.js.es6 index a396b61cfb..dad5d68949 100644 --- a/app/assets/javascripts/discourse/components/share-popup.js.es6 +++ b/app/assets/javascripts/discourse/components/share-popup.js.es6 @@ -170,7 +170,7 @@ export default Ember.Component.extend({ ) .on("keydown.share-view", this._boundKeydownHandler); - this.appEvents.on("share:url", this._shareUrlHandler); + this.appEvents.on("share:url", this, "_shareUrlHandler"); }, willDestroyElement() { @@ -181,7 +181,7 @@ export default Ember.Component.extend({ .off("mousedown.outside-share-link", this._boundMouseDownHandler) .off("keydown.share-view", this._boundKeydownHandler); - this.appEvents.off("share:url", this._shareUrlHandler); + this.appEvents.off("share:url", this, "_shareUrlHandler"); }, actions: { diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 99c41d5350..41c71a48e5 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -362,6 +362,12 @@ export default SiteHeaderComponent; export function headerHeight() { const $header = $("header.d-header"); + + // Header may not exist in tests (e.g. in the user menu component test). + if ($header.length === 0) { + return 0; + } + const headerOffset = $header.offset(); const headerOffsetTop = headerOffset ? headerOffset.top : 0; return parseInt( diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index f387de7bee..41c57d073c 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -158,12 +158,17 @@ export default Ember.Component.extend({ const offset = window.pageYOffset || $html.scrollTop(); const progressHeight = this.site.mobileView ? 0 - : $("#topic-progress").height(); + : $("#topic-progress").outerHeight(); const maximumOffset = $("#topic-bottom").offset().top + progressHeight; const windowHeight = $(window).height(); const composerHeight = $("#reply-control").height() || 0; const isDocked = offset >= maximumOffset - windowHeight + composerHeight; - const bottom = $("body").height() - maximumOffset; + let bottom = $("body").height() - maximumOffset; + + const $iPadFooterNav = $(".footer-nav-ipad .footer-nav"); + if ($iPadFooterNav && $iPadFooterNav.length > 0) { + bottom += $iPadFooterNav.outerHeight(); + } const wrapperDir = $html.hasClass("rtl") ? "left" : "right"; if (composerHeight > 0) { @@ -175,7 +180,7 @@ export default Ember.Component.extend({ this.set("docked", isDocked); const $replyArea = $("#reply-control .reply-area"); - if ($replyArea && $replyArea.length > 0) { + if ($replyArea && $replyArea.length > 0 && wrapperDir === "left") { $wrapper.css(wrapperDir, `${$replyArea.offset().left}px`); } else { $wrapper.css(wrapperDir, "1em"); diff --git a/app/assets/javascripts/discourse/components/user-stream.js.es6 b/app/assets/javascripts/discourse/components/user-stream.js.es6 index c29b80b09f..7054358457 100644 --- a/app/assets/javascripts/discourse/components/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/components/user-stream.js.es6 @@ -70,13 +70,16 @@ export default Ember.Component.extend(LoadMore, { } else { Draft.get(item.draft_key) .then(d => { - if (d.draft) { - composer.open({ - draft: d.draft, - draftKey: item.draft_key, - draftSequence: d.draft_sequence - }); + const draft = d.draft || item.data; + if (!draft) { + return; } + + composer.open({ + draft, + draftKey: item.draft_key, + draftSequence: d.draft_sequence + }); }) .catch(error => { popupAjaxError(error); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 19da455a26..dfe7eaf68c 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -217,7 +217,10 @@ export default Ember.Controller.extend(ModalFunctionality, { // On Mobile, Android or iOS always go with full screen if ( this.isMobileDevice || - (capabilities && (capabilities.isIOS || capabilities.isAndroid)) + (capabilities && + (capabilities.isIOS || + capabilities.isAndroid || + capabilities.isSafari)) ) { fullScreenLogin = true; } @@ -346,7 +349,7 @@ export default Ember.Controller.extend(ModalFunctionality, { $.removeCookie("destination_url"); window.location.href = destinationUrl; } else if (window.location.pathname === Discourse.getURL("/login")) { - window.location.pathname = Discourse.getURL("/"); + window.location = Discourse.getURL("/"); } else { window.location.reload(); } diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index f5c1f6f5ad..8bf5d24d27 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -190,7 +190,7 @@ export default Ember.Controller.extend( () => { bootbox.alert( I18n.t("user.deleted_yourself"), - () => (window.location.pathname = Discourse.getURL("/")) + () => (window.location = Discourse.getURL("/")) ); }, () => { @@ -238,7 +238,7 @@ export default Ember.Controller.extend( if (!token) { const redirect = this.siteSettings.logout_redirect; if (Ember.isEmpty(redirect)) { - window.location.pathname = Discourse.getURL("/"); + window.location = Discourse.getURL("/"); } else { window.location.href = redirect; } diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 7a7996e627..6cb4ecf0be 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -690,20 +690,7 @@ export default Ember.Controller.extend(bufferedProperty("model"), { }, jumpToPost(postNumber) { - if (this.get("model.postStream.isMegaTopic")) { - this._jumpToPostNumber(postNumber); - } else { - const postStream = this.get("model.postStream"); - let postId = postStream.findPostIdForPostNumber(postNumber); - - // If we couldn't find the post, find the closest post to it - if (!postId) { - const closest = postStream.closestPostNumberFor(postNumber); - postId = postStream.findPostIdForPostNumber(closest); - } - - this._jumpToPostId(postId); - } + this._jumpToPostNumber(postNumber); }, jumpTop() { @@ -1352,17 +1339,12 @@ export default Ember.Controller.extend(bufferedProperty("model"), { }) .then(() => refresh({ id: data.id, refreshLikes: true })); break; - case "read": + case "read": { postStream - .triggerChangedPost(data.id, data.updated_at, { - preserveCooked: true - }) - .then(() => - refresh({ - id: data.id, - refreshReaders: topic.show_read_indicator - }) - ); + .triggerReadPost(data.id, data.readers_count) + .then(() => refresh({ id: data.id, refreshLikes: true })); + break; + } case "revised": case "rebaked": { postStream diff --git a/app/assets/javascripts/discourse/helpers/category-link.js.es6 b/app/assets/javascripts/discourse/helpers/category-link.js.es6 index 3b62c82170..7f02dae280 100644 --- a/app/assets/javascripts/discourse/helpers/category-link.js.es6 +++ b/app/assets/javascripts/discourse/helpers/category-link.js.es6 @@ -75,7 +75,7 @@ export function categoryLinkHTML(category, options) { registerUnbound("category-link", categoryLinkHTML); function defaultCategoryLinkRenderer(category, opts) { - let description = get(category, "description_text"); + let descriptionText = get(category, "description_text"); let restricted = get(category, "read_restricted"); let url = opts.url ? opts.url @@ -121,7 +121,7 @@ function defaultCategoryLinkRenderer(category, opts) { 'data-drop-close="true" class="' + classNames + '"' + - (description ? 'title="' + escapeExpression(description) + '" ' : "") + + (descriptionText ? 'title="' + descriptionText + '" ' : "") + ">"; let categoryName = escapeExpression(get(category, "name")); diff --git a/app/assets/javascripts/discourse/initializers/relative-ages.js.es6 b/app/assets/javascripts/discourse/initializers/relative-ages.js.es6 index 30a0118d3e..aac39ca3c6 100644 --- a/app/assets/javascripts/discourse/initializers/relative-ages.js.es6 +++ b/app/assets/javascripts/discourse/initializers/relative-ages.js.es6 @@ -3,9 +3,17 @@ import { updateRelativeAge } from "discourse/lib/formatter"; // Updates the relative ages of dates on the screen. export default { name: "relative-ages", - initialize: function() { - setInterval(function() { + + initialize() { + this._interval = setInterval(function() { updateRelativeAge($(".relative-date")); }, 60 * 1000); + }, + + teardown() { + if (this._interval) { + clearInterval(this._interval); + this._interval = null; + } } }; diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index 1cec5148e7..cafa6e6f70 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -159,7 +159,7 @@ export function ajax() { if ( args.type && args.type.toUpperCase() !== "GET" && - url !== "/clicks/track" && + url !== Discourse.getURL("/clicks/track") && !Discourse.Session.currentProp("csrfToken") ) { promise = new Ember.RSVP.Promise((resolve, reject) => { diff --git a/app/assets/javascripts/discourse/lib/click-track.js.es6 b/app/assets/javascripts/discourse/lib/click-track.js.es6 index 438915c13f..42a65623a6 100644 --- a/app/assets/javascripts/discourse/lib/click-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/click-track.js.es6 @@ -102,9 +102,9 @@ export default { data.append("url", href); data.append("post_id", postId); data.append("topic_id", topicId); - navigator.sendBeacon("/clicks/track", data); + navigator.sendBeacon(Discourse.getURL("/clicks/track"), data); } else { - trackPromise = ajax("/clicks/track", { + trackPromise = ajax(Discourse.getURL("/clicks/track"), { type: "POST", data: { url: href, diff --git a/app/assets/javascripts/discourse/lib/formatter.js.es6 b/app/assets/javascripts/discourse/lib/formatter.js.es6 index 2d5961a552..2f71c83658 100644 --- a/app/assets/javascripts/discourse/lib/formatter.js.es6 +++ b/app/assets/javascripts/discourse/lib/formatter.js.es6 @@ -262,7 +262,7 @@ function relativeAgeTinyShowsYear(relativeAgeString) { return relativeAgeString.match(/'[\d]{2}$/); } -function relativeAgeMediumSpan(distance, leaveAgo) { +export function relativeAgeMediumSpan(distance, leaveAgo) { let formatted; const distanceInMinutes = Math.round(distance / 60.0); @@ -283,14 +283,24 @@ function relativeAgeMediumSpan(distance, leaveAgo) { case distanceInMinutes >= 90 && distanceInMinutes <= 1409: formatted = t("x_hours", { count: Math.round(distanceInMinutes / 60.0) }); break; - case distanceInMinutes >= 1410 && distanceInMinutes <= 2159: + case distanceInMinutes >= 1410 && distanceInMinutes <= 2519: formatted = t("x_days", { count: 1 }); break; - case distanceInMinutes >= 2160: + case distanceInMinutes >= 2520 && distanceInMinutes <= 129599: formatted = t("x_days", { count: Math.round((distanceInMinutes - 720.0) / 1440.0) }); break; + case distanceInMinutes >= 129600 && distanceInMinutes <= 525599: + formatted = t("x_months", { + count: Math.round(distanceInMinutes / 43200.0) + }); + break; + default: + formatted = t("x_years", { + count: Math.round(distanceInMinutes / 525600.0) + }); + break; } return formatted || "—"; } diff --git a/app/assets/javascripts/discourse/lib/logout.js.es6 b/app/assets/javascripts/discourse/lib/logout.js.es6 index 888b07eca9..5e81d738e3 100644 --- a/app/assets/javascripts/discourse/lib/logout.js.es6 +++ b/app/assets/javascripts/discourse/lib/logout.js.es6 @@ -9,7 +9,7 @@ export default function logout(siteSettings, keyValueStore) { const redirect = siteSettings.logout_redirect; if (Ember.isEmpty(redirect)) { - window.location.pathname = Discourse.getURL("/"); + window.location = Discourse.getURL("/"); } else { window.location.href = redirect; } diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 1c234632e7..644fa2c436 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -1,5 +1,8 @@ import debounce from "discourse/lib/debounce"; -import { isAppleDevice, safariHacksDisabled } from "discourse/lib/utilities"; +import { safariHacksDisabled } from "discourse/lib/utilities"; + +// TODO: remove calcHeight once iOS 13 adoption > 90% +// In iOS 13 and up we use visualViewport API to calculate height // we can't tell what the actual visible window height is // because we cannot account for the height of the mobile keyboard @@ -70,7 +73,9 @@ export function isWorkaroundActive() { // per http://stackoverflow.com/questions/29001977/safari-in-ios8-is-scrolling-screen-when-fixed-elements-get-focus/29064810 function positioningWorkaround($fixedElement) { - if (!isAppleDevice() || safariHacksDisabled()) { + const caps = Discourse.__container__.lookup("capabilities:main"); + + if (!caps.isIOS || caps.isIpadOS || safariHacksDisabled()) { return; } @@ -89,9 +94,14 @@ function positioningWorkaround($fixedElement) { fixedElement.style.position = ""; fixedElement.style.top = ""; - fixedElement.style.height = oldHeight; - Ember.run.later(() => $(fixedElement).removeClass("no-transition"), 500); + if (window.visualViewport === undefined) { + fixedElement.style.height = oldHeight; + Ember.run.later( + () => $(fixedElement).removeClass("no-transition"), + 500 + ); + } $(window).scrollTop(originalScrollTop); @@ -165,10 +175,11 @@ function positioningWorkaround($fixedElement) { fixedElement.style.top = "0px"; - const height = calcHeight(); - fixedElement.style.height = height + "px"; - - $(fixedElement).addClass("no-transition"); + if (window.visualViewport === undefined) { + const height = calcHeight(); + fixedElement.style.height = height + "px"; + $(fixedElement).addClass("no-transition"); + } evt.preventDefault(); this.focus(); diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6 index 28380ddc93..b8e7dd0546 100644 --- a/app/assets/javascripts/discourse/lib/search.js.es6 +++ b/app/assets/javascripts/discourse/lib/search.js.es6 @@ -4,6 +4,7 @@ import Category from "discourse/models/category"; import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import userSearch from "discourse/lib/user-search"; import { userPath } from "discourse/lib/url"; +import { emojiUnescape } from "discourse/lib/text"; import User from "discourse/models/user"; import Post from "discourse/models/post"; import Topic from "discourse/models/topic"; @@ -31,6 +32,7 @@ export function translateResults(results, opts) { } post = Post.create(post); post.set("topic", topicMap[post.topic_id]); + post.blurb = emojiUnescape(post.blurb); return post; }); diff --git a/app/assets/javascripts/discourse/lib/sharing.js.es6 b/app/assets/javascripts/discourse/lib/sharing.js.es6 index 4e57b9dfa5..2a3b4d191b 100644 --- a/app/assets/javascripts/discourse/lib/sharing.js.es6 +++ b/app/assets/javascripts/discourse/lib/sharing.js.es6 @@ -19,6 +19,11 @@ return "http://twitter.com/intent/tweet?url=" + encodeURIComponent(link) + "&text=" + encodeURIComponent(title); }, + // If provided, handle by custom javascript rather than default url open + clickHandler: function(link, title){ + alert("Hello!") + } + // If true, opens in a popup of `popupHeight` size. If false it's opened in a new tab shouldOpenInPopup: true, popupHeight: 265 @@ -48,23 +53,27 @@ export default { }, shareSource(source, data) { - const url = source.generateUrl(data.url, data.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); + if (source.clickHandler) { + source.clickHandler(data.url, data.title); } else { - window.open(url, "_blank"); + const url = source.generateUrl(data.url, data.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/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 3f2be55872..530f21d6ac 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -538,10 +538,8 @@ export function determinePostReplaceSelection({ export function isAppleDevice() { // IE has no DOMNodeInserted so can not get this hack despite saying it is like iPhone // This will apply hack on all iDevices - return ( - navigator.userAgent.match(/(iPad|iPhone|iPod)/g) && - !navigator.userAgent.match(/Trident/g) - ); + const caps = Discourse.__container__.lookup("capabilities:main"); + return caps.isIOS && !navigator.userAgent.match(/Trident/g); } let iPadDetected = undefined; @@ -599,8 +597,12 @@ export function clipboardData(e, canUpload) { return { clipboard, types, canUpload, canPasteHtml }; } +export function toNumber(input) { + return typeof input === "number" ? input : parseFloat(input); +} + export function isNumeric(input) { - return !isNaN(parseFloat(input)) && isFinite(input); + return !isNaN(toNumber(input)) && isFinite(input); } export function fillMissingDates(data, startDate, endDate) { diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 903d61b521..ebe6d5aa6e 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -358,6 +358,17 @@ const Composer = RestModel.extend({ } } + if (topicFirstPost) { + // user should modify topic template + const category = this.category; + if (category && category.topic_template) { + if (this.reply.trim() === category.topic_template.trim()) { + bootbox.alert(I18n.t("composer.error.topic_template_not_modified")); + return true; + } + } + } + if (this.privateMessage) { // need at least one user when sending a PM return ( diff --git a/app/assets/javascripts/discourse/models/nav-item.js.es6 b/app/assets/javascripts/discourse/models/nav-item.js.es6 index 8ae207a37c..85379d4bfc 100644 --- a/app/assets/javascripts/discourse/models/nav-item.js.es6 +++ b/app/assets/javascripts/discourse/models/nav-item.js.es6 @@ -19,7 +19,10 @@ const NavItem = Discourse.Model.extend({ displayName(categoryName, name, count) { count = count || 0; - if (name === "latest" && !Discourse.Site.currentProp("mobileView")) { + if ( + name === "latest" && + (!Discourse.Site.currentProp("mobileView") || this.tagId !== undefined) + ) { count = 0; } diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 51e536965e..346226b9eb 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -716,6 +716,19 @@ export default RestModel.extend({ return resolved; }, + triggerReadPost(postId, readersCount) { + const resolved = Ember.RSVP.Promise.resolve(); + resolved.then(() => { + const post = this.findLoadedPost(postId); + if (post && readersCount > post.readers_count) { + post.set("readers_count", readersCount); + this.storePost(post); + } + }); + + return resolved; + }, + postForPostNumber(postNumber) { if (!this.hasPosts) { return; diff --git a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 index 0a41fbab9a..0cfc4f2c61 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -35,10 +35,14 @@ export default { caps.canPasteImages = caps.isChrome || caps.isFirefox; } - caps.isIOS = - /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + caps.isIpadOS = + ua.indexOf("Mac OS") !== -1 && + !/iPhone|iPod/.test(navigator.userAgent) && + touch; - caps.isIpadOS = ua.indexOf("Mac OS") !== -1 && touch; + caps.isIOS = + (/iPhone|iPod/.test(navigator.userAgent) || caps.isIpadOS) && + !window.MSStream; } // We consider high res a device with 1280 horizontal pixels. High DPI tablets like diff --git a/app/assets/javascripts/discourse/routes/associate-account.js.es6 b/app/assets/javascripts/discourse/routes/associate-account.js.es6 index dfbe2e52f7..d89654e078 100644 --- a/app/assets/javascripts/discourse/routes/associate-account.js.es6 +++ b/app/assets/javascripts/discourse/routes/associate-account.js.es6 @@ -7,7 +7,7 @@ export default Discourse.Route.extend({ const params = this.paramsFor("associate-account"); this.replaceWith(`preferences.account`, this.currentUser).then(() => Ember.run.next(() => - ajax(`/associate/${encodeURIComponent(params.token)}`) + ajax(`/associate/${encodeURIComponent(params.token)}.json`) .then(model => showModal("associate-account-confirm", { model })) .catch(popupAjaxError) ) diff --git a/app/assets/javascripts/discourse/templates/about.hbs b/app/assets/javascripts/discourse/templates/about.hbs index 5abd436bca..e9be344032 100644 --- a/app/assets/javascripts/discourse/templates/about.hbs +++ b/app/assets/javascripts/discourse/templates/about.hbs @@ -59,7 +59,7 @@ {{#if model.category_moderators.length}} {{#each model.category_moderators as |cm|}} -
+

{{category-link cm.category}}{{i18n "about.moderators"}}

{{#each cm.moderators as |m|}} diff --git a/app/assets/javascripts/discourse/templates/components/link-to-input.hbs b/app/assets/javascripts/discourse/templates/components/link-to-input.hbs index 19d1d34bc5..29b892df7f 100644 --- a/app/assets/javascripts/discourse/templates/components/link-to-input.hbs +++ b/app/assets/javascripts/discourse/templates/components/link-to-input.hbs @@ -1,5 +1,12 @@ {{#if showInput}} {{yield}} {{else}} - {{i18n key}} + + {{#if key}} + {{i18n key}} + {{/if}} + {{#if icon}} + {{d-icon icon}} + {{/if}} + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs index 1c9736a41f..21bd7efbdf 100644 --- a/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs +++ b/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs @@ -9,7 +9,7 @@ disabled=reviewableUpdating}} {{else}} {{d-button - class=(concat "reviewable-action " (dasherize first.id)) + class=(concat "reviewable-action " (dasherize first.id) " " first.button_class) icon=first.icon action=(action "perform" first) translatedLabel=first.label diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 4ae081b212..43c3599ec8 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -37,7 +37,7 @@ {{/unless}} {{#if canEdit}} - {{#link-to-input onClick=(action "displayEditReason") showInput=showEditReason key="composer.show_edit_reason" class="display-edit-reason"}} + {{#link-to-input onClick=(action "displayEditReason") showInput=showEditReason icon="info-circle" class="display-edit-reason"}} {{text-field value=editReason tabindex="7" id="edit-reason" maxlength="255" placeholderKey="composer.edit_reason_placeholder"}} {{/link-to-input}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/preferences.hbs b/app/assets/javascripts/discourse/templates/preferences.hbs index 3abd833348..854db06883 100644 --- a/app/assets/javascripts/discourse/templates/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/preferences.hbs @@ -54,7 +54,7 @@ {{/d-section}}
- {{plugin-outlet name="above-user-preferences"}} + {{plugin-outlet name="above-user-preferences" args=(hash model=model)}}
{{outlet}} diff --git a/app/assets/javascripts/discourse/templates/user.hbs b/app/assets/javascripts/discourse/templates/user.hbs index f8f8d31c60..8fbc7b440f 100644 --- a/app/assets/javascripts/discourse/templates/user.hbs +++ b/app/assets/javascripts/discourse/templates/user.hbs @@ -1,3 +1,4 @@ +{{plugin-outlet name="above-user-profile" tagName='' args=(hash model=model)}}
{{#d-section class="user-main"}} diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index 4138fdbaa0..65f55a996b 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -14,7 +14,7 @@ import { formatUsername } from "discourse/lib/utilities"; import hbs from "discourse/widgets/hbs-compiler"; -import { durationTiny } from "discourse/lib/formatter"; +import { relativeAgeMediumSpan } from "discourse/lib/formatter"; import { prioritizeNameInUx } from "discourse/lib/settings"; function transformWithCallbacks(post) { @@ -472,7 +472,7 @@ createWidget("post-notice", { "p", I18n.t("post.notice.returning_user", { user, - time: durationTiny(distance, { addAgo: true }) + time: relativeAgeMediumSpan(distance, true) }) ); } diff --git a/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 new file mode 100644 index 0000000000..ca1642b897 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 @@ -0,0 +1,51 @@ +import { h } from "virtual-dom"; +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import UserAction from "discourse/models/user-action"; +import { ajax } from "discourse/lib/ajax"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { postUrl } from "discourse/lib/utilities"; + +const ICON = "bookmark"; + +createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", { + buildKey: () => "quick-access-bookmarks", + + hasMore() { + // Always show the button to the bookmarks page. + return true; + }, + + showAllHref() { + return `${this.attrs.path}/activity/bookmarks`; + }, + + emptyStatePlaceholderItem() { + return h("li.read", this.state.emptyStatePlaceholderItemText); + }, + + findNewItems() { + return ajax("/user_actions.json", { + cache: "false", + data: { + username: this.currentUser.username, + filter: UserAction.TYPES.bookmarks, + limit: this.estimateItemLimit(), + no_results_help_key: "user_activity.no_bookmarks" + } + }).then(({ user_actions, no_results_help }) => { + // The empty state help text for bookmarks page is localized on the + // server. + this.state.emptyStatePlaceholderItemText = no_results_help; + return user_actions; + }); + }, + + itemHtml(bookmark) { + return this.attach("quick-access-item", { + icon: ICON, + href: postUrl(bookmark.slug, bookmark.topic_id, bookmark.post_number), + content: bookmark.title, + username: bookmark.username + }); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 new file mode 100644 index 0000000000..a37200e595 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 @@ -0,0 +1,62 @@ +import { h } from "virtual-dom"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidget } from "discourse/widgets/widget"; +import { emojiUnescape } from "discourse/lib/text"; +import { iconNode } from "discourse-common/lib/icon-library"; +import { escapeExpression } from "discourse/lib/utilities"; + +/** + * This helper widget tries to enforce a consistent look and behavior for any + * item under any quick access panels. + * + * It accepts the following attributes: + * action + * actionParam + * content + * escapedContent + * href + * icon + * read + * username + */ +createWidget("quick-access-item", { + tagName: "li", + + buildClasses(attrs) { + const result = []; + if (attrs.className) { + result.push(attrs.className); + } + if (attrs.read === undefined || attrs.read) { + result.push("read"); + } + return result; + }, + + html({ icon, href }) { + return h("a", { attributes: { href } }, [ + iconNode(icon), + new RawHtml({ + html: `
${this._usernameHtml()}${this._contentHtml()}
` + }) + ]); + }, + + click(e) { + this.attrs.read = true; + if (this.attrs.action) { + e.preventDefault(); + return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam); + } + }, + + _contentHtml() { + const content = + this.attrs.escapedContent || escapeExpression(this.attrs.content); + return emojiUnescape(content); + }, + + _usernameHtml() { + return this.attrs.username ? `${this.attrs.username} ` : ""; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 new file mode 100644 index 0000000000..e8431bc3d2 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 @@ -0,0 +1,50 @@ +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { postUrl } from "discourse/lib/utilities"; + +const ICON = "notification.private_message"; + +function toItem(message) { + const lastReadPostNumber = message.last_read_post_number || 0; + const nextUnreadPostNumber = Math.min( + lastReadPostNumber + 1, + message.highest_post_number + ); + + return { + escapedContent: message.fancy_title, + href: postUrl(message.slug, message.id, nextUnreadPostNumber), + icon: ICON, + read: message.last_read_post_number >= message.highest_post_number, + username: message.last_poster_username + }; +} + +createWidgetFrom(QuickAccessPanel, "quick-access-messages", { + buildKey: () => "quick-access-messages", + emptyStatePlaceholderItemKey: "choose_topic.none_found", + + hasMore() { + // Always show the button to the messages page for composing, archiving, + // etc. + return true; + }, + + showAllHref() { + return `${this.attrs.path}/messages`; + }, + + findNewItems() { + return this.store + .findFiltered("topicList", { + filter: `topics/private-messages/${this.currentUser.username_lower}` + }) + .then(({ topic_list }) => { + return topic_list.topics.map(toItem).slice(0, this.estimateItemLimit()); + }); + }, + + itemHtml(message) { + return this.attach("quick-access-item", message); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 new file mode 100644 index 0000000000..515e702ace --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 @@ -0,0 +1,55 @@ +import { ajax } from "discourse/lib/ajax"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; + +createWidgetFrom(QuickAccessPanel, "quick-access-notifications", { + buildKey: () => "quick-access-notifications", + emptyStatePlaceholderItemKey: "notifications.empty", + + markReadRequest() { + return ajax("/notifications/mark-read", { method: "PUT" }); + }, + + newItemsLoaded() { + if (!this.currentUser.enforcedSecondFactor) { + this.currentUser.set("unread_notifications", 0); + } + }, + + itemHtml(notification) { + const notificationName = this.site.notificationLookup[ + notification.notification_type + ]; + + return this.attach( + `${notificationName.dasherize()}-notification-item`, + notification, + {}, + { fallbackWidgetName: "default-notification-item" } + ); + }, + + findNewItems() { + return this._findStaleItemsInStore().refresh(); + }, + + showAllHref() { + return `${this.attrs.path}/notifications`; + }, + + hasUnread() { + return this.getItems().filterBy("read", false).length > 0; + }, + + _findStaleItemsInStore() { + return this.store.findStale( + "notification", + { + recent: true, + silent: this.currentUser.enforcedSecondFactor, + limit: this.estimateItemLimit() + }, + { cacheKey: "recent-notifications" } + ); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 new file mode 100644 index 0000000000..e70985fde2 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 @@ -0,0 +1,143 @@ +import Session from "discourse/models/session"; +import { createWidget } from "discourse/widgets/widget"; +import { h } from "virtual-dom"; +import { headerHeight } from "discourse/components/site-header"; + +const AVERAGE_ITEM_HEIGHT = 55; + +/** + * This tries to enforce a consistent flow of fetching, caching, refreshing, + * and rendering for "quick access items". + * + * There are parts to introducing a new quick access panel: + * 1. A user menu link that sends a `quickAccess` action, with a unique `type`. + * 2. A `quick-access-${type}` widget, extended from `quick-access-panel`. + */ +export default createWidget("quick-access-panel", { + tagName: "div.quick-access-panel", + emptyStatePlaceholderItemKey: "", + + buildKey: () => { + throw Error('Cannot attach abstract widget "quick-access-panel".'); + }, + + markReadRequest() { + return Ember.RSVP.Promise.resolve(); + }, + + hasUnread() { + return false; + }, + + showAllHref() { + return ""; + }, + + hasMore() { + return this.getItems().length >= this.estimateItemLimit(); + }, + + findNewItems() { + return Ember.RSVP.Promise.resolve([]); + }, + + newItemsLoaded() {}, + + itemHtml(item) {}, // eslint-disable-line no-unused-vars + + emptyStatePlaceholderItem() { + if (this.emptyStatePlaceholderItemKey) { + return h("li.read", I18n.t(this.emptyStatePlaceholderItemKey)); + } else { + return ""; + } + }, + + defaultState() { + return { items: [], loading: false, loaded: false }; + }, + + markRead() { + return this.markReadRequest().then(() => { + this.refreshNotifications(this.state); + }); + }, + + estimateItemLimit() { + // Estimate (poorly) the amount of notifications to return. + let limit = Math.round( + ($(window).height() - headerHeight()) / AVERAGE_ITEM_HEIGHT + ); + + // We REALLY don't want to be asking for negative counts of notifications + // less than 5 is also not that useful. + if (limit < 5) { + limit = 5; + } else if (limit > 40) { + limit = 40; + } + + return limit; + }, + + refreshNotifications(state) { + if (this.loading) { + return; + } + + if (this.getItems().length === 0) { + state.loading = true; + } + + this.findNewItems() + .then(newItems => this.setItems(newItems)) + .catch(() => this.setItems([])) + .finally(() => { + state.loading = false; + state.loaded = true; + this.newItemsLoaded(); + this.sendWidgetAction("itemsLoaded", { + hasUnread: this.hasUnread(), + markRead: () => this.markRead() + }); + this.scheduleRerender(); + }); + }, + + html(attrs, state) { + if (!state.loaded) { + this.refreshNotifications(state); + } + + if (state.loading) { + return [h("div.spinner-container", h("div.spinner"))]; + } + + const items = this.getItems().length + ? this.getItems().map(item => this.itemHtml(item)) + : [this.emptyStatePlaceholderItem()]; + + if (this.hasMore()) { + items.push( + h( + "li.read.last.show-all", + this.attach("link", { + title: "view_all", + icon: "chevron-down", + href: this.showAllHref() + }) + ) + ); + } + + return [h("ul", items)]; + }, + + getItems() { + return Session.currentProp(`${this.key}-items`) || []; + }, + + setItems(newItems) { + Session.currentProp(`${this.key}-items`, newItems); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 new file mode 100644 index 0000000000..b74054e243 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 @@ -0,0 +1,91 @@ +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import { createWidgetFrom } from "discourse/widgets/widget"; + +createWidgetFrom(QuickAccessPanel, "quick-access-profile", { + buildKey: () => "quick-access-profile", + + hasMore() { + // Never show the button to the full profile page. + return false; + }, + + findNewItems() { + return Ember.RSVP.Promise.resolve(this._getItems()); + }, + + itemHtml(item) { + return this.attach("quick-access-item", item); + }, + + _getItems() { + const items = this._getDefaultItems(); + if (this._showToggleAnonymousButton()) { + items.push(this._toggleAnonymousButton()); + } + if (this.attrs.showLogoutButton) { + items.push(this._logOutButton()); + } + return items; + }, + + _getDefaultItems() { + return [ + { + icon: "user", + href: `${this.attrs.path}/summary`, + content: I18n.t("user.summary.title") + }, + { + icon: "stream", + href: `${this.attrs.path}/activity`, + content: I18n.t("user.activity_stream") + }, + { + icon: "envelope", + href: `${this.attrs.path}/messages`, + content: I18n.t("user.private_messages") + }, + { + icon: "cog", + href: `${this.attrs.path}/preferences`, + content: I18n.t("user.preferences") + } + ]; + }, + + _toggleAnonymousButton() { + if (this.currentUser.is_anonymous) { + return { + action: "toggleAnonymous", + className: "disable-anonymous", + content: I18n.t("switch_from_anon"), + icon: "ban" + }; + } else { + return { + action: "toggleAnonymous", + className: "enable-anonymous", + content: I18n.t("switch_to_anon"), + icon: "user-secret" + }; + } + }, + + _logOutButton() { + return { + action: "logout", + className: "logout", + content: I18n.t("user.log_out"), + icon: "sign-out-alt" + }; + }, + + _showToggleAnonymousButton() { + return ( + (this.siteSettings.allow_anonymous_posting && + this.currentUser.trust_level >= + this.siteSettings.anonymous_posting_min_trust_level) || + this.currentUser.is_anonymous + ); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 index a25240db72..96e56fb7ac 100644 --- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 @@ -3,6 +3,17 @@ import { h } from "virtual-dom"; import { formatUsername } from "discourse/lib/utilities"; import hbs from "discourse/widgets/hbs-compiler"; +const UserMenuAction = { + QUICK_ACCESS: "quickAccess" +}; + +const QuickAccess = { + BOOKMARKS: "bookmarks", + MESSAGES: "messages", + NOTIFICATIONS: "notifications", + PROFILE: "profile" +}; + let extraGlyphs; export function addUserMenuGlyph(glyph) { @@ -15,6 +26,8 @@ createWidget("user-menu-links", { profileLink() { const link = { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.PROFILE, route: "user", model: this.currentUser, className: "user-activity-link", @@ -30,8 +43,21 @@ createWidget("user-menu-links", { return link; }, + notificationsGlyph() { + return { + label: "user.notifications", + className: "user-notifications-link", + icon: "bell", + href: `${this.attrs.path}/notifications`, + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.NOTIFICATIONS + }; + }, + bookmarksGlyph() { return { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.BOOKMARKS, label: "user.bookmarks", className: "user-bookmarks-link", icon: "bookmark", @@ -41,6 +67,8 @@ createWidget("user-menu-links", { messagesGlyph() { return { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.MESSAGES, label: "user.private_messages", className: "user-pms-link", icon: "envelope", @@ -49,24 +77,20 @@ createWidget("user-menu-links", { }, linkHtml(link) { + if (this.isActive(link)) { + link = this.markAsActive(link); + } return this.attach("link", link); }, glyphHtml(glyph) { + if (this.isActive(glyph)) { + glyph = this.markAsActive(glyph); + } return this.attach("link", $.extend(glyph, { hideLabel: true })); }, - html(attrs) { - const { currentUser, siteSettings } = this; - - const isAnon = currentUser.is_anonymous; - const allowAnon = - (siteSettings.allow_anonymous_posting && - currentUser.trust_level >= - siteSettings.anonymous_posting_min_trust_level) || - isAnon; - - const path = attrs.path; + html() { const links = [this.profileLink()]; const glyphs = []; @@ -81,42 +105,39 @@ createWidget("user-menu-links", { }); } + glyphs.push(this.notificationsGlyph()); glyphs.push(this.bookmarksGlyph()); - if (siteSettings.enable_personal_messages) { + if (this.siteSettings.enable_personal_messages) { glyphs.push(this.messagesGlyph()); } - if (allowAnon) { - if (!isAnon) { - glyphs.push({ - action: "toggleAnonymous", - label: "switch_to_anon", - className: "enable-anonymous", - icon: "user-secret" - }); - } else { - glyphs.push({ - action: "toggleAnonymous", - label: "switch_from_anon", - className: "disable-anonymous", - icon: "ban" - }); - } - } - - // preferences always goes last - glyphs.push({ - label: "user.preferences", - className: "user-preferences-link", - icon: "cog", - href: `${path}/preferences` - }); - return h("ul.menu-links-row", [ links.map(l => h("li.user", this.linkHtml(l))), h("li.glyphs", glyphs.map(l => this.glyphHtml(l))) ]); + }, + + markAsActive(definition) { + // Clicking on an active quick access tab icon should redirect the user to + // the full page. + definition.action = null; + definition.actionParam = null; + + if (definition.className) { + definition.className += " active"; + } else { + definition.className = "active"; + } + + return definition; + }, + + isActive({ action, actionParam }) { + return ( + action === UserMenuAction.QUICK_ACCESS && + actionParam === this.attrs.currentQuickAccess + ); } }); @@ -148,6 +169,7 @@ export default createWidget("user-menu", { defaultState() { return { + currentQuickAccess: QuickAccess.NOTIFICATIONS, hasUnread: false, markUnread: null }; @@ -155,37 +177,18 @@ export default createWidget("user-menu", { panelContents() { const path = this.currentUser.get("path"); + const { currentQuickAccess } = this.state; - let result = [ - this.attach("user-menu-links", { path }), - this.attach("user-notifications", { path }) + const result = [ + this.attach("user-menu-links", { + path, + currentQuickAccess + }), + this.quickAccessPanel(path) ]; - if (this.settings.showLogoutButton || this.state.hasUnread) { - result.push(h("hr.bottom-area")); - } - - if (this.settings.showLogoutButton) { - result.push( - h("div.logout-link", [ - h( - "ul.menu-links", - h( - "li", - this.attach("link", { - action: "logout", - className: "logout", - icon: "sign-out-alt", - href: "", - label: "user.log_out" - }) - ) - ) - ]) - ); - } - if (this.state.hasUnread) { + result.push(h("hr.bottom-area")); result.push(this.attach("user-menu-dismiss-link")); } @@ -196,8 +199,8 @@ export default createWidget("user-menu", { return this.state.markRead(); }, - notificationsLoaded({ notifications, markRead }) { - this.state.hasUnread = notifications.filterBy("read", false).length > 0; + itemsLoaded({ hasUnread, markRead }) { + this.state.hasUnread = hasUnread; this.state.markRead = markRead; }, @@ -234,5 +237,20 @@ export default createWidget("user-menu", { } else { this.sendWidgetAction("toggleUserMenu"); } + }, + + quickAccess(type) { + if (this.state.currentQuickAccess !== type) { + this.state.currentQuickAccess = type; + } + }, + + quickAccessPanel(path) { + const { showLogoutButton } = this.settings; + // This deliberately does NOT fallback to a default quick access panel. + return this.attach(`quick-access-${this.state.currentQuickAccess}`, { + path, + showLogoutButton + }); } }); diff --git a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 deleted file mode 100644 index fb19fb56a7..0000000000 --- a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 +++ /dev/null @@ -1,131 +0,0 @@ -import { createWidget } from "discourse/widgets/widget"; -import { headerHeight } from "discourse/components/site-header"; -import { h } from "virtual-dom"; -import DiscourseURL from "discourse/lib/url"; -import { ajax } from "discourse/lib/ajax"; - -export default createWidget("user-notifications", { - tagName: "div.notifications", - buildKey: () => "user-notifications", - - defaultState() { - return { notifications: [], loading: false, loaded: false }; - }, - - markRead() { - ajax("/notifications/mark-read", { method: "PUT" }).then(() => { - this.refreshNotifications(this.state); - }); - }, - - refreshNotifications(state) { - if (this.loading) { - return; - } - - // estimate (poorly) the amount of notifications to return - let limit = Math.round(($(window).height() - headerHeight()) / 55); - // we REALLY don't want to be asking for negative counts of notifications - // less than 5 is also not that useful - if (limit < 5) { - limit = 5; - } - if (limit > 40) { - limit = 40; - } - - const silent = this.currentUser.get("enforcedSecondFactor"); - const stale = this.store.findStale( - "notification", - { recent: true, silent, limit }, - { cacheKey: "recent-notifications" } - ); - - if (stale.hasResults) { - const results = stale.results; - let content = results.get("content"); - - // we have to truncate to limit, otherwise we will render too much - if (content && content.length > limit) { - content = content.splice(0, limit); - results.set("content", content); - results.set("totalRows", limit); - } - - state.notifications = results; - } else { - state.loading = true; - } - - stale - .refresh() - .then(notifications => { - if (!silent) { - this.currentUser.set("unread_notifications", 0); - } - state.notifications = notifications; - }) - .catch(() => { - state.notifications = []; - }) - .finally(() => { - state.loading = false; - state.loaded = true; - this.sendWidgetAction("notificationsLoaded", { - notifications: state.notifications, - markRead: () => this.markRead() - }); - this.scheduleRerender(); - }); - }, - - html(attrs, state) { - if (!state.loaded) { - this.refreshNotifications(state); - } - - const result = []; - if (state.loading) { - result.push(h("div.spinner-container", h("div.spinner"))); - } else if (state.notifications.length) { - const notificationItems = state.notifications.map(notificationAttrs => { - const notificationName = this.site.notificationLookup[ - notificationAttrs.notification_type - ]; - - return this.attach( - `${notificationName.dasherize()}-notification-item`, - notificationAttrs, - {}, - { fallbackWidgetName: "default-notification-item" } - ); - }); - - result.push(h("hr")); - - const items = [notificationItems]; - - if (notificationItems.length > 5) { - items.push( - h( - "li.read.last.heading.show-all", - this.attach("button", { - title: "notifications.more", - icon: "chevron-down", - action: "showAllNotifications", - className: "btn" - }) - ) - ); - } - - result.push(h("ul", items)); - } - - return result; - }, - - showAllNotifications() { - DiscourseURL.routeTo(`${this.attrs.path}/notifications`); - } -}); diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index cffc2e214a..68657e28b6 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -116,7 +116,15 @@ I18n.interpolate = function(message, options) { for (var i = 0; placeholder = matches[i]; i++) { name = placeholder.replace(this.PLACEHOLDER, "$1"); - value = options[name]; + if (typeof options[name] === "string") { + // The dollar sign (`$`) is a special replace pattern, and `$&` inserts + // the matched string. Thus dollars signs need to be escaped with the + // special pattern `$$`, which inserts a single `$`. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Specifying_a_string_as_a_parameter + value = options[name].replace(/\$/g, "$$$$"); + } else { + value = options[name]; + } if (!this.isValidNode(options, name)) { value = "[missing " + placeholder + " value]"; diff --git a/app/assets/javascripts/pretty-text/white-lister.js.es6 b/app/assets/javascripts/pretty-text/white-lister.js.es6 index 40918c49c3..5ea109398b 100644 --- a/app/assets/javascripts/pretty-text/white-lister.js.es6 +++ b/app/assets/javascripts/pretty-text/white-lister.js.es6 @@ -167,6 +167,7 @@ const DEFAULT_LIST = [ "iframe[marginheight]", "iframe[marginwidth]", "iframe[width]", + "iframe[allowfullscreen]", "img[alt]", "img[height]", "img[title]", diff --git a/app/assets/javascripts/select-kit/components/category-row.js.es6 b/app/assets/javascripts/select-kit/components/category-row.js.es6 index c0c3657b1c..2915301422 100644 --- a/app/assets/javascripts/select-kit/components/category-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-row.js.es6 @@ -84,9 +84,9 @@ export default SelectKitRowComponent.extend({ }, @computed("category.description_text") - descriptionText(description) { - if (description) { - return this._formatCategoryDescription(description); + descriptionText(descriptionText) { + if (descriptionText) { + return this._formatCategoryDescription(descriptionText); } }, diff --git a/app/assets/stylesheets/common/admin/admin_report_table.scss b/app/assets/stylesheets/common/admin/admin_report_table.scss index 7fc1661fb2..e63330355a 100644 --- a/app/assets/stylesheets/common/admin/admin_report_table.scss +++ b/app/assets/stylesheets/common/admin/admin_report_table.scss @@ -53,6 +53,9 @@ &:hover { color: $primary-medium; background: $primary-low; + .d-icon { + color: $primary-medium; + } } } } diff --git a/app/assets/stylesheets/common/admin/dashboard.scss b/app/assets/stylesheets/common/admin/dashboard.scss index 8d42b716bb..d92e0191b7 100644 --- a/app/assets/stylesheets/common/admin/dashboard.scss +++ b/app/assets/stylesheets/common/admin/dashboard.scss @@ -626,10 +626,14 @@ flex: 1 0 auto; white-space: nowrap; } - h4 { + h4, + .sha-link { font-size: $font-down-2; margin-bottom: 0; } + .sha-link { + font-weight: normal; + } } .version-status { display: flex; diff --git a/app/assets/stylesheets/common/admin/settings.scss b/app/assets/stylesheets/common/admin/settings.scss index d4129b412d..2ec2bd3d4f 100644 --- a/app/assets/stylesheets/common/admin/settings.scss +++ b/app/assets/stylesheets/common/admin/settings.scss @@ -90,7 +90,7 @@ color: $danger; } .desc { - color: dark-light-choose($primary-medium, $secondary-medium); + color: $primary-medium; } h3 { font-size: $font-0; diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 655290bae6..2a4cab7632 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -169,12 +169,23 @@ font-style: italic; } + .whisper { + margin-right: 0.25em; + } + .display-edit-reason { - display: inline; + display: inline-flex; + a { + display: inline-flex; + } + .d-icon { + padding: 0.3em 0.5em; + color: $tertiary; + } } #edit-reason { - margin: 4px; + margin: 0 4px; } .user-selector, diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index b0b84006d3..44f1d6be7d 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -147,7 +147,7 @@ } .user-menu { - .notifications { + .quick-access-panel { width: 100%; display: table; @@ -187,6 +187,11 @@ padding: 0; > div { overflow: hidden; // clears the text from wrapping below icons + + // Truncate items with more than 2 lines. + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } } @@ -223,9 +228,12 @@ border-width: 2px; margin: 0 auto; } - .show-all .btn { + .show-all a { width: 100%; - padding: 2px 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 30px; color: dark-light-choose($primary-medium, $secondary-high); background: blend-primary-secondary(5%); &:hover { @@ -237,29 +245,24 @@ @include unselectable; } - .logout-link, .dismiss-link { display: inline-block; - } - .dismiss-link { float: right; } } -.notifications .logout { - padding: 0.25em; - &:hover { - background-color: $highlight-medium; - } -} - div.menu-links-header { width: 100%; display: table; border-collapse: separate; border-spacing: 0 0.5em; .menu-links-row { + border-bottom: 1px solid dark-light-choose($primary-low, $secondary-medium); display: flex; + + // Tabs should have "ears". + padding: 0 4px; + li { display: inline-flex; align-items: center; @@ -271,6 +274,42 @@ div.menu-links-header { flex-wrap: wrap; text-align: right; max-width: 65%; //IE11 + + a { + // Expand the click area a bit. + padding-left: 0.6em; + padding-right: 0.6em; + } + } + + a { + // This is to make sure active and inactive tab icons have the same + // size. `box-sizing` does not work and I have no idea why. + border: 1px solid transparent; + border-bottom: 0; + } + + a.active { + border: 1px solid dark-light-choose($primary-low, $secondary-medium); + border-bottom: 0; + position: relative; + + &:after { + display: block; + position: absolute; + top: 100%; + left: 0; + z-index: z("header") + 1; // Higher than .menu-panel + width: 100%; + height: 0; + content: ""; + border-top: 1px solid $secondary; + } + + &:focus, + &:hover { + background-color: inherit; + } } } } @@ -283,12 +322,24 @@ div.menu-links-header { padding: 0.3em 0.5em; } a.user-activity-link { - max-width: 150px; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + align-items: center; + display: flex; margin: -0.5em 0; + max-width: 130px; + + // `overflow: hidden` on `.user-activity-link` would hide the `::after` + // pseudo element (used to create the tab-looking effect). Sets `overflow: + // hidden` on the child username label instead. + overflow: visible; + + span.d-label { + display: block; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + @include breakpoint(mobile-medium) { max-width: 125px; } @@ -311,6 +362,6 @@ div.menu-links-header { } .d-icon-user { - margin-right: 0.2em; + margin-right: 0.475em; } } diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index e1a6a3dfb7..b00f53fb83 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -464,8 +464,9 @@ aside.onebox.twitterstatus .onebox-body { } } -// Onebox - Imgur - Album -.onebox.imgur-album { +// Onebox - Imgur/Flickr - Album +.onebox.imgur-album, +.onebox.flickr-album { .outer-box { position: absolute; z-index: z("base"); diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss index 0228f0c1c7..e47cb5287f 100644 --- a/app/assets/stylesheets/common/base/reviewables.scss +++ b/app/assets/stylesheets/common/base/reviewables.scss @@ -262,10 +262,6 @@ .reviewable-action, .reviewable-action-dropdown { margin-right: 0.5em; - - &.delete-user { - @extend .btn-danger; - } } } padding-bottom: 1em; diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index dd9fcb5e27..88c3c8e1dd 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -23,28 +23,29 @@ animation-name: button-jump-up; width: 145px; text-align: center; - position: relative; margin-bottom: 0px; + position: absolute; + right: 0; + top: -2em; .btn { margin: 0; } } #topic-progress-wrapper { + display: flex; .topic-admin-menu-button-container { - position: absolute; - bottom: 0px; - left: -38px; - width: 0px; - .widget-button { - height: 35px; - border-right: 1px solid dark-light-diff($primary, $secondary, 80%, -70%); + display: flex; + > span { + display: flex; } } .topic-admin-popup-menu.right-side { - position: relative; - right: 50px; + position: absolute; + bottom: 0; + right: 0; left: auto; + transition: bottom 0.5s; transform: translateZ( 0 ); // iOS11 Rendering bug https://meta.discourse.org/t/wrench-menu-not-disappearing-on-ios/94297 diff --git a/app/assets/stylesheets/common/components/footer-nav.scss b/app/assets/stylesheets/common/components/footer-nav.scss index 2691951d91..01b6856825 100644 --- a/app/assets/stylesheets/common/components/footer-nav.scss +++ b/app/assets/stylesheets/common/components/footer-nav.scss @@ -78,6 +78,10 @@ body.footer-nav-ipad { padding-bottom: 0; // resets safe-area-inset-bottom } + #reply-control.fullscreen { + z-index: z("ipad-header-nav") + 1; + } + &.docked .d-header { margin-top: $footer-nav-height; } diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index f4b4976519..386f7b44eb 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -241,6 +241,9 @@ a.toggle-preview { &.fullscreen { // important needed because of inline styles when height is changed manually with grippie height: 100vh !important; + @supports (--custom: property) { + height: calc(var(--composer-vh, 1vh) * 100) !important; + } z-index: z("header") + 1; .d-editor-preview-wrapper { margin-top: 1%; @@ -279,3 +282,9 @@ a.toggle-preview { } } } + +.fullscreen-composer.keyboard-visible { + #reply-control.fullscreen { + top: 0px; + } +} diff --git a/app/assets/stylesheets/embed.scss b/app/assets/stylesheets/embed.scss index 286570b081..c4c7046463 100644 --- a/app/assets/stylesheets/embed.scss +++ b/app/assets/stylesheets/embed.scss @@ -196,10 +196,10 @@ div.lightbox-wrapper { } } - .topic-details-flex { + .topic-column-wrapper { display: flex; - .topic-details-column-1 { + .topic-column.details-column { flex-direction: column; width: 80%; @@ -225,7 +225,7 @@ div.lightbox-wrapper { } } - .topic-details-column-2 { + .topic-column.featured-image-column { .topic-featured-image img { max-width: 200px; max-height: 100px; diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index f9da09f0ad..4c8c05bf03 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -21,6 +21,14 @@ height: 250px; &.edit-title { height: 100%; + height: calc(var(--composer-vh, 1vh) * 100); + } + } + + html.keyboard-visible &.open { + height: calc(var(--composer-vh, 1vh) * 100); + .reply-area { + padding-bottom: 0px; } } diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index ad07cfb044..c1367e3454 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -50,17 +50,12 @@ #topic-progress-wrapper { position: fixed; - width: 0; right: 0; bottom: 0; z-index: z("timeline"); - margin-right: 148px; &:not(.docked) { margin-bottom: env(safe-area-inset-bottom); } - .topic-admin-menu-button-container .toggle-admin-menu { - height: 43px; - } } #topic-progress-expanded { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 069b3c097d..7940565ea5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -64,7 +64,7 @@ class ApplicationController < ActionController::Base after_action :remember_theme_id def remember_theme_id - if @theme_ids.present? + if @theme_ids.present? && request.format == "html" Stylesheet::Watcher.theme_id = @theme_ids.first if defined? Stylesheet::Watcher end end diff --git a/app/controllers/embed_controller.rb b/app/controllers/embed_controller.rb index c4bde730a5..5490fa59fa 100644 --- a/app/controllers/embed_controller.rb +++ b/app/controllers/embed_controller.rb @@ -8,7 +8,7 @@ class EmbedController < ApplicationController skip_before_action :check_xhr, :preload_json, :verify_authenticity_token before_action :ensure_embeddable, except: [ :info, :topics ] - before_action :get_embeddable_css_class, except: [ :info, :topics ] + before_action :prepare_embeddable, except: [ :info ] before_action :ensure_api_request, only: [ :info ] layout 'embed' @@ -123,10 +123,13 @@ class EmbedController < ApplicationController private - def get_embeddable_css_class + def prepare_embeddable @embeddable_css_class = "" embeddable_host = EmbeddableHost.record_for_url(request.referer) @embeddable_css_class = " class=\"#{embeddable_host.class_name}\"" if embeddable_host.present? && embeddable_host.class_name.present? + + @data_referer = request.referer + @data_referer = '*' if SiteSetting.embed_any_origin? && @data_referer.blank? end def ensure_api_request diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index 189852afb1..279cb99345 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -47,11 +47,16 @@ class MetadataController < ApplicationController } logo = SiteSetting.site_manifest_icon_url - manifest[:icons] << { - src: UrlHelper.absolute(logo), - sizes: "512x512", - type: MiniMime.lookup_by_filename(logo)&.content_type || "image/png" - } if logo + if logo + icon_entry = { + src: UrlHelper.absolute(logo), + sizes: "512x512", + type: MiniMime.lookup_by_filename(logo)&.content_type || "image/png" + } + manifest[:icons] << icon_entry.dup + icon_entry[:purpose] = "maskable" + manifest[:icons] << icon_entry + end manifest[:short_name] = SiteSetting.short_title if SiteSetting.short_title.present? diff --git a/app/controllers/post_readers_controller.rb b/app/controllers/post_readers_controller.rb index a5fd76fafc..fdba0605a2 100644 --- a/app/controllers/post_readers_controller.rb +++ b/app/controllers/post_readers_controller.rb @@ -4,15 +4,20 @@ class PostReadersController < ApplicationController requires_login def index - post = Post.includes(topic: %i[allowed_groups]).find(params[:id]) - read_state = post.topic.allowed_groups.any? { |g| g.publish_read_state? && g.users.include?(current_user) } - raise Discourse::InvalidAccess unless read_state + post = Post.includes(topic: %i[topic_allowed_groups topic_allowed_users]).find(params[:id]) + ensure_can_see_readers!(post) readers = User + .where(staged: false) + .where.not(id: post.user_id) .joins(:topic_users) .where.not(topic_users: { last_read_post_number: nil }) .where('topic_users.topic_id = ? AND topic_users.last_read_post_number >= ?', post.topic_id, post.post_number) - .where.not(id: [current_user.id, post.user_id]) + + if post.whisper? + non_group_members = post.topic.topic_allowed_users.map(&:user_id) + readers = readers.where.not(id: non_group_members) + end readers = readers.map do |r| { @@ -24,4 +29,15 @@ class PostReadersController < ApplicationController render_json_dump(post_readers: readers) end + + private + + def ensure_can_see_readers!(post) + show_readers = GroupUser + .where(user: current_user) + .joins(:group) + .where(groups: { id: post.topic.topic_allowed_groups.map(&:group_id), publish_read_state: true }).exists? + + raise Discourse::InvalidAccess unless show_readers + end end diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index 489dabd865..2c133273cf 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -4,19 +4,20 @@ class UserActionsController < ApplicationController def index params.require(:username) - params.permit(:filter, :offset, :acting_username) + params.permit(:filter, :offset, :acting_username, :limit) user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) raise Discourse::NotFound unless guardian.can_see_profile?(user) offset = [0, params[:offset].to_i].max action_types = (params[:filter] || "").split(",").map(&:to_i) + limit = params.fetch(:limit, 30).to_i opts = { user_id: user.id, user: user, offset: offset, - limit: 30, + limit: limit, action_types: action_types, guardian: guardian, ignore_private_messages: params[:filter] ? false : true, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index da400520d2..7a6225a15a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -141,6 +141,10 @@ class UsersController < ApplicationController def username params.require(:new_username) + if clashing_with_existing_route?(params[:new_username]) || User.reserved_username?(params[:new_username]) + return render_json_error(I18n.t("login.reserved_username")) + end + user = fetch_user_from_params guardian.ensure_can_edit_username!(user) @@ -359,7 +363,7 @@ class UsersController < ApplicationController return fail_with("login.email_too_long") end - if User.reserved_username?(params[:username]) + if clashing_with_existing_route?(params[:username]) || User.reserved_username?(params[:username]) return fail_with("login.reserved_username") end @@ -1355,4 +1359,18 @@ class UsersController < ApplicationController end end + def clashing_with_existing_route?(username) + normalized_username = User.normalize_username(username) + http_verbs = %w[GET POST PUT DELETE PATCH] + allowed_actions = %w[show update destroy] + + http_verbs.any? do |verb| + begin + path = Rails.application.routes.recognize_path("/u/#{normalized_username}", method: verb) + allowed_actions.exclude?(path[:action]) + rescue ActionController::RoutingError + false + end + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ff7cc87227..34f92d7985 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -384,7 +384,7 @@ module ApplicationHelper return "" if erbs.blank? result = +"" - erbs.each { |erb| result << render(file: erb) } + erbs.each { |erb| result << render(inline: File.read(erb)) } result.html_safe end @@ -437,17 +437,14 @@ module ApplicationHelper def theme_lookup(name) Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name) - &.html_safe end def theme_translations_lookup Theme.lookup_field(theme_ids, :translations, I18n.locale) - &.html_safe end def theme_js_lookup Theme.lookup_field(theme_ids, :extra_js, nil) - &.html_safe end def discourse_stylesheet_link_tag(name, opts = {}) diff --git a/app/jobs/regular/automatic_group_membership.rb b/app/jobs/regular/automatic_group_membership.rb index 9335b6e34a..8695737ab2 100644 --- a/app/jobs/regular/automatic_group_membership.rb +++ b/app/jobs/regular/automatic_group_membership.rb @@ -22,7 +22,7 @@ module Jobs .where(staged: false) .find_each do |user| next unless user.email_confirmed? - group.add(user) + group.add(user, automatic: true) GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) end diff --git a/app/jobs/regular/pull_hotlinked_images.rb b/app/jobs/regular/pull_hotlinked_images.rb index 46457fe8fc..c741b659d4 100644 --- a/app/jobs/regular/pull_hotlinked_images.rb +++ b/app/jobs/regular/pull_hotlinked_images.rb @@ -148,7 +148,7 @@ module Jobs if start_raw == post.raw && raw != post.raw changes = { raw: raw, edit_reason: I18n.t("upload.edit_reason") } - post.revise(Discourse.system_user, changes, bypass_bump: true) + post.revise(Discourse.system_user, changes, bypass_bump: true, skip_staff_log: true) elsif has_downloaded_image || has_new_large_image || has_new_broken_image post.trigger_post_process(bypass_bump: true) end diff --git a/app/jobs/scheduled/pending_reviewables_reminder.rb b/app/jobs/scheduled/pending_reviewables_reminder.rb index a0b4c6597c..92642b0327 100644 --- a/app/jobs/scheduled/pending_reviewables_reminder.rb +++ b/app/jobs/scheduled/pending_reviewables_reminder.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_dependency 'flag_query' - module Jobs class PendingReviewablesReminder < Jobs::Scheduled diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb index 9bf10f93c0..80b858a11a 100644 --- a/app/jobs/scheduled/poll_mailbox.rb +++ b/app/jobs/scheduled/poll_mailbox.rb @@ -34,7 +34,7 @@ module Jobs if SiteSetting.pop3_polling_ssl if SiteSetting.pop3_polling_openssl_verify - pop3.enable_ssl + pop3.enable_ssl(max_version: OpenSSL::SSL::TLS1_2_VERSION) else pop3.enable_ssl(OpenSSL::SSL::VERIFY_NONE) end diff --git a/app/jobs/scheduled/reviewable_priorities.rb b/app/jobs/scheduled/reviewable_priorities.rb index 49f4aa2cd8..b1133a89e4 100644 --- a/app/jobs/scheduled/reviewable_priorities.rb +++ b/app/jobs/scheduled/reviewable_priorities.rb @@ -3,15 +3,33 @@ class Jobs::ReviewablePriorities < Jobs::Scheduled every 1.day - def execute(args) + # We need this many reviewables before we'll calculate priorities + def self.min_reviewables + 15 + end - # We calculate the percentiles here for medium and high. Low is always 0 (all) - res = DB.query_single(<<~SQL) + # We want to look at scores for items with this many reviewables (flags) attached + def self.target_count + 2 + end + + def execute(args) + return unless Reviewable.where('score > 0').count >= self.class.min_reviewables + + res = DB.query_single(<<~SQL, target_count: self.class.target_count) SELECT COALESCE(PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY score), 0.0) AS medium, COALESCE(PERCENTILE_DISC(0.85) WITHIN GROUP (ORDER BY score), 0.0) AS high - FROM reviewables + FROM ( + SELECT r.score + FROM reviewables AS r + INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id + GROUP BY r.id + HAVING COUNT(*) >= :target_count + ) AS x SQL + return unless res && res.size == 2 + medium, high = res Reviewable.set_priorities(medium: medium, high: high) diff --git a/app/models/category.rb b/app/models/category.rb index 6009164df8..af7b073166 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -261,7 +261,8 @@ class Category < ActiveRecord::Base @@cache ||= LruRedux::ThreadSafeCache.new(1000) @@cache.getset(self.description) do - Nokogiri::HTML.fragment(self.description).text.strip.html_safe + text = Nokogiri::HTML.fragment(self.description).text.strip + Rack::Utils.escape_html(text).html_safe end end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 34b32ee159..89f8daf2d3 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -65,7 +65,8 @@ class DirectoryItem < ActiveRecord::Base 0 FROM users u LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type - WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL and u.active + WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active + #{SiteSetting.must_approve_users ? 'AND u.approved' : ''} ", period_type: period_types[period_type] # Calculate new values and update records diff --git a/app/models/group.rb b/app/models/group.rb index a04cdef63e..c3f632acd1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -613,7 +613,7 @@ class Group < ActiveRecord::Base PUBLISH_CATEGORIES_LIMIT = 10 - def add(user, notify: false) + def add(user, notify: false, automatic: false) self.users.push(user) unless self.users.include?(user) if notify @@ -635,12 +635,15 @@ class Group < ActiveRecord::Base Discourse.request_refresh!(user_ids: [user.id]) end + DiscourseEvent.trigger(:user_added_to_group, user, self, automatic: automatic) + self end def remove(user) self.group_users.where(user: user).each(&:destroy) user.update_columns(primary_group_id: nil) if user.primary_group_id == self.id + DiscourseEvent.trigger(:user_removed_from_group, user, self) end def add_owner(user) diff --git a/app/models/post.rb b/app/models/post.rb index 56d96b0c33..2d2cb253e4 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -116,10 +116,19 @@ class Post < ActiveRecord::Base } scope :have_uploads, -> { - where( - "(posts.cooked LIKE '% 1 && !node_children_names(node).include?("img")) - next end @@ -34,7 +33,7 @@ class InlineUploads if (actual_link = (node.attributes["href"]&.value || node.attributes["src"]&.value)) link_occurences << { link: actual_link, is_valid: true } elsif node.name != "p" - link_occurences << { link: actual_link, is_valid: false } + link_occurences << { link: seen_link, is_valid: false } end end end @@ -64,7 +63,7 @@ class InlineUploads ] if Discourse.store.external? - regexps << /((https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/ + regexps << /((?:https?:)?#{SiteSetting.Upload.s3_base_url}#{UPLOAD_REGEXP_PATTERN})/ regexps << /(#{SiteSetting.Upload.s3_cdn_url}#{UPLOAD_REGEXP_PATTERN})/ end @@ -72,8 +71,8 @@ class InlineUploads indexes = Set.new markdown.scan(/(\n{2,}|\A)#{regexp}$/) do |match| - if match[1].present? - extension = match[1].split(".")[-1].downcase + if match[1].present? && match[2].present? + extension = match[2].split(".")[-1].downcase index = $~.offset(2)[0] indexes << index if FileHelper.supported_images.include?(extension) @@ -87,36 +86,20 @@ class InlineUploads markdown.scan(/^#{regexp}(\s)/) do |match| if match[0].present? index = $~.offset(0)[0] - next if indexes.include?(index) - indexes << index - - raw_matches << [ - match[0], - match[0], - +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", - $~.offset(0)[0] - ] + next if !indexes.add?(index) + raw_matches << [match[0], match[0], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index] end end markdown.scan(/\[[^\[\]]*\]: #{regexp}/) do |match| - if match[0].present? - index = $~.offset(1)[0] - next if indexes.include?(index) - indexes << index - end + indexes.add($~.offset(1)[0]) if match[0].present? end markdown.scan(/(([\n\s\)\]\<])+)#{regexp}/) do |match| if matched_uploads(match[2]).present? - next if indexes.include?($~.offset(3)[0]) - - raw_matches << [ - match[2], - match[2], - +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", - $~.offset(0)[0] - ] + next if !indexes.add?($~.offset(3)[0]) + index = $~.offset(0)[0] + raw_matches << [match[2], match[2], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index] end end end @@ -160,7 +143,7 @@ class InlineUploads end end - markdown.scan(/(__([a-f0-9]{40})__)/) do |match| + markdown.scan(/(__(\h{40})__)/) do |match| upload = Upload.find_by(sha1: match[1]) markdown = markdown.sub(match[0], upload.short_path) end @@ -182,7 +165,7 @@ class InlineUploads end def self.match_bbcode_img(markdown, external_src: false) - markdown.scan(/(\[img\]\s*([^\[\]\s]+)\s*\[\/img\])/) do |match| + markdown.scan(/(\[img\]\s*([^\[\]\s]+)\s*\[\/img\])/i) do |match| if (matched_uploads(match[1]).present? && block_given?) || external_src yield(match[0], match[1], +"![](#{PLACEHOLDER})", $~.offset(0)[0]) end @@ -203,7 +186,7 @@ class InlineUploads end def self.match_anchor(markdown, external_href: false) - markdown.scan(/(()([^<\a>]*?)<\/a>)/) do |match| + markdown.scan(/(()([^<\a>]*?)<\/a>)/i) do |match| node = Nokogiri::HTML::fragment(match[0]).children[0] href = node.attributes["href"]&.value @@ -219,8 +202,8 @@ class InlineUploads end def self.match_img(markdown, external_src: false) - markdown.scan(/(([ ]*)<(?!img)[^<>]+\/?>)?([\r\n]*)(([ ]*)\n]+)>([ ]*))([\r\n]*)/) do |match| - node = Nokogiri::HTML::fragment(match[3].strip).children[0] + markdown.scan(/(<(?!img)[^<>]+\/?>)?(\s*)(\n]+>)/i) do |match| + node = Nokogiri::HTML::fragment(match[2].strip).children[0] src = node.attributes["src"]&.value if src && (matched_uploads(src).present? || external_src) @@ -229,50 +212,17 @@ class InlineUploads height = node.attributes["height"]&.value.to_i title = node.attributes["title"]&.value text = "#{text}|#{width}x#{height}" if width > 0 && height > 0 - after_html_tag = match[0].present? + spaces_before = match[1].present? ? match[1][/ +$/].size : 0 + replacement = +"#{" " * spaces_before}![#{text}](#{PLACEHOLDER}#{title.present? ? " \"#{title}\"" : ""})" - spaces_before = - if after_html_tag && !match[0].end_with?("/>") - (match[4].length > 0 ? match[4] : " ") - else - "" - end - - replacement = +"#{spaces_before}![#{text}](#{PLACEHOLDER}#{title.present? ? " \"#{title}\"" : ""})" - - if after_html_tag && (num_newlines = match[2].length) <= 1 - replacement.prepend("\n" * (num_newlines == 0 ? 2 : 1)) - end - - if after_html_tag && !match[0].end_with?("/>") && (num_newlines = match[7].length) <= 1 - replacement += ("\n" * (num_newlines == 0 ? 2 : 1)) - end - - match[3].strip! if !after_html_tag - - if (match[1].nil? || match[1].length < 4) - if (match[4].nil? || match[4].length < 4) - yield(match[3], src, replacement, $~.offset(0)[0]) if block_given? - else - yield(match[3], src, match[3].sub(src, PATH_PLACEHOLDER), $~.offset(0)[0]) if block_given? - end - else - yield(match[3], src, match[3].sub(src, PATH_PLACEHOLDER), $~.offset(0)[0]) if block_given? - end + yield(match[2], src, replacement, $~.offset(0)[0]) if block_given? end end end def self.matched_uploads(node) - matches = [] - - base_url = Discourse.base_url.sub(/https?:\/\//, "(https?://)") - - if GlobalSetting.cdn_url - cdn_url = GlobalSetting.cdn_url.sub(/https?:\/\//, "(https?://)") - end - db = RailsMultisite::ConnectionManagement.current_db + base_url = Discourse.base_url.sub(/https?:\/\//, "(https?://)") regexps = [ /(upload:\/\/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)/, @@ -282,7 +232,12 @@ class InlineUploads /(#{base_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/, ] - regexps << /(#{cdn_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/ if cdn_url + if GlobalSetting.cdn_url && (cdn_url = GlobalSetting.cdn_url.sub(/https?:\/\//, "(https?://)")) + regexps << /(#{cdn_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/ + if GlobalSetting.relative_url_root.present? + regexps << /(#{cdn_url}#{GlobalSetting.relative_url_root}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/ + end + end if Discourse.store.external? if Rails.configuration.multisite @@ -294,6 +249,7 @@ class InlineUploads end end + matches = [] node = node.to_s regexps.each do |regexp| diff --git a/app/services/user_authenticator.rb b/app/services/user_authenticator.rb index c2361f6a5d..d988300ceb 100644 --- a/app/services/user_authenticator.rb +++ b/app/services/user_authenticator.rb @@ -4,7 +4,8 @@ class UserAuthenticator def initialize(user, session, authenticator_finder = Users::OmniauthCallbacksController) @user = user - @session = session[:authentication] + @session = session + @auth_session = session[:authentication] @authenticator_finder = authenticator_finder end @@ -15,7 +16,7 @@ class UserAuthenticator @user.password_required! end - @user.skip_email_validation = true if @session && @session[:skip_email_validation].present? + @user.skip_email_validation = true if @auth_session && @auth_session[:skip_email_validation].present? end def has_authenticator? @@ -24,18 +25,18 @@ class UserAuthenticator def finish if authenticator - authenticator.after_create_account(@user, @session) + authenticator.after_create_account(@user, @auth_session) confirm_email end - @session = nil + @session[:authentication] = @auth_session = nil if @auth_session end def email_valid? - @session && @session[:email_valid] + @auth_session && @auth_session[:email_valid] end def authenticated? - @session && @session[:email]&.downcase == @user.email.downcase && @session[:email_valid].to_s == "true" + @auth_session && @auth_session[:email]&.downcase == @user.email.downcase && @auth_session[:email_valid].to_s == "true" end private @@ -54,7 +55,7 @@ class UserAuthenticator end def authenticator_name - @session && @session[:authenticator_name] + @auth_session && @auth_session[:authenticator_name] end end diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index 690f3a2711..b6f64114e7 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -12,6 +12,6 @@ <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> -<%- Discourse.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?).each do |file| %> +<%- Discourse.find_plugin_css_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, mobile_view: mobile_view?, desktop_view: !mobile_view?).each do |file| %> <%= discourse_stylesheet_link_tag(file) %> <%- end %> diff --git a/app/views/embed/topics.html.erb b/app/views/embed/topics.html.erb index 5a4660165a..2aaf326796 100644 --- a/app/views/embed/topics.html.erb +++ b/app/views/embed/topics.html.erb @@ -4,8 +4,8 @@
<%- if @template == "complete" %>