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.
463 lines
11 KiB
JavaScript
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;
|