diff --git a/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.hbs b/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.hbs new file mode 100644 index 00000000000..629092f1ce0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.hbs @@ -0,0 +1,3 @@ +{{#if this.loadingSlider.stillLoading}} +
{{loading-spinner}}
+{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.js b/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.js new file mode 100644 index 00000000000..5cb2fddc892 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/loading-slider-fallback-spinner.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class LoadingSliderFallbackSpinner extends Component { + @service loadingSlider; +} diff --git a/app/assets/javascripts/discourse/app/components/page-loading-slider.hbs b/app/assets/javascripts/discourse/app/components/page-loading-slider.hbs new file mode 100644 index 00000000000..f895371072f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/page-loading-slider.hbs @@ -0,0 +1,17 @@ +{{#if this.loadingSlider.enabled}} +
+
+
+
+{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/page-loading-slider.js b/app/assets/javascripts/discourse/app/components/page-loading-slider.js new file mode 100644 index 00000000000..d3c9577e085 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/page-loading-slider.js @@ -0,0 +1,67 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { cancel, next } from "@ember/runloop"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { bind } from "discourse-common/utils/decorators"; +import { htmlSafe } from "@ember/template"; + +export default class extends Component { + @service loadingSlider; + @service capabilities; + + @tracked state = "ready"; + + constructor() { + super(...arguments); + this.loadingSlider.on("stateChanged", this.stateChanged); + } + + @bind + stateChanged(loading) { + if (this._deferredStateChange) { + cancel(this._deferredStateChange); + this._deferredStateChange = null; + } + + if (loading && this.ready) { + this.state = "loading"; + } else if (loading) { + this.state = "ready"; + this._deferredStateChange = next(() => (this.state = "loading")); + } else { + this.state = "done"; + } + } + + destroy() { + this.loadingSlider.off("stateChange", this, "stateChange"); + super.destroy(); + } + + @action + onContainerTransitionEnd(event) { + if ( + event.target === event.currentTarget && + event.propertyName === "opacity" + ) { + this.state = "ready"; + } + } + + @action + onBarTransitionEnd(event) { + if ( + event.target === event.currentTarget && + event.propertyName === "transform" && + this.state === "loading" + ) { + this.state = "still-loading"; + } + } + + get containerStyle() { + const duration = this.loadingSlider.averageLoadingDuration.toFixed(2); + return htmlSafe(`--loading-duration: ${duration}s`); + } +} diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js index 793b187ab59..a869581c590 100644 --- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js +++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js @@ -309,6 +309,7 @@ export default MountWidget.extend({ } } this.queueRerender(); + this._scrollTriggered(); }, @bind @@ -370,6 +371,10 @@ export default MountWidget.extend({ this.appEvents.off("post-stream:posted", this, "_posted"); }, + didUpdateAttrs() { + this._refresh(); + }, + _handleWidgetButtonHoverState(event) { if (event.target.classList.contains("widget-button")) { document diff --git a/app/assets/javascripts/discourse/app/components/topic-list-item.js b/app/assets/javascripts/discourse/app/components/topic-list-item.js index 7ff0c6f0d3f..33722e1979a 100644 --- a/app/assets/javascripts/discourse/app/components/topic-list-item.js +++ b/app/assets/javascripts/discourse/app/components/topic-list-item.js @@ -34,7 +34,11 @@ export function showEntrance(e) { } export function navigateToTopic(topic, href) { - this.appEvents.trigger("header:update-topic", topic); + if (this.siteSettings.page_loading_indicator !== "slider") { + // With the slider, it feels nicer for the header to update once the rest of the topic content loads, + // so skip setting it early. + this.appEvents.trigger("header:update-topic", topic); + } DiscourseURL.routeTo(href || topic.get("url")); return false; } diff --git a/app/assets/javascripts/discourse/app/controllers/discovery.js b/app/assets/javascripts/discourse/app/controllers/discovery.js index ae0570b28bf..f2cad393286 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery.js @@ -60,6 +60,13 @@ export default Controller.extend({ return `${url}?${urlSearchParams.toString()}`; }, + get showLoadingSpinner() { + return ( + this.get("loading") && + this.siteSettings.page_loading_indicator === "spinner" + ); + }, + actions: { changePeriod(p) { DiscourseURL.routeTo(this.showMoreUrl(p)); diff --git a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js index d5c13c8451d..ba3791eeeb2 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js @@ -4,7 +4,6 @@ import DismissTopics from "discourse/mixins/dismiss-topics"; import DiscoveryController from "discourse/controllers/discovery"; import I18n from "I18n"; import Topic from "discourse/models/topic"; -import TopicList from "discourse/models/topic-list"; import { inject as controller } from "@ember/controller"; import deprecated from "discourse-common/lib/deprecated"; import discourseComputed from "discourse-common/utils/decorators"; @@ -106,41 +105,11 @@ const controllerOpts = { ); return routeAction("changeSort", this.router._router, ...arguments)(); }, + }, - refresh(options = { skipResettingParams: [] }) { - const filter = this.get("model.filter"); - this.send("resetParams", options.skipResettingParams); - - // Don't refresh if we're still loading - if (this.discovery.loading) { - return; - } - - // If we `send('loading')` here, due to returning true it bubbles up to the - // router and ember throws an error due to missing `handlerInfos`. - // Lesson learned: Don't call `loading` yourself. - this.discovery.loadingBegan(); - - this.topicTrackingState.resetTracking(); - - this.store.findFiltered("topicList", { filter }).then((list) => { - TopicList.hideUniformCategory(list, this.category); - - // If query params are present in the current route, we need still need to sync topic - // tracking with the topicList without any query params. Then we set the topic - // list to the list filtered with query params in the afterRefresh. - const params = this.router.currentRoute.queryParams; - if (Object.keys(params).length) { - this.store - .findFiltered("topicList", { filter, params }) - .then((listWithParams) => { - this.afterRefresh(filter, list, listWithParams); - }); - } else { - this.afterRefresh(filter, list); - } - }); - }, + @action + refresh() { + this.send("triggerRefresh"); }, afterRefresh(filter, list, listModel = list) { diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js index b0022eb5a26..33c165e3583 100644 --- a/app/assets/javascripts/discourse/app/routes/application.js +++ b/app/assets/javascripts/discourse/app/routes/application.js @@ -14,6 +14,7 @@ import { inject as service } from "@ember/service"; import { setting } from "discourse/lib/computed"; import showModal from "discourse/lib/show-modal"; import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help"; +import { action } from "@ember/object"; function unlessReadOnly(method, message) { return function () { @@ -42,6 +43,20 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, { dialog: service(), composer: service(), modal: service(), + loadingSlider: service(), + + @action + loading(transition) { + if (this.loadingSlider.enabled) { + this.loadingSlider.transitionStarted(); + transition.promise.finally(() => { + this.loadingSlider.transitionEnded(); + }); + return false; + } else { + return true; // Use native ember loading implementation + } + }, actions: { toggleAnonymous() { diff --git a/app/assets/javascripts/discourse/app/routes/discovery.js b/app/assets/javascripts/discourse/app/routes/discovery.js index 1951755782f..f3047a999c9 100644 --- a/app/assets/javascripts/discourse/app/routes/discovery.js +++ b/app/assets/javascripts/discourse/app/routes/discovery.js @@ -98,4 +98,9 @@ export default DiscourseRoute.extend(OpenComposer, { includeSubcategories: !controller.noSubcategories, }); }, + + @action + triggerRefresh() { + this.refresh(); + }, }); diff --git a/app/assets/javascripts/discourse/app/services/loading-slider.js b/app/assets/javascripts/discourse/app/services/loading-slider.js new file mode 100644 index 00000000000..08f1bb04e14 --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/loading-slider.js @@ -0,0 +1,129 @@ +import Service, { inject as service } from "@ember/service"; +import Evented from "@ember/object/evented"; +import { cancel, later, schedule } from "@ember/runloop"; +import { tracked } from "@glimmer/tracking"; +import { bind } from "discourse-common/utils/decorators"; +import { disableImplicitInjections } from "discourse/lib/implicit-injections"; + +const STORE_LOADING_TIMES = 5; +const DEFAULT_LOADING_TIME = 0.3; +const MIN_LOADING_TIME = 0.1; + +const STILL_LOADING_DURATION = 2; + +class RollingAverage { + @tracked average; + #values = []; + #i = 0; + #size; + + constructor(size, initialAverage) { + this.#size = size; + this.average = initialAverage; + } + + record(value) { + this.#values[this.#i] = value; + this.#i = (this.#i + 1) % this.#size; + this.average = + this.#values.reduce((p, c) => p + c, 0) / this.#values.length; + } +} + +class ScheduleManager { + #scheduled = []; + + cancelAll() { + this.#scheduled.forEach((s) => cancel(s)); + this.#scheduled = []; + } + + schedule() { + this.#scheduled.push(schedule(...arguments)); + } + + later() { + this.#scheduled.push(later(...arguments)); + } +} + +class Timer { + #startedAt; + + start() { + this.#startedAt = Date.now(); + } + + stop() { + return (Date.now() - this.#startedAt) / 1000; + } +} + +@disableImplicitInjections +export default class LoadingSlider extends Service.extend(Evented) { + @service siteSettings; + @tracked loading = false; + @tracked stillLoading = false; + + rollingAverage = new RollingAverage( + STORE_LOADING_TIMES, + DEFAULT_LOADING_TIME + ); + + scheduleManager = new ScheduleManager(); + + timer = new Timer(); + + get enabled() { + return this.siteSettings.page_loading_indicator === "slider"; + } + + get averageLoadingDuration() { + return this.rollingAverage.average; + } + + transitionStarted() { + this.timer.start(); + this.loading = true; + this.trigger("stateChanged", true); + + this.scheduleManager.cancelAll(); + + this.scheduleManager.later( + this.setStillLoading, + STILL_LOADING_DURATION * 1000 + ); + } + + @bind + transitionEnded() { + let duration = this.timer.stop(); + if (duration < MIN_LOADING_TIME) { + duration = MIN_LOADING_TIME; + } + this.rollingAverage.record(duration); + + this.loading = false; + this.stillLoading = false; + this.trigger("stateChanged", false); + + this.scheduleManager.cancelAll(); + this.scheduleManager.schedule("afterRender", this.removeClasses); + } + + @bind + setStillLoading() { + this.stillLoading = true; + this.scheduleManager.schedule("afterRender", this.addStillLoadingClass); + } + + @bind + addStillLoadingClass() { + document.body.classList.add("still-loading"); + } + + @bind + removeClasses() { + document.body.classList.remove("loading", "still-loading"); + } +} diff --git a/app/assets/javascripts/discourse/app/templates/application.hbs b/app/assets/javascripts/discourse/app/templates/application.hbs index 2a90c6694ca..1a7df932747 100644 --- a/app/assets/javascripts/discourse/app/templates/application.hbs +++ b/app/assets/javascripts/discourse/app/templates/application.hbs @@ -1,6 +1,7 @@ + + +
diff --git a/app/assets/javascripts/discourse/app/templates/discovery.hbs b/app/assets/javascripts/discourse/app/templates/discovery.hbs index 39f859fbfd4..2eaecdf418a 100644 --- a/app/assets/javascripts/discourse/app/templates/discovery.hbs +++ b/app/assets/javascripts/discourse/app/templates/discovery.hbs @@ -22,13 +22,13 @@
- + -
+
diff --git a/app/assets/javascripts/discourse/tests/acceptance/loading-indicator-test.js b/app/assets/javascripts/discourse/tests/acceptance/loading-indicator-test.js new file mode 100644 index 00000000000..fa2ad0314f4 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/loading-indicator-test.js @@ -0,0 +1,99 @@ +import { + currentRouteName, + getSettledState, + settled, + visit, + waitUntil, +} from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import pretender from "discourse/tests/helpers/create-pretender"; +import AboutFixtures from "discourse/tests/fixtures/about"; + +// Like settled(), but ignores timers, transitions and network requests +function isMostlySettled() { + let { hasRunLoop, hasPendingWaiters, isRenderPending } = getSettledState(); + + if (hasRunLoop || hasPendingWaiters || isRenderPending) { + return false; + } else { + return true; + } +} + +function mostlySettled() { + return waitUntil(isMostlySettled); +} + +acceptance("Page Loading Indicator", function (needs) { + let pendingRequest; + let resolvePendingRequest; + + needs.pretender((server, helper) => { + pendingRequest = new Promise( + (resolve) => (resolvePendingRequest = resolve) + ); + + pretender.get( + "/about.json", + (request) => { + resolvePendingRequest(request); + return helper.response(AboutFixtures["about.json"]); + }, + true // Require manual resolution + ); + }); + + test("it works in 'spinner' mode", async function (assert) { + this.siteSettings.page_loading_indicator = "spinner"; + + await visit("/"); + visit("/about"); + + const aboutRequest = await pendingRequest; + await mostlySettled(); + + assert.strictEqual(currentRouteName(), "about_loading"); + assert.dom("#main-outlet > div.spinner").exists(); + assert.dom(".loading-indicator-container").doesNotExist(); + + pretender.resolve(aboutRequest); + await settled(); + + assert.strictEqual(currentRouteName(), "about"); + assert.dom("#main-outlet > div.spinner").doesNotExist(); + assert.dom("#main-outlet section.about").exists(); + }); + + test("it works in 'slider' mode", async function (assert) { + this.siteSettings.page_loading_indicator = "slider"; + + await visit("/"); + + assert.dom(".loading-indicator-container").exists(); + assert.dom(".loading-indicator-container").hasClass("ready"); + + visit("/about"); + + const aboutRequest = await pendingRequest; + await mostlySettled(); + + assert.strictEqual(currentRouteName(), "discovery.latest"); + assert.dom("#main-outlet > div.spinner").doesNotExist(); + + await waitUntil(() => + query(".loading-indicator-container").classList.contains("loading") + ); + + pretender.resolve(aboutRequest); + + await waitUntil(() => + query(".loading-indicator-container").classList.contains("done") + ); + + await settled(); + + assert.strictEqual(currentRouteName(), "about"); + assert.dom("#main-outlet section.about").exists(); + }); +}); diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index f7ea5d544ef..6f998ad5850 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -17,3 +17,4 @@ @import "common/d-editor"; @import "common/software-update-prompt"; @import "common/topic-timeline"; +@import "common/loading-slider"; diff --git a/app/assets/stylesheets/common/loading-slider.scss b/app/assets/stylesheets/common/loading-slider.scss new file mode 100644 index 00000000000..dc4be5e46aa --- /dev/null +++ b/app/assets/stylesheets/common/loading-slider.scss @@ -0,0 +1,74 @@ +.loading-indicator-container { + --loading-width: 0.8; + --still-loading-width: 0.9; + + --still-loading-duration: 10s; + --done-duration: 0.4s; + --fade-out-duration: 0.4s; + + position: fixed; + top: 0; + left: 0; + z-index: z("header") + 1; + + height: 3px; + width: 100%; + + opacity: 0; + transition: opacity var(--fade-out-duration) ease var(--done-duration); + + background-color: var(--primary-low); + + .loading-indicator { + height: 100%; + width: 100%; + transform: scaleX(0); + transform-origin: left; + background-color: var(--tertiary); + } + + &.loading, + &.still-loading { + opacity: 1; + transition: opacity 0s; + } + + &.loading .loading-indicator { + transition: transform var(--loading-duration) ease-in; + transform: scaleX(var(--loading-width)); + } + + &.still-loading .loading-indicator { + transition: transform var(--still-loading-duration) linear; + transform: scaleX(var(--still-loading-width)); + } + + &.done .loading-indicator { + transition: transform var(--done-duration) ease-out; + transform: scaleX(1); + } + + &.discourse-hub-webview { + // DiscourseHub obscures the top 1px to work around an iOS bug + top: 1px; + } + + body.footer-nav-ipad & { + top: var(--footer-nav-height); + } +} + +.loading-slider-fallback-spinner { + padding-top: 1.8em; + display: none; +} + +body.still-loading { + .loading-slider-fallback-spinner { + display: block; + } + + #main-outlet { + display: none; + } +} diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 50c51db93eb..94c52da0c77 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2435,6 +2435,8 @@ en: experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter" experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer" + page_loading_indicator: "Configure the loading indicator which appears during page navigations within Discourse. 'Spinner' is a full page indicator. 'Slider' shows a narrow bar at the top of the screen." + errors: invalid_css_color: "Invalid color. Enter a color name or hex value." invalid_email: "Invalid email address." diff --git a/config/site_settings.yml b/config/site_settings.yml index 51802a67446..c990fdbd926 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -393,6 +393,13 @@ basic: client: true default: true refresh: true + page_loading_indicator: + client: true + type: enum + default: "spinner" + choices: + - spinner + - slider login: invite_only: