0
0
mirror of https://github.com/OpenBazaar/openbazaar-desktop synced 2025-10-06 00:22:47 +02:00
Files
openbazaar-desktop/js/router.js
2019-04-24 11:00:59 -04:00

662 lines
21 KiB
JavaScript

import $ from 'jquery';
import { ipcRenderer } from 'electron';
import { Router } from 'backbone';
import app from './app';
import { getGuid, isMultihash } from './utils';
import { getPageContainer } from './utils/selectors';
import { isPromise } from './utils/object';
import { startAjaxEvent, endAjaxEvent, recordEvent } from './utils/metrics';
import { getCurrentConnection } from './utils/serverConnect';
import { isBlocked, isUnblocking, events as blockEvents } from './utils/block';
import './lib/whenAll.jquery';
import Profile from './models/profile/Profile';
import Listing from './models/listing/Listing';
import { getOpenModals } from './views/modals/BaseModal';
import UserPage from './views/userPage/UserPage';
import Search from './views/search/Search';
import Transactions from './views/transactions/Transactions';
import ConnectedPeersPage from './views/ConnectedPeersPage';
import TemplateOnly from './views/TemplateOnly';
import BlockedWarning from './views/modals/BlockedWarning';
import UserLoadingModal from './views/userPage/Loading';
export default class ObRouter extends Router {
constructor(options = {}) {
super(options);
this.options = options;
// This is a mapping of guids to handles. It is currently updated any time
// a profile is fetched via this.user() and anytime a user route is navigated to
// via this.navigateUser(). The main purpose of this cache is to avoid the flicker
// in the address bar that would be present due to the fact that we are storing user
// routes with guids in the history, but diplaying a version with the handle in the
// address bar.
this.guidHandleMap = new Map();
const routes = [
[/^(?:ob:\/\/)@([^\/]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'userViaHandle'],
[/^@([^\/]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'userViaHandle'],
[/^(?:ob:\/\/)(Qm[a-zA-Z0-9]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'user'],
[/^(Qm[a-zA-Z0-9]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'user'],
[/^(?:ob:\/\/)(12D3Koo[a-zA-Z0-9]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'user'],
[/^(12D3Koo[a-zA-Z0-9]+)[\/]?([^\/]*)[\/]?([^\/]*)[\/]?([^\/]*)\/?$/, 'user'],
['(ob://)transactions(/)', 'transactions'],
['(ob://)transactions/:tab(/)', 'transactions'],
['(ob://)connected-peers(/)', 'connectedPeers'],
['(ob://)search(/:tab)(?:query)', 'search'],
['(ob://)*path', 'pageNotFound'],
];
routes.slice(0)
.reverse()
.forEach((route) => this.route.apply(this, route));
this.setAddressBarText();
this._curHash = location.hash;
$(window).on('hashchange', () => {
this.setAddressBarText();
if (window.Countly) {
window.Countly.q.push(['track_pageview', location.hash]);
}
});
ipcRenderer.on('external-route', (e, route) => {
if (app.pageNav.navigable) {
this.navigate(route, { trigger: true });
}
});
}
get maxCachedHandles() {
return 1000;
}
// FYI - There is a scenario where the prevHash will be inaccurate. More details in
// the confirmPromises when() fail handler in execute().
setPrevHash(prevHash = this._curHash) {
this._prevHash = prevHash;
this._curHash = location.hash;
}
get prevHash() {
return this._prevHash;
}
/**
* Our own profile is not available when the router is constructed, so please call this method
* when it is.
*/
onProfileSet() {
this.stopListening(app.profile, null, this.onOwnHandleChange);
this.listenTo(app.profile, 'change:handle', this.onOwnHandleChange);
}
onOwnHandleChange() {
this.cacheGuidHandle(app.profile.id, app.profile.get('handle'));
// If we're on our own user page, we'll call router.setAddressBarText, which
// will ensure the updated handle is reflected in the address bar.
if (location.hash.slice(1).startsWith(app.profile.id)) {
this.setAddressBarText();
}
}
/**
* Updates our this.guidHandleMap which is an in-memory mapping of a guid to handle.
*/
cacheGuidHandle(guid, handle) {
if (typeof guid !== 'string') {
throw new Error('Please provide a guid as a string.');
}
if (typeof handle !== 'string') {
throw new Error('Please provide a handle as a string.');
}
if (!handle) {
this.guidHandleMap.delete(guid);
return;
}
const keys = Array.from(this.guidHandleMap.keys());
if (!this.guidHandleMap.get(guid) && keys.length >= this.maxCachedHandles) {
// We're already at or over the limit, so we need to remove one from the cache to
// make room for the new one.
this.guidHandleMap.delete(keys[0]);
}
this.guidHandleMap.set(guid, handle);
}
standardizedRoute(route = location.hash) {
let standardized = route;
if (standardized.startsWith('#')) {
standardized = standardized.slice(1);
}
if (standardized.startsWith('/')) {
standardized = standardized.slice(1);
}
if (standardized.startsWith('ob://')) {
standardized = standardized.slice(5);
}
if (standardized.endsWith('/')) {
standardized = standardized.slice(0, standardized.length - 1);
}
return standardized;
}
setAddressBarText(route = this.standardizedRoute()) {
let displayRoute = route;
if (!route) {
displayRoute = '';
} else {
const split = route.split('/');
// If the route starts with a guid and we have a cached handle
// for that guid, we'll put the handle in.
if (isMultihash(split[0])) {
const handle = this.guidHandleMap.get(split[0]);
if (handle) {
displayRoute =
`@${handle}${split.length > 1 ? `/${split.slice(1).join('/')}` : ''}`;
}
}
displayRoute = `ob://${displayRoute}`;
}
app.pageNav.setAddressBar(displayRoute);
}
execute(callback, args, name, options = {}) {
if (this.closeUnconfirmedRollBack) {
this.closeUnconfirmedRollBack = false;
return false;
}
this.navigate(this.standardizedRoute(), { replace: true });
// We'll iterate through any open modal which have a confirmClose method
// implemented. We'll call the method and only proceed with the route
// if every method confirms that the close is ok. If not, we'll cancel
// the route and roll back the hash.
if (!options.confirmedClose) {
const confirmPromises = [];
getOpenModals().forEach(modal => {
if (typeof modal.confirmClose !== 'function') return;
const closeConfirmed = modal.confirmClose.call(modal);
if (isPromise(closeConfirmed)) {
confirmPromises.push(closeConfirmed);
} else if (closeConfirmed) {
confirmPromises.push($.Deferred().resolve().promise());
} else {
confirmPromises.push($.Deferred().reject().promise());
}
});
if (confirmPromises.length) {
// Routing to a new page while the confirm close process is active could produce
// weird things, so we'll block page navigation.
app.pageNav.navigable = false;
$.when(...confirmPromises)
.done(() => {
this.execute(callback, args, name, { confirmedClose: true });
})
.fail(() => {
// If any of the closeConfirm promises are rejected, it indicates that
// the close of at least one modal was not confirmed and we won't proceed
// with the new route. We need to rollback the location hash.
if (location.hash !== this._prevHash) {
// When we roll back, it will trigger a new route. We want that route to be
// ignored and not reload a new page since we never unloaded the page. It's not
// pretty, but the following flag will be used for execute() to opt-out of reloading
// the page.
this.closeUnconfirmedRollBack = true;
location.hash = this._prevHash;
// FYI - at this point, since we've rolled back one level but never rolled back
// _prevHash one level, _prevHash is not accurate. To do that, we would need to track
// more than the previous hash, but also track all previous hashes. It's beyond the
// scope of what is necessary here. As long as prev hash is used only when the
// location hash changes to a new one and you want to cancel that route, we're good.
}
})
.always(() => (app.pageNav.navigable = true));
return false;
}
}
app.loadingModal.open();
// This block is intentionally duplicated here and in loadPage. It's
// here because we want to remove any current views (and have them
// do their remove cleanup) as soon as we know we're matching a new
// route. Based on some subsequent async fecthes, it may be a little
// bit of time before loadPage is called.
if (this.currentPage) {
this.currentPage.remove();
this.currentPage = null;
}
if (callback) {
this.trigger('will-route');
callback.apply(this, args);
}
return undefined;
}
loadPage(vw) {
// This block is intentionally duplicated here in case a route
// method was called directly on the app.router instance therefore
// bypassing execute.
if (this.currentPage) {
this.currentPage.remove();
this.currentPage = null;
}
this.currentPage = vw;
getPageContainer().append(vw.el);
app.loadingModal.close();
}
/**
* If you need to navigate to a user page via a handle and you have the user's guid, use
* this method which is mostly a wrapper around the standard Router.navigate. The addition
* is that this will make sure to store a version of the given fragment in history with the
* guid in place of the handle. It will make sure that the given version (with handle)
* will be shown in the address bar. It will also update the guidHandleMap caching.
*
* It's essentially a way to ensure that behind the scenes navigation is being done via
* guids (not dependant on the 3rd party resolver), but visually in the address bar, the
* user is seeing the handle (when available).
*
* @param {string} fragment - The user route you want stored. If the handle is available,
* provide it in this parameter (e.g. '@themes/store')
* @param {guid} string - The guid of the user corresponding to the given fragment.
* @param {object} [options={}] - Options that will be passed to Router.navigate.
*/
navigateUser(fragment, guid, options = {}) {
if (typeof fragment !== 'string') {
throw new Error('Please provide a fragment as a string.');
}
if (!guid) {
throw new Error('Please provide a guid.');
}
let guidRoute = fragment;
const split = fragment.split('/');
if (split[0].startsWith('@')) {
this.cacheGuidHandle(guid, split[0].slice(1));
guidRoute = [guid].concat(split.slice(1)).join('/');
}
return this.navigate(guidRoute, options);
}
navigate(fragment, options = {}) {
// Navigate is often times called in quick succession with url rewrites, so to
// properly capture the previous hash we'll just base it off the final call when
// they're called in such a burst fashion.
if (typeof fragment === 'string') {
clearTimeout(this.navigateSetPrevHash);
this.navigateSetPrevHash = setTimeout(() => {
this.setPrevHash();
});
}
return super.navigate(fragment, options);
}
userViaHandle(handle, ...args) {
getGuid(handle).done((guid) => {
// hack to pass in the handle to this.user - forgive me code gods
this.user(guid, ...[args[0], { handle }, ...args.slice(1)]);
}).fail(() => {
this.userNotFound(handle);
});
}
get userStates() {
return [
'home',
'store',
'following',
'followers',
'reputation',
];
}
/**
* Based on the route arguments, determine whether we
* have a valid user route.
*/
isValidUserRoute(guid, state, ...deepRouteParts) {
if (!guid || this.userStates.indexOf(state) === -1) {
return false;
}
if (state === 'store') {
// so far store is the only state that could have
// route parts beyond the state, e.g @themes/store/<slug>
if (deepRouteParts.length > 1) {
return false;
}
} else if (deepRouteParts.length) {
return false;
}
return true;
}
user(guid, state, ...args) {
let functionArgs = [...args];
// Hack to pass the handle into this function, which should really only
// happen when called from userViaHandle(). If a handle is being passed in,
// it will be passed in as { handle: 'charlie' } as the first element of the
// ...args argument.
let handle;
if (args.length && args[0] && args[0].hasOwnProperty('handle')) {
functionArgs = functionArgs.slice(1);
handle = args[0].handle;
}
const pageState = state || 'store';
const deepRouteParts = functionArgs.filter(arg => arg !== null);
if (!this.isValidUserRoute(guid, pageState, ...deepRouteParts)) {
this.pageNotFound();
return;
}
const standardizedHash = hash =>
(hash.endsWith('/') ? hash.slice(0, hash.length - 1) : hash);
if (isBlocked(guid) && !isUnblocking(guid)) {
app.loadingModal.close();
const blockedWarningModal = new BlockedWarning({ peerId: guid })
.render()
.open();
const onBlockWarningCanceled = () => {
const prevHash = standardizedHash(this.prevHash);
const locationHash = standardizedHash(location.hash);
if (prevHash === locationHash) {
// means there is no previous page - will go to our own node page
this.navigate(`${app.profile.id}`, {
replace: true,
trigger: true,
});
} else {
this.navigate(`${this.prevHash.slice(1)}`, {
replace: true,
trigger: true,
});
}
};
const onUnblock = data => {
if (data.peerIds.includes(guid)) {
app.loadingModal.open();
this.user(guid, state, ...args);
}
};
const cleanUpBlockedModal = () => {
blockEvents.off(null, onUnblock);
};
blockedWarningModal.on('canceled', onBlockWarningCanceled);
blockEvents.on('unblocking unblocked', onUnblock);
blockedWarningModal.on('close', cleanUpBlockedModal);
return;
}
let profile;
let profileFetch;
let listing;
let listingFetch;
let userPageFetchError = '';
let slug;
startAjaxEvent('UserPageLoad');
if (guid === app.profile.id) {
// don't fetch our own profile, since we have it already
profileFetch = $.Deferred().resolve();
profile = app.profile;
} else {
profile = new Profile({ peerID: guid });
profileFetch = profile.fetch();
}
if (state === 'store') {
if (deepRouteParts[0]) {
slug = deepRouteParts[0];
listing = new Listing({
slug,
}, { guid });
listingFetch = listing.fetch();
}
}
app.loadingModal.close();
if (this.userLoadingModal) {
this.userLoadingModal.remove();
}
this.userLoadingModal = new UserLoadingModal({
initialState: {
contentText: app.polyglot.t('userPage.loading.loadingText', {
name: `<b>${handle || `${guid.slice(0, 8)}`}</b>`,
}),
isProcessing: true,
},
})
.on('clickCancel', () => {
const prevHash = standardizedHash(this.prevHash);
const locationHash = standardizedHash(location.hash);
if (prevHash === locationHash) {
// there is no previous page, let's navigate to our home page
this.navigate(`${app.profile.id}`, {
trigger: true,
});
} else {
// go back to previous page
window.history.back();
}
})
.on('clickRetry', () => this.user(guid, state, ...args));
this.userLoadingModal.render()
.open();
const onWillRoute = () => {
// The app has been routed to a new route, let's
// clean up by aborting all fetches
if (profileFetch.abort) profileFetch.abort();
if (listingFetch) listingFetch.abort();
this.userLoadingModal.remove();
};
this.once('will-route', onWillRoute);
$.whenAll(profileFetch, listingFetch).done(() => {
handle = profile.get('handle');
this.cacheGuidHandle(guid, handle);
this.userLoadingModal.remove();
// Setting the address bar which will ensure the most up to date handle (or none) is
// shown in the address bar.
this.setAddressBarText();
if (pageState === 'store' && !profile.get('vendor') && guid !== app.profile.id) {
// the user does not have an active store and this is not our own node
if (state) {
// You've explicitly tried to navigate to the store tab. Since it's not
// available, we'll re-route to page-not-found
this.pageNotFound();
return;
}
// You've attempted to find a user with no particular tab. Since store is not available
// we'll take you to the home tab.
this.navigate(`${guid}/home/${deepRouteParts ? deepRouteParts.join('/') : ''}`, {
replace: true,
trigger: true,
});
return;
}
if (!state) {
this.navigate(`${guid}/store/${deepRouteParts ? deepRouteParts.join('/') : ''}`, {
replace: true,
});
}
this.loadPage(
new UserPage({
model: profile,
state: pageState,
listing,
}).render()
);
}).fail((...failArgs) => {
const jqXhr = failArgs[0];
const reason = jqXhr && jqXhr.responseJSON && jqXhr.responseJSON.reason ||
jqXhr && jqXhr.responseText || '';
if (jqXhr === profileFetch && profileFetch.statusText === 'abort') return;
if (jqXhr === listingFetch && listingFetch.statusText === 'abort') return;
if (profileFetch.state() === 'rejected') {
userPageFetchError = 'User Not Found';
} else if (listingFetch.state() === 'rejected') {
userPageFetchError = 'Listing Not Found';
}
userPageFetchError = userPageFetchError ?
`${userPageFetchError} - ${reason || 'unknown'}` :
reason || 'unknown';
let contentText = app.polyglot.t('userPage.loading.failTextStore', {
store: `<b>${handle || `${guid.slice(0, 8)}`}</b>`,
});
if (profileFetch.state() === 'resolved' && listingFetch.state() === 'rejected') {
const linkText = app.polyglot.t('userPage.loading.failTextListingLink');
const listingSlug = slug.length > 25 ?
`${slug.slice(0, 25)}` : slug;
contentText = app.polyglot.t('userPage.loading.failTextListingWithLink', {
listing: `<b>${listingSlug}</b>`,
link: `<a href="#${guid}/store">${linkText}</a>`,
});
}
this.userLoadingModal.setState({
contentText,
isProcessing: false,
});
})
.always(() => {
this.off(null, onWillRoute);
const dismissedCallout = getCurrentConnection() &&
getCurrentConnection().server.get('dismissedDiscoverCallout');
endAjaxEvent('UserPageLoad', {
ownPage: guid === app.profile.id,
tab: pageState,
dismissedCallout,
listing: !!listingFetch,
errors: userPageFetchError || 'none',
});
});
}
transactions(tab) {
if (tab && ['sales', 'cases', 'purchases'].indexOf(tab) === -1) {
this.pageNotFound();
return;
}
if (!tab) {
this.navigate('transactions/sales');
}
const initialTab = tab || 'sales';
this.loadPage(
new Transactions({ initialTab }).render()
);
recordEvent('Transactions_PageLoad', {
tab: initialTab,
});
}
connectedPeers() {
const peerFetch = $.get(app.getServerUrl('ob/peers')).done((data) => {
const peersData = data || [];
const peers = peersData.map(peer => (peer.slice(peer.lastIndexOf('/') + 1)));
this.loadPage(
new ConnectedPeersPage({ peers }).render()
);
}).fail((xhr) => {
let content = '<p>There was an error retrieving the connected peers.</p>';
if (xhr.responseText) {
content += `<p>${xhr.responseJSON && xhr.responseJSON.reason || xhr.responseText}</p>`;
}
this.genericError({ content });
});
this.once('will-route', () => (peerFetch.abort()));
}
search(tab = 'listings', query) {
this.loadPage(
new Search({ query, initialState: { tab } })
);
}
userNotFound(user) {
this.loadPage(
new TemplateOnly({ template: 'error-pages/userNotFound.html' }).render({ user })
);
}
pageNotFound() {
this.loadPage(
new TemplateOnly({
template: 'error-pages/pageNotFound.html',
}).render()
);
}
genericError(context = {}) {
this.loadPage(
new TemplateOnly({ template: 'error-pages/genericError.html' }).render(context)
);
}
}