This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/lib/url.js.es6
Robin Ward 02f6db4e59 FIX: Back button shenanigans when redirecting from index routes
This is a problem that has long plagued Discourse. The root issue here
is that we have to implement our own link click handler, because
Discourse allows users to create HTML blobs of content with links, and
when those links are clicked they must be handled by the Ember router.

This always involved a certain amount of use of private Ember APIs which
of course evolved over time.

The API has more or less stabilized in the last two years, however we
have hacks in our URLs to handle a dynamic root path, depending on how
forums have set up their filters and in what order.

This patch adds a special case for the root path so we needn't update
the URL ourselves otherwise, which preserves the back button on index
routes. The update call would otherwise insert an extra history event if
a route redirected on transition, breaking the back button.
2019-06-21 14:42:01 -04:00

463 lines
11 KiB
JavaScript

import offsetCalculator from "discourse/lib/offset-calculator";
import LockOn from "discourse/lib/lock-on";
import { defaultHomepage } from "discourse/lib/utilities";
const rewrites = [];
const TOPIC_REGEXP = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/;
function redirectTo(url) {
document.location = url;
return true;
}
// We can add links here that have server side responses but not client side.
const SERVER_SIDE_ONLY = [
/^\/assets\//,
/^\/uploads\//,
/^\/stylesheets\//,
/^\/site_customizations\//,
/^\/raw\//,
/^\/posts\/\d+\/raw/,
/^\/raw\/\d+/,
/^\/wizard/,
/\.rss$/,
/\.json$/,
/^\/admin\/upgrade$/,
/^\/logs($|\/)/
];
export function rewritePath(path) {
const params = path.split("?");
let result = params[0];
rewrites.forEach(rw => {
if ((rw.opts.exceptions || []).some(ex => path.indexOf(ex) === 0)) {
return;
}
result = result.replace(rw.regexp, rw.replacement);
});
if (params.length > 1) {
result += `?${params[1]}`;
}
return result;
}
export function clearRewrites() {
rewrites.length = 0;
}
export function userPath(subPath) {
return Discourse.getURL(subPath ? `/u/${subPath}` : "/u");
}
export function groupPath(subPath) {
return Discourse.getURL(subPath ? `/g/${subPath}` : "/g");
}
let _jumpScheduled = false;
export function jumpToElement(elementId) {
if (_jumpScheduled || Ember.isEmpty(elementId)) {
return;
}
const selector = `#${elementId}, a[name=${elementId}]`;
_jumpScheduled = true;
Ember.run.schedule("afterRender", function() {
const lockon = new LockOn(selector, {
finished() {
_jumpScheduled = false;
}
});
lockon.lock();
});
}
let _transitioning = false;
const DiscourseURL = Ember.Object.extend({
isJumpScheduled() {
return _transitioning || _jumpScheduled;
},
// Jumps to a particular post in the stream
jumpToPost(postNumber, opts) {
opts = opts || {};
const holderId = `#post_${postNumber}`;
_transitioning = postNumber > 1;
Ember.run.schedule("afterRender", () => {
let elementId;
let holder;
if (postNumber === 1 && !opts.anchor) {
$(window).scrollTop(0);
_transitioning = false;
return;
}
if (opts.anchor) {
elementId = opts.anchor;
holder = $(elementId);
}
if (!holder || holder.length === 0) {
elementId = holderId;
holder = $(elementId);
}
const lockon = new LockOn(elementId, {
finished() {
_transitioning = false;
}
});
if (holder.length > 0 && opts && opts.skipIfOnScreen) {
const elementTop = lockon.elementTop();
const scrollTop = $(window).scrollTop();
const windowHeight = $(window).height() - offsetCalculator();
const height = holder.height();
if (
elementTop > scrollTop &&
elementTop + height < scrollTop + windowHeight
) {
_transitioning = false;
return;
}
}
lockon.lock();
if (lockon.elementTop() < 1) {
_transitioning = false;
return;
}
});
},
// Browser aware replaceState. Will only be invoked if the browser supports it.
replaceState(path) {
if (
window.history &&
window.history.pushState &&
window.history.replaceState &&
window.location.pathname !== path
) {
// Always use replaceState in the next runloop to prevent weird routes changing
// while URLs are loading. For example, while a topic loads it sets `currentPost`
// which triggers a replaceState even though the topic hasn't fully loaded yet!
Ember.run.next(() => {
const location = DiscourseURL.get("router.location");
if (location && location.replaceURL) {
location.replaceURL(path);
}
});
}
},
routeToTag(a) {
// skip when we are provided nowhere to route to
if (!a || !a.href) {
return false;
}
if (a.host && a.host !== document.location.host) {
document.location = a.href;
return false;
}
return this.routeTo(a.href);
},
/**
Our custom routeTo method is used to intelligently overwrite default routing
behavior.
It contains the logic necessary to route within a topic using replaceState to
keep the history intact.
**/
routeTo(path, opts) {
opts = opts || {};
if (Ember.isEmpty(path)) {
return;
}
if (Discourse.get("requiresRefresh")) {
return redirectTo(Discourse.getURL(path));
}
const pathname = path.replace(/(https?\:)?\/\/[^\/]+/, "");
const baseUri = Discourse.BaseUri;
if (!DiscourseURL.isInternal(path)) {
return redirectTo(path);
}
const serverSide = SERVER_SIDE_ONLY.some(r => {
if (pathname.match(r)) {
return redirectTo(path);
}
});
if (serverSide) {
return;
}
// Scroll to the same page, different anchor
const m = /^#(.+)$/.exec(path);
if (m) {
jumpToElement(m[1]);
return this.replaceState(path);
}
const oldPath = window.location.pathname;
path = path.replace(/(https?\:)?\/\/[^\/]+/, "");
// Rewrite /my/* urls
let myPath = `${baseUri}/my/`;
if (path.indexOf(myPath) === 0) {
const currentUser = Discourse.User.current();
if (currentUser) {
path = path.replace(
myPath,
userPath(currentUser.get("username_lower") + "/")
);
} else {
return redirectTo("/404");
}
}
// handle prefixes
if (path.indexOf("/") === 0) {
const rootURL = (baseUri === undefined ? "/" : baseUri).replace(
/\/$/,
""
);
path = path.replace(rootURL, "");
}
path = rewritePath(path);
if (typeof opts.afterRouteComplete === "function") {
Ember.run.schedule("afterRender", opts.afterRouteComplete);
}
if (this.navigatedToPost(oldPath, path, opts)) {
return;
}
if (oldPath === path) {
// If navigating to the same path send an app event.
// Views can watch it and tell their controllers to refresh
this.appEvents.trigger("url:refresh");
}
// TODO: Extract into rules we can inject into the URL handler
if (this.navigatedToHome(oldPath, path, opts)) {
return;
}
// Navigating to empty string is the same as root
if (path === "") {
path = "/";
}
return this.handleURL(path, opts);
},
rewrite(regexp, replacement, opts) {
rewrites.push({ regexp, replacement, opts: opts || {} });
},
redirectTo(url) {
window.location = Discourse.getURL(url);
},
/**
* Determines whether a URL is internal or not
*
* @method isInternal
* @param {String} url
**/
isInternal(url) {
if (url && url.length) {
if (url.indexOf("//") === 0) {
url = "http:" + url;
}
if (url.indexOf("#") === 0) {
return true;
}
if (url.indexOf("/") === 0) {
return true;
}
if (url.indexOf(this.origin()) === 0) {
return true;
}
if (url.replace(/^http/, "https").indexOf(this.origin()) === 0) {
return true;
}
if (url.replace(/^https/, "http").indexOf(this.origin()) === 0) {
return true;
}
}
return false;
},
/**
If the URL is in the topic form, /t/something/:topic_id/:post_number
then we want to apply some special logic. If the post_number changes within the
same topic, use replaceState and instruct our controller to load more posts.
**/
navigatedToPost(oldPath, path, routeOpts) {
const newMatches = TOPIC_REGEXP.exec(path);
const newTopicId = newMatches ? newMatches[2] : null;
if (newTopicId) {
const oldMatches = TOPIC_REGEXP.exec(oldPath);
const oldTopicId = oldMatches ? oldMatches[2] : null;
// If the topic_id is the same
if (oldTopicId === newTopicId) {
DiscourseURL.replaceState(path);
const container = Discourse.__container__;
const topicController = container.lookup("controller:topic");
const opts = {};
const postStream = topicController.get("model.postStream");
if (newMatches[3]) {
opts.nearPost = newMatches[3];
}
if (path.match(/last$/)) {
opts.nearPost = topicController.get("model.highest_post_number");
}
opts.cancelSummary = true;
postStream.refresh(opts).then(() => {
const closest = postStream.closestPostNumberFor(opts.nearPost || 1);
topicController.setProperties({
"model.currentPost": closest,
enteredAt: new Date().getTime().toString()
});
this.appEvents.trigger("post:highlight", closest);
const jumpOpts = {
skipIfOnScreen: routeOpts.skipIfOnScreen
};
const m = /#.+$/.exec(path);
if (m) {
jumpOpts.anchor = m[0];
}
this.jumpToPost(closest, jumpOpts);
});
// Abort routing, we have replaced our state.
return true;
}
}
return false;
},
/**
@private
Handle the custom case of routing to the root path from itself.
@param {String} oldPath the previous path we were on
@param {String} path the path we're navigating to
**/
navigatedToHome(oldPath, path) {
const homepage = defaultHomepage();
if (
window.history &&
window.history.pushState &&
(path === "/" || path === "/" + homepage) &&
(oldPath === "/" || oldPath === "/" + homepage)
) {
this.appEvents.trigger("url:refresh");
return true;
}
return false;
},
// This has been extracted so it can be tested.
origin() {
return (
window.location.origin +
(Discourse.BaseUri === "/" ? "" : Discourse.BaseUri)
);
},
/**
@private
Get a handle on the application's router. Note that currently it uses `__container__` which is not
advised but there is no other way to access the router.
@property router
**/
router: function() {
return Discourse.__container__.lookup("router:main");
}
.property()
.volatile(),
// Get a controller. Note that currently it uses `__container__` which is not
// advised but there is no other way to access the router.
controllerFor(name) {
return Discourse.__container__.lookup("controller:" + name);
},
/**
Be wary of looking up the router. In this case, we have links in our
HTML, say form compiled markdown posts, that need to be routed.
**/
handleURL(path, opts) {
opts = opts || {};
const router = this.router;
if (opts.replaceURL) {
this.replaceState(path);
} else {
const discoveryTopics = this.controllerFor("discovery/topics");
if (discoveryTopics) {
discoveryTopics.resetParams();
}
}
const split = path.split("#");
let elementId;
if (split.length === 2) {
path = split[0];
elementId = split[1];
}
// The default path has a hack to allow `/` to default to defaultHomepage
// via BareRouter.handleUrl
let transition;
if (path === "/") {
router._routerMicrolib.updateURL(path);
transition = router.handleURL(path);
} else {
transition = router.transitionTo(path);
}
transition._discourse_intercepted = true;
const promise = transition.promise || transition;
promise.then(() => jumpToElement(elementId));
}
}).create();
export default DiscourseURL;