Commit 7bbb8a60 authored by Robert Knight's avatar Robert Knight

Convert root `<hypothesis-app>` component to Preact

Convert the root `<hypothesis-app>` component to Preact and update the
startup code in `src/sidebar/index.js` to replace the AngularJS app
bootstrap with rendering the `HypothesisApp` component directly using
`render` from Preact.
parent 1d37386a
import { createElement } from 'preact';
import { useEffect, useMemo } from 'preact/hooks';
import propTypes from 'prop-types';
import bridgeEvents from '../../shared/bridge-events';
import events from '../events';
import serviceConfig from '../service-config';
import useStore from '../store/use-store';
import uiConstants from '../ui-constants';
import { parseAccountID } from '../util/account-id';
import isSidebar from '../util/is-sidebar';
import { shouldAutoDisplayTutorial } from '../util/session';
import { applyTheme } from '../util/theme';
import { withServices } from '../util/service-context';
import AnnotationViewerContent from './annotation-viewer-content';
import HelpPanel from './help-panel';
import ShareAnnotationsPanel from './share-annotations-panel';
import SidebarContent from './sidebar-content';
import StreamContent from './stream-content';
import ToastMessages from './toast-messages';
import TopBar from './top-bar';
/**
* Return the user's authentication status from their profile.
......@@ -31,102 +43,82 @@ function authStateFromProfile(profile) {
}
}
// @ngInject
function HypothesisAppController(
$document,
$rootScope,
$scope,
$window,
analytics,
store,
/**
* The root component for the Hypothesis client.
*
* This handles login/logout actions and renders the top navigation bar
* and content appropriate for the current route.
*/
function HypothesisApp({
auth,
bridge,
features,
frameSync,
groups,
serviceUrl,
session,
settings,
toastMessenger
) {
const self = this;
// This stores information about the current user's authentication status.
// When the controller instantiates we do not yet know if the user is
// logged-in or not, so it has an initial status of 'unknown'. This can be
// used by templates to show an intermediate or loading state.
this.auth = { status: 'unknown' };
this.backgroundStyle = applyTheme(['appBackgroundColor'], settings);
// Check to see if we're in the sidebar, or on a standalone page such as
// the stream page or an individual annotation page.
this.isSidebar = isSidebar();
if (this.isSidebar) {
frameSync.connect();
}
// Reload the view when the user switches accounts
this.onUserChange = profile => {
self.auth = authStateFromProfile(profile);
if (shouldAutoDisplayTutorial(this.isSidebar, store.profile(), settings)) {
// Auto-open the tutorial (help) panel
store.openSidebarPanel(uiConstants.PANEL_HELP);
session,
toastMessenger,
}) {
const clearGroups = useStore(store => store.clearGroups);
const closeSidebarPanel = useStore(store => store.closeSidebarPanel);
const countDrafts = useStore(store => store.countDrafts);
const discardAllDrafts = useStore(store => store.discardAllDrafts);
const hasFetchedProfile = useStore(store => store.hasFetchedProfile());
const openSidebarPanel = useStore(store => store.openSidebarPanel);
const profile = useStore(store => store.profile());
const removeAnnotations = useStore(store => store.removeAnnotations);
const route = useStore(store => store.route());
const unsavedAnnotations = useStore(store => store.unsavedAnnotations);
const authState = useMemo(() => {
if (!hasFetchedProfile) {
return { status: 'unknown' };
}
};
return authStateFromProfile(profile);
}, [hasFetchedProfile, profile]);
this.route = () => store.route();
const backgroundStyle = useMemo(
() => applyTheme(['backgroundColor'], settings),
[settings]
);
$scope.$on(events.USER_CHANGED, function (event, data) {
self.onUserChange(data.profile);
});
const isSidebar = route === 'sidebar';
session.load().then(profile => {
self.onUserChange(profile);
});
useEffect(() => {
if (shouldAutoDisplayTutorial(isSidebar, profile, settings)) {
openSidebarPanel(uiConstants.PANEL_HELP);
}
}, [isSidebar, profile, openSidebarPanel, settings]);
/**
* Start the login flow. This will present the user with the login dialog.
*
* @return {Promise<void>} - A Promise that resolves when the login flow
* completes. For non-OAuth logins, always resolves immediately.
*/
this.login = function () {
const login = async () => {
if (serviceConfig(settings)) {
// Let the host page handle the login request
bridge.call(bridgeEvents.LOGIN_REQUESTED);
return Promise.resolve();
return;
}
return auth
.login()
.then(() => {
// If the prompt-to-log-in sidebar panel is open, close it
store.closeSidebarPanel(uiConstants.PANEL_LOGIN_PROMPT);
store.clearGroups();
session.reload();
})
.catch(err => {
toastMessenger.error(err.message);
});
};
try {
await auth.login();
this.signUp = function () {
analytics.track(analytics.events.SIGN_UP_REQUESTED);
closeSidebarPanel(uiConstants.PANEL_LOGIN_PROMPT);
clearGroups();
session.reload();
} catch (err) {
toastMessenger.error(err.message);
}
};
const signUp = () => {
if (serviceConfig(settings)) {
// Let the host page handle the signup request
bridge.call(bridgeEvents.SIGNUP_REQUESTED);
return;
}
$window.open(serviceUrl('signup'));
window.open(serviceUrl('signup'));
};
// Prompt to discard any unsaved drafts.
const promptToLogout = function () {
const promptToLogout = () => {
// TODO - Replace this with a UI which doesn't look terrible.
let text = '';
const drafts = store.countDrafts();
const drafts = countDrafts();
if (drafts === 1) {
text =
'You have an unsaved annotation.\n' +
......@@ -138,31 +130,73 @@ function HypothesisAppController(
' unsaved annotations.\n' +
'Do you really want to discard these drafts?';
}
return drafts === 0 || $window.confirm(text);
return drafts === 0 || window.confirm(text);
};
// Log the user out.
this.logout = function () {
const logout = () => {
if (!promptToLogout()) {
return;
}
store.clearGroups();
store.removeAnnotations(store.unsavedAnnotations());
store.discardAllDrafts();
clearGroups();
removeAnnotations(unsavedAnnotations());
discardAllDrafts();
if (serviceConfig(settings)) {
// Let the host page handle the signup request
bridge.call(bridgeEvents.LOGOUT_REQUESTED);
return;
}
session.logout();
};
return (
<div
className="app-content-wrapper js-thread-list-scroll-root"
style={backgroundStyle}
>
<TopBar
auth={authState}
onLogin={login}
onSignUp={signUp}
onLogout={logout}
isSidebar={isSidebar}
/>
<div className="content">
<ToastMessages />
<HelpPanel auth={authState} />
<ShareAnnotationsPanel />
{route && (
<main>
{route === 'annotation' && <AnnotationViewerContent />}
{route === 'stream' && <StreamContent />}
{route === 'sidebar' && (
<SidebarContent onLogin={login} onSignUp={signUp} />
)}
</main>
)}
</div>
</div>
);
}
export default {
controller: HypothesisAppController,
controllerAs: 'vm',
template: require('../templates/hypothesis-app.html'),
HypothesisApp.propTypes = {
// Injected.
auth: propTypes.object,
bridge: propTypes.object,
serviceUrl: propTypes.func,
settings: propTypes.object,
session: propTypes.object,
toastMessenger: propTypes.object,
};
HypothesisApp.injectedProps = [
'auth',
'bridge',
'serviceUrl',
'session',
'settings',
'toastMessenger',
];
export default withServices(HypothesisApp);
import angular from 'angular';
import { mount } from 'enzyme';
import { createElement } from 'preact';
import bridgeEvents from '../../../shared/bridge-events';
import events from '../../events';
import { events as analyticsEvents } from '../../services/analytics';
import hypothesisApp from '../hypothesis-app';
import { $imports } from '../hypothesis-app';
describe('sidebar.components.hypothesis-app', function () {
let $componentController = null;
let $scope = null;
let $rootScope = null;
import mockImportedComponents from '../../../test-util/mock-imported-components';
import HypothesisApp, { $imports } from '../hypothesis-app';
describe('HypothesisApp', () => {
let fakeStore = null;
let fakeAnalytics = null;
let fakeAuth = null;
let fakeBridge = null;
let fakeFeatures = null;
let fakeFrameSync = null;
let fakeIsSidebar = null;
let fakeServiceConfig = null;
let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null;
let fakeGroups = null;
let fakeServiceUrl = null;
let fakeSettings = null;
let fakeToastMessenger = null;
let fakeWindow = null;
let sandbox = null;
const createController = function (locals) {
locals = locals || {};
locals.$scope = $scope;
return $componentController('hypothesisApp', locals);
const createComponent = (props = {}) => {
return mount(
<HypothesisApp
auth={fakeAuth}
bridge={fakeBridge}
serviceUrl={fakeServiceUrl}
settings={fakeSettings}
session={fakeSession}
toastMessenger={fakeToastMessenger}
{...props}
/>
);
};
beforeEach(function () {
sandbox = sinon.createSandbox();
});
beforeEach(function () {
fakeIsSidebar = sandbox.stub().returns(true);
fakeServiceConfig = sandbox.stub();
beforeEach(() => {
fakeServiceConfig = sinon.stub();
fakeShouldAutoDisplayTutorial = sinon.stub().returns(false);
fakeStore = {
clearSelectedAnnotations: sinon.spy(),
clearGroups: sinon.stub(),
closeSidebarPanel: sinon.stub(),
openSidebarPanel: sinon.stub(),
// draft store
countDrafts: sinon.stub().returns(0),
discardAllDrafts: sinon.stub(),
unsavedAnnotations: sinon.stub().returns([]),
removeAnnotations: sinon.stub(),
hasFetchedProfile: sinon.stub().returns(true),
profile: sinon.stub().returns({
userid: null,
preferences: {
show_sidebar_tutorial: false,
},
}),
route: sinon.stub().returns('sidebar'),
};
fakeAuth = {};
fakeSession = {
load: sinon.stub().returns(Promise.resolve({ userid: null })),
logout: sinon.stub(),
reload: sinon.stub().returns(Promise.resolve({ userid: null })),
};
fakeServiceUrl = sinon.stub();
fakeSettings = {};
fakeBridge = {
call: sinon.stub(),
};
fakeToastMessenger = {
error: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../util/is-sidebar': fakeIsSidebar,
'../service-config': fakeServiceConfig,
'../store/use-store': callback => callback(fakeStore),
'../util/session': {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
},
});
angular.module('h', []).component('hypothesisApp', hypothesisApp);
});
afterEach(() => {
$imports.$restore();
});
beforeEach(angular.mock.module('h'));
beforeEach(
angular.mock.module(function ($provide) {
fakeStore = {
tool: 'comment',
clearSelectedAnnotations: sandbox.spy(),
clearGroups: sinon.stub(),
closeSidebarPanel: sinon.stub(),
openSidebarPanel: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
removeAnnotations: sandbox.stub(),
profile: sinon.stub().returns({
preferences: {
show_sidebar_tutorial: false,
},
}),
};
fakeAnalytics = {
track: sandbox.stub(),
events: analyticsEvents,
};
fakeAuth = {};
fakeFeatures = {
fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false),
};
fakeFrameSync = {
connect: sandbox.spy(),
};
fakeSession = {
load: sandbox.stub().returns(Promise.resolve({ userid: null })),
logout: sandbox.stub(),
reload: sandbox.stub().returns(Promise.resolve({ userid: null })),
};
fakeGroups = {
focus: sandbox.spy(),
};
fakeWindow = {
top: {},
confirm: sandbox.stub(),
open: sandbox.stub(),
};
fakeServiceUrl = sinon.stub();
fakeSettings = {};
fakeBridge = {
call: sandbox.stub(),
};
fakeToastMessenger = {
error: sandbox.stub(),
};
$provide.value('store', fakeStore);
$provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics);
$provide.value('features', fakeFeatures);
$provide.value('frameSync', fakeFrameSync);
$provide.value('serviceUrl', fakeServiceUrl);
$provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('toastMessenger', fakeToastMessenger);
$provide.value('bridge', fakeBridge);
$provide.value('groups', fakeGroups);
$provide.value('$window', fakeWindow);
})
);
beforeEach(
angular.mock.inject(function (_$componentController_, _$rootScope_) {
$componentController = _$componentController_;
$rootScope = _$rootScope_;
$scope = $rootScope.$new();
})
);
afterEach(function () {
sandbox.restore();
});
it('connects to host frame in the sidebar app', function () {
fakeIsSidebar.returns(true);
createController();
assert.called(fakeFrameSync.connect);
it('does not render content if route is not yet determined', () => {
fakeStore.route.returns(null);
const wrapper = createComponent();
[
'main',
'AnnotationViewerContent',
'StreamContent',
'SidebarContent',
].forEach(contentComponent => {
assert.isFalse(wrapper.exists(contentComponent));
});
});
it('does not connect to the host frame in the stream', function () {
fakeIsSidebar.returns(false);
createController();
assert.notCalled(fakeFrameSync.connect);
[
{
route: 'annotation',
contentComponent: 'AnnotationViewerContent',
},
{
route: 'sidebar',
contentComponent: 'SidebarContent',
},
{
route: 'stream',
contentComponent: 'StreamContent',
},
].forEach(({ route, contentComponent }) => {
it('renders app content for route', () => {
fakeStore.route.returns(route);
const wrapper = createComponent();
assert.isTrue(wrapper.find(contentComponent).exists());
});
});
describe('auto-opening tutorial', () => {
it('should open tutorial on profile load when criteria are met', () => {
fakeShouldAutoDisplayTutorial.returns(true);
createController();
return fakeSession.load().then(() => {
assert.calledOnce(fakeStore.openSidebarPanel);
});
createComponent();
assert.calledOnce(fakeStore.openSidebarPanel);
});
it('should not open tutorial on profile load when criteria are not met', () => {
fakeShouldAutoDisplayTutorial.returns(false);
createController();
return fakeSession.load().then(() => {
assert.equal(fakeStore.openSidebarPanel.callCount, 0);
});
createComponent();
assert.notCalled(fakeStore.openSidebarPanel);
});
});
it('auth.status is "unknown" on startup', function () {
const ctrl = createController();
assert.equal(ctrl.auth.status, 'unknown');
const getAuthState = wrapper => wrapper.find('TopBar').prop('auth');
it('auth state is "unknown" if profile has not yet been fetched', () => {
fakeStore.hasFetchedProfile.returns(false);
const wrapper = createComponent();
assert.equal(getAuthState(wrapper).status, 'unknown');
});
it('sets auth.status to "logged-out" if userid is null', function () {
const ctrl = createController();
return fakeSession.load().then(function () {
assert.equal(ctrl.auth.status, 'logged-out');
});
it('auth state is "logged-out" if userid is null', () => {
fakeStore.profile.returns({ userid: null });
const wrapper = createComponent();
assert.equal(getAuthState(wrapper).status, 'logged-out');
});
it('sets auth.status to "logged-in" if userid is non-null', function () {
fakeSession.load = function () {
return Promise.resolve({ userid: 'acct:jim@hypothes.is' });
};
const ctrl = createController();
return fakeSession.load().then(function () {
assert.equal(ctrl.auth.status, 'logged-in');
});
it('auth state is "logged-in" if userid is non-null', () => {
fakeStore.profile.returns({ userid: 'acct:jimsmith@hypothes.is' });
const wrapper = createComponent();
assert.equal(getAuthState(wrapper).status, 'logged-in');
});
[
......@@ -236,123 +192,100 @@ describe('sidebar.components.hypothesis-app', function () {
},
},
].forEach(({ profile, expectedAuth }) => {
it('sets `auth` properties when profile has loaded', () => {
fakeSession.load = () => Promise.resolve(profile);
const ctrl = createController();
return fakeSession.load().then(() => {
assert.deepEqual(ctrl.auth, expectedAuth);
});
it('sets auth state depending on profile', () => {
fakeStore.profile.returns(profile);
const wrapper = createComponent();
assert.deepEqual(getAuthState(wrapper), expectedAuth);
});
});
it('updates auth when the logged-in user changes', function () {
const ctrl = createController();
return fakeSession.load().then(function () {
$scope.$broadcast(events.USER_CHANGED, {
profile: {
userid: 'acct:john@hypothes.is',
},
});
assert.deepEqual(ctrl.auth, {
status: 'logged-in',
displayName: 'john',
userid: 'acct:john@hypothes.is',
username: 'john',
provider: 'hypothes.is',
});
describe('"Sign up" action', () => {
const clickSignUp = wrapper => wrapper.find('TopBar').props().onSignUp();
beforeEach(() => {
sinon.stub(window, 'open');
});
});
describe('#signUp', function () {
it('tracks sign up requests in analytics', function () {
const ctrl = createController();
ctrl.signUp();
assert.calledWith(
fakeAnalytics.track,
fakeAnalytics.events.SIGN_UP_REQUESTED
);
afterEach(() => {
window.open.restore();
});
context('when using a third-party service', function () {
beforeEach(function () {
context('when using a third-party service', () => {
beforeEach(() => {
fakeServiceConfig.returns({});
});
it('sends SIGNUP_REQUESTED event', function () {
const ctrl = createController();
ctrl.signUp();
it('sends SIGNUP_REQUESTED event', () => {
const wrapper = createComponent();
clickSignUp(wrapper);
assert.calledWith(fakeBridge.call, bridgeEvents.SIGNUP_REQUESTED);
});
it('does not open a URL directly', function () {
const ctrl = createController();
ctrl.signUp();
assert.notCalled(fakeWindow.open);
it('does not open a URL directly', () => {
const wrapper = createComponent();
clickSignUp(wrapper);
assert.notCalled(window.open);
});
});
context('when not using a third-party service', function () {
it('opens the signup URL in a new tab', function () {
context('when not using a third-party service', () => {
it('opens the signup URL in a new tab', () => {
fakeServiceUrl.withArgs('signup').returns('https://ann.service/signup');
const ctrl = createController();
ctrl.signUp();
assert.calledWith(fakeWindow.open, 'https://ann.service/signup');
const wrapper = createComponent();
clickSignUp(wrapper);
assert.calledWith(window.open, 'https://ann.service/signup');
});
});
});
describe('#login()', function () {
describe('"Log in" action', () => {
const clickLogIn = wrapper => wrapper.find('TopBar').props().onLogin();
beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve());
});
it('clears groups', () => {
const ctrl = createController();
return ctrl.login().then(() => {
assert.called(fakeStore.clearGroups);
});
it('clears groups', async () => {
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.called(fakeStore.clearGroups);
});
it('initiates the OAuth login flow', () => {
const ctrl = createController();
ctrl.login();
it('initiates the OAuth login flow', async () => {
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.called(fakeAuth.login);
});
it('reloads the session when login completes', () => {
const ctrl = createController();
return ctrl.login().then(() => {
assert.called(fakeSession.reload);
});
it('reloads the session when login completes', async () => {
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.called(fakeSession.reload);
});
it('closes the login prompt panel', () => {
const ctrl = createController();
return ctrl.login().then(() => {
assert.called(fakeStore.closeSidebarPanel);
});
it('closes the login prompt panel', async () => {
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.called(fakeStore.closeSidebarPanel);
});
it('reports an error if login fails', () => {
it('reports an error if login fails', async () => {
fakeAuth.login.returns(Promise.reject(new Error('Login failed')));
const ctrl = createController();
return ctrl.login().then(null, () => {
assert.called(fakeToastMessenger.error);
});
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.called(fakeToastMessenger.error);
});
it('sends LOGIN_REQUESTED if a third-party service is in use', function () {
it('sends LOGIN_REQUESTED if a third-party service is in use', async () => {
// If the client is using a third-party annotation service then clicking
// on a login button should send the LOGIN_REQUESTED event over the bridge
// (so that the partner site we're embedded in can do its own login
// thing).
fakeServiceConfig.returns({});
const ctrl = createController();
ctrl.login();
const wrapper = createComponent();
await clickLogIn(wrapper);
assert.equal(fakeBridge.call.callCount, 1);
assert.isTrue(
......@@ -361,33 +294,44 @@ describe('sidebar.components.hypothesis-app', function () {
});
});
describe('#logout()', function () {
// Tests shared by both of the contexts below.
function doSharedTests() {
it('prompts the user if there are drafts', function () {
fakeStore.countDrafts.returns(1);
const ctrl = createController();
describe('"Log out" action', () => {
const clickLogOut = wrapper => wrapper.find('TopBar').props().onLogout();
beforeEach(() => {
sinon.stub(window, 'confirm');
});
afterEach(() => {
window.confirm.restore();
});
// Tests used by both the first and third-party account scenarios.
function addCommonLogoutTests() {
// nb. Slightly different messages are shown depending on the draft count.
[1, 2].forEach(draftCount => {
it('prompts the user if there are drafts', () => {
fakeStore.countDrafts.returns(draftCount);
ctrl.logout();
const wrapper = createComponent();
clickLogOut(wrapper);
assert.equal(fakeWindow.confirm.callCount, 1);
assert.equal(window.confirm.callCount, 1);
});
});
it('clears groups', () => {
const ctrl = createController();
ctrl.logout();
const wrapper = createComponent();
clickLogOut(wrapper);
assert.called(fakeStore.clearGroups);
});
it('removes unsaved annotations', function () {
fakeStore.unsavedAnnotations = sandbox
it('removes unsaved annotations', () => {
fakeStore.unsavedAnnotations = sinon
.stub()
.returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController();
ctrl.logout();
const wrapper = createComponent();
clickLogOut(wrapper);
assert.calledWith(fakeStore.removeAnnotations, [
'draftOne',
......@@ -396,64 +340,63 @@ describe('sidebar.components.hypothesis-app', function () {
]);
});
it('discards drafts', function () {
const ctrl = createController();
ctrl.logout();
it('discards drafts', () => {
const wrapper = createComponent();
clickLogOut(wrapper);
assert(fakeStore.discardAllDrafts.calledOnce);
});
it('does not remove unsaved annotations if the user cancels the prompt', function () {
const ctrl = createController();
it('does not remove unsaved annotations if the user cancels the prompt', () => {
const wrapper = createComponent();
fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false);
window.confirm.returns(false);
ctrl.logout();
clickLogOut(wrapper);
assert.notCalled(fakeStore.removeAnnotations);
});
it('does not discard drafts if the user cancels the prompt', function () {
const ctrl = createController();
it('does not discard drafts if the user cancels the prompt', () => {
const wrapper = createComponent();
fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false);
window.confirm.returns(false);
ctrl.logout();
clickLogOut(wrapper);
assert(fakeStore.discardAllDrafts.notCalled);
});
it('does not prompt if there are no drafts', function () {
const ctrl = createController();
it('does not prompt if there are no drafts', () => {
const wrapper = createComponent();
fakeStore.countDrafts.returns(0);
ctrl.logout();
clickLogOut(wrapper);
assert.equal(fakeWindow.confirm.callCount, 0);
assert.notCalled(window.confirm);
});
}
context('when no third-party service is in use', function () {
doSharedTests();
context('when no third-party service is in use', () => {
addCommonLogoutTests();
it('calls session.logout()', function () {
const ctrl = createController();
ctrl.logout();
it('calls session.logout()', () => {
const wrapper = createComponent();
clickLogOut(wrapper);
assert.called(fakeSession.logout);
});
});
context('when a third-party service is in use', function () {
beforeEach('configure a third-party service to be in use', function () {
context('when a third-party service is in use', () => {
beforeEach('configure a third-party service to be in use', () => {
fakeServiceConfig.returns({});
});
doSharedTests();
addCommonLogoutTests();
it('sends LOGOUT_REQUESTED', function () {
createController().logout();
it('sends LOGOUT_REQUESTED', () => {
const wrapper = createComponent();
clickLogOut(wrapper);
assert.calledOnce(fakeBridge.call);
assert.calledWithExactly(
......@@ -462,18 +405,19 @@ describe('sidebar.components.hypothesis-app', function () {
);
});
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function () {
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', () => {
fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false);
window.confirm.returns(false);
createController().logout();
const wrapper = createComponent();
clickLogOut(wrapper);
assert.notCalled(fakeBridge.call);
});
it('does not call session.logout()', function () {
createController().logout();
it('does not call session.logout()', () => {
const wrapper = createComponent();
clickLogOut(wrapper);
assert.notCalled(fakeSession.logout);
});
});
......
......@@ -21,19 +21,9 @@ if (appConfig.sentry) {
sentry.init(appConfig.sentry);
}
// Disable Angular features that are not compatible with CSP.
//
// See https://docs.angularjs.org/api/ng/directive/ngCsp
//
// The `ng-csp` attribute must be set on some HTML element in the document
// _before_ Angular is require'd for the first time.
document.body.setAttribute('ng-csp', '');
// Prevent tab-jacking.
disableOpenerForExternalLinks(document.body);
import angular from 'angular';
// Load polyfill for :focus-visible pseudo-class.
import 'focus-visible';
......@@ -42,8 +32,6 @@ if (process.env.NODE_ENV !== 'production') {
require('preact/debug');
}
import wrapReactComponent from './util/wrap-react-component';
if (appConfig.googleAnalytics) {
addAnalytics(appConfig.googleAnalytics);
}
......@@ -100,29 +88,24 @@ function autosave(autosaveService) {
autosaveService.init();
}
// @ngInject
function setupFrameSync(frameSync) {
if (isSidebar) {
frameSync.connect();
}
}
// Register icons used by the sidebar app (and maybe other assets in future).
import { registerIcons } from '../shared/components/svg-icon';
import iconSet from './icons';
registerIcons(iconSet);
// Preact UI components that are wrapped for use within Angular templates.
import AnnotationViewerContent from './components/annotation-viewer-content';
import HelpPanel from './components/help-panel';
import LoginPromptPanel from './components/login-prompt-panel';
import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContent from './components/sidebar-content';
import StreamContent from './components/stream-content';
import ThreadList from './components/thread-list';
import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular.
import hypothesisApp from './components/hypothesis-app';
// The entry point component for the app.
import { createElement, render } from 'preact';
import HypothesisApp from './components/hypothesis-app';
import { ServiceContext } from './util/service-context';
// Services.
import bridgeService from '../shared/bridge';
import analyticsService from './services/analytics';
......@@ -151,18 +134,13 @@ import unicodeService from './services/unicode';
import viewFilterService from './services/view-filter';
// Redux store.
import store from './store';
// Utilities.
import { Injector } from '../shared/injector';
import EventEmitter from 'tiny-emitter';
function startAngularApp(config) {
// Create dependency injection container for services.
//
// This is a replacement for the use of Angular's dependency injection
// (including its `$injector` service) to construct services with dependencies.
function startApp(config) {
const container = new Injector();
// Register services.
......@@ -194,6 +172,15 @@ function startAngularApp(config) {
.register('viewFilter', viewFilterService)
.register('store', store);
// Register a dummy `$rootScope` pub-sub service for services that still
// use it.
const emitter = new EventEmitter();
const dummyRootScope = {
$on: (event, callback) => emitter.on(event, data => callback({}, data)),
$broadcast: (event, data) => emitter.emit(event, data),
};
container.register('$rootScope', { value: dummyRootScope });
// Register utility values/classes.
//
// nb. In many cases these can be replaced by direct imports in the services
......@@ -203,92 +190,23 @@ function startAngularApp(config) {
.register('isSidebar', { value: isSidebar })
.register('settings', { value: config });
// Register services which only Angular can construct, once Angular has
// constructed them.
//
// @ngInject
function registerAngularServices($rootScope) {
container.register('$rootScope', { value: $rootScope });
}
// Run initialization logic that uses constructed services.
//
// @ngInject
function initServices() {
container.run(persistDefaults);
container.run(autosave);
container.run(sendPageView);
container.run(setupApi);
container.run(setupRoute);
container.run(startRPCServer);
}
const wrapComponent = component => wrapReactComponent(component, container);
angular
.module('h', [])
// The root component for the application
.component('hypothesisApp', hypothesisApp)
// UI components
.component(
'annotationViewerContent',
wrapComponent(AnnotationViewerContent)
)
.component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel))
.component('sidebarContent', wrapComponent(SidebarContent))
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', wrapComponent(StreamContent))
.component('threadList', wrapComponent(ThreadList))
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
// Register services, the store and utilities with Angular, so that
// Angular components can use them.
.service('analytics', () => container.get('analytics'))
.service('api', () => container.get('api'))
.service('auth', () => container.get('auth'))
.service('bridge', () => container.get('bridge'))
.service('features', () => container.get('features'))
.service('frameSync', () => container.get('frameSync'))
.service('groups', () => container.get('groups'))
.service('loadAnnotationsService', () =>
container.get('loadAnnotationsService')
)
.service('rootThread', () => container.get('rootThread'))
.service('searchFilter', () => container.get('searchFilter'))
.service('serviceUrl', () => container.get('serviceUrl'))
.service('session', () => container.get('session'))
.service('streamer', () => container.get('streamer'))
.service('streamFilter', () => container.get('streamFilter'))
.service('toastMessenger', () => container.get('toastMessenger'))
// Redux store
.service('store', () => container.get('store'))
// Utilities
.value('isSidebar', container.get('isSidebar'))
.value('settings', container.get('settings'))
// Make Angular built-ins available to services constructed by `container`.
.run(registerAngularServices)
.run(initServices);
// Work around a check in Angular's $sniffer service that causes it to
// incorrectly determine that Firefox extensions are Chrome Packaged Apps which
// do not support the HTML 5 History API. This results Angular redirecting the
// browser on startup and thus the app fails to load.
// See https://github.com/angular/angular.js/blob/a03b75c6a812fcc2f616fc05c0f1710e03fca8e9/src/ng/sniffer.js#L30
if (window.chrome && !window.chrome.app) {
window.chrome.app = {
dummyAddedByHypothesisClient: true,
};
}
// Initialize services.
container.run(persistDefaults);
container.run(autosave);
container.run(sendPageView);
container.run(setupApi);
container.run(setupRoute);
container.run(startRPCServer);
container.run(setupFrameSync);
// Render the UI.
const appEl = document.querySelector('hypothesis-app');
angular.bootstrap(appEl, ['h'], { strictDi: true });
render(
<ServiceContext.Provider value={container}>
<HypothesisApp />
</ServiceContext.Provider>,
appEl
);
}
// Start capturing RPC requests before we start the RPC server (startRPCServer)
......@@ -296,11 +214,10 @@ preStartRPCServer();
fetchConfig(appConfig)
.then(config => {
startAngularApp(config);
startApp(config);
})
.catch(err => {
// Report error. This will be the only notice that the user gets because the
// sidebar does not currently appear at all if the Angular app fails to
// start.
// sidebar does not currently appear at all if the app fails to start.
console.error('Failed to start Hypothesis client: ', err);
});
<div class="app-content-wrapper js-thread-list-scroll-root" ng-style="vm.backgroundStyle">
<top-bar
auth="vm.auth"
on-login="vm.login()"
on-sign-up="vm.signUp()"
on-logout="vm.logout()"
is-sidebar="::vm.isSidebar">
</top-bar>
<div class="content">
<toast-messages></toast-messages>
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-if="vm.route()">
<annotation-viewer-content ng-if="vm.route() == 'annotation'"></annotation-viewer-content>
<stream-content ng-if="vm.route() == 'stream'"></stream-content>
<sidebar-content ng-if="vm.route() == 'sidebar'" on-login="vm.login()" on-signUp="vm.signUp()"></sidebar-content>
</main>
</div>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment