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 @@
{{i18n "skip_to_main_content"}}
+
+
+
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 @@
-
+
-