Commit c4187a17 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Migrate `SidebarContent` component to preact

Make `streamer` auto-reconnect on user change to support this migration
parent f6479483
import events from '../events'; import { createElement } from 'preact';
import isThirdPartyService from '../util/is-third-party-service'; import propTypes from 'prop-types';
import * as tabs from '../util/tabs'; import { useEffect, useRef } from 'preact/hooks';
// @ngInject import { withServices } from '../util/service-context';
function SidebarContentController( import useStore from '../store/use-store';
$scope, import { tabForAnnotation } from '../util/tabs';
analytics,
loadAnnotationsService, import FocusedModeHeader from './focused-mode-header';
store, import LoggedOutMessage from './logged-out-message';
frameSync, import LoginPromptPanel from './login-prompt-panel';
rootThread, import SearchStatusBar from './search-status-bar';
settings, import SelectionTabs from './selection-tabs';
streamer import SidebarContentError from './sidebar-content-error';
) { import ThreadList from './thread-list';
const self = this;
/**
this.rootThread = () => rootThread.thread(store.getState()); * Render the sidebar and its components
function focusAnnotation(annotation) {
let highlights = [];
if (annotation) {
highlights = [annotation.$tag];
}
frameSync.focusAnnotations(highlights);
}
function scrollToAnnotation(annotation) {
if (!annotation) {
return;
}
frameSync.scrollToAnnotation(annotation.$tag);
}
/**
* Returns the Annotation object for the first annotation in the
* selected annotation set. Note that 'first' refers to the order
* of annotations passed to store when selecting annotations,
* not the order in which they appear in the document.
*/ */
function firstSelectedAnnotation() { function SidebarContent({
const selectedAnnotationMap = store.getSelectedAnnotationMap(); frameSync,
if (selectedAnnotationMap) { onLogin,
const id = Object.keys(selectedAnnotationMap)[0]; onSignUp,
return store.getState().annotations.annotations.find(function (annot) { loadAnnotationsService,
return annot.id === id; rootThread: rootThreadService,
}); streamer,
} else { }) {
return null; const rootThread = useStore(store =>
} rootThreadService.thread(store.getState())
} );
this.isLoading = () => {
if (
!store.frames().some(function (frame) {
return frame.uri;
})
) {
// The document's URL isn't known so the document must still be loading.
return true;
}
return store.isFetchingAnnotations();
};
$scope.$on('sidebarOpened', function () {
analytics.track(analytics.events.SIDEBAR_OPENED);
streamer.connect();
});
this.$onInit = () => {
// If the user is logged in, we connect nevertheless
if (this.auth.status === 'logged-in') {
streamer.connect();
}
};
$scope.$on(events.USER_CHANGED, function () {
streamer.reconnect();
});
$scope.$on(events.ANNOTATIONS_SYNCED, function (event, tags) { // Store state values
// When a direct-linked annotation is successfully anchored in the page, const focusedGroupId = useStore(store => store.focusedGroupId());
// focus and scroll to it const hasAppliedFilter = useStore(store => store.hasAppliedFilter());
const selectedAnnot = firstSelectedAnnotation(); const isFocusedMode = useStore(store => store.focusModeEnabled());
if (!selectedAnnot) { const annotationsLoading = useStore(store => {
return; return !store.hasFetchedAnnotations() || store.isFetchingAnnotations();
}
const matchesSelection = tags.some(function (tag) {
return tag === selectedAnnot.$tag;
}); });
if (!matchesSelection) { const isLoggedIn = useStore(store => store.isLoggedIn());
return; const linkedAnnotationId = useStore(store =>
} store.directLinkedAnnotationId()
focusAnnotation(selectedAnnot); );
scrollToAnnotation(selectedAnnot); const linkedAnnotation = useStore(store => {
return linkedAnnotationId
store.selectTab(tabs.tabForAnnotation(selectedAnnot)); ? store.findAnnotationByID(linkedAnnotationId)
: undefined;
}); });
const directLinkedTab = linkedAnnotation
? tabForAnnotation(linkedAnnotation)
: null;
const searchUris = useStore(store => store.searchUris());
const sidebarHasOpened = useStore(store => store.hasSidebarOpened());
const userId = useStore(store => store.profile().userid);
// The local `$tag` of a direct-linked annotation; populated once it
// has anchored: meaning that it's ready to be focused and scrolled to
const linkedAnnotationAnchorTag =
linkedAnnotation && linkedAnnotation.$orphan === false
? linkedAnnotation.$tag
: null;
// Actions
const clearSelectedAnnotations = useStore(
store => store.clearSelectedAnnotations
);
const selectTab = useStore(store => store.selectTab);
// Re-fetch annotations when focused group, logged-in user or connected frames // If, after loading completes, no `linkedAnnotation` object is present when
// change. // a `linkedAnnotationId` is set, that indicates an error
$scope.$watch( const hasDirectLinkedAnnotationError =
() => [ !annotationsLoading && linkedAnnotationId ? !linkedAnnotation : false;
store.focusedGroupId(),
store.profile().userid,
...store.searchUris(),
],
([currentGroupId], [prevGroupId]) => {
if (!currentGroupId) {
// When switching accounts, groups are cleared and so the focused group id
// will be null for a brief period of time.
store.clearSelectedAnnotations();
return;
}
if (!prevGroupId || currentGroupId !== prevGroupId) {
store.clearSelectedAnnotations();
}
const searchUris = store.searchUris(); const hasDirectLinkedGroupError = useStore(store =>
loadAnnotationsService.load(searchUris, currentGroupId); store.directLinkedGroupFetchFailed()
},
true
); );
this.showFocusedHeader = () => { const hasContentError =
return store.focusModeEnabled(); hasDirectLinkedAnnotationError || hasDirectLinkedGroupError;
};
this.showSelectedTabs = function () {
if (
this.selectedAnnotationUnavailable() ||
this.selectedGroupUnavailable() ||
store.getState().selection.filterQuery
) {
return false;
} else if (store.focusModeFocused()) {
return false;
} else {
return true;
}
};
this.setCollapsed = function (id, collapsed) {
store.setCollapsed(id, collapsed);
};
this.focus = focusAnnotation; const showTabs = !hasContentError && !hasAppliedFilter;
this.scrollTo = scrollToAnnotation; const showSearchStatus = !hasContentError && !annotationsLoading;
this.selectedGroupUnavailable = function () { // Show a CTA to log in if successfully viewing a direct-linked annotation
return store.getState().directLinked.directLinkedGroupFetchFailed; // and not logged in
}; const showLoggedOutMessage =
linkedAnnotationId &&
!isLoggedIn &&
!hasDirectLinkedAnnotationError &&
!annotationsLoading;
this.selectedAnnotationUnavailable = function () { const prevGroupId = useRef(focusedGroupId);
const selectedID = store.getFirstSelectedAnnotationId();
return (
!this.isLoading() && !!selectedID && !store.annotationExists(selectedID)
);
};
this.shouldShowLoggedOutMessage = function () { // Reload annotations when group, user or document search URIs change
// If user is not logged out, don't show CTA. useEffect(() => {
if (self.auth.status !== 'logged-out') { if (!prevGroupId.current || prevGroupId.current !== focusedGroupId) {
return false; // Clear any selected annotations when the group ID changes
clearSelectedAnnotations();
prevGroupId.current = focusedGroupId;
} }
if (focusedGroupId && searchUris.length) {
// If user has not landed on a direct linked annotation loadAnnotationsService.load(searchUris, focusedGroupId);
// don't show the CTA.
if (!store.getState().directLinked.directLinkedAnnotationId) {
return false;
} }
}, [
// The CTA text and links are only applicable when using Hypothesis clearSelectedAnnotations,
// accounts. loadAnnotationsService,
if (isThirdPartyService(settings)) { focusedGroupId,
return false; userId,
searchUris,
]);
// When a `linkedAnnotationAnchorTag` becomes available, scroll to it
// and focus it
useEffect(() => {
if (linkedAnnotationAnchorTag) {
frameSync.focusAnnotations([linkedAnnotationAnchorTag]);
frameSync.scrollToAnnotation(linkedAnnotationAnchorTag);
selectTab(directLinkedTab);
}
}, [directLinkedTab, frameSync, linkedAnnotationAnchorTag, selectTab]);
// Connect to the streamer when the sidebar has opened or if user is logged in
useEffect(() => {
if (sidebarHasOpened || isLoggedIn) {
streamer.connect();
} }
}, [streamer, sidebarHasOpened, isLoggedIn]);
// The user is logged out and has landed on a direct linked
// annotation. If there is an annotation selection and that
// selection is available to the user, show the CTA.
const selectedID = store.getFirstSelectedAnnotationId();
return ( return (
!store.isFetchingAnnotations() && <div>
!!selectedID && {isFocusedMode && <FocusedModeHeader />}
store.annotationExists(selectedID) <LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
{hasDirectLinkedAnnotationError && (
<SidebarContentError errorType="annotation" onLoginRequest={onLogin} />
)}
{hasDirectLinkedGroupError && (
<SidebarContentError errorType="group" onLoginRequest={onLogin} />
)}
{showTabs && <SelectionTabs isLoading={annotationsLoading} />}
{showSearchStatus && <SearchStatusBar />}
<ThreadList thread={rootThread} />
{showLoggedOutMessage && <LoggedOutMessage onLogin={onLogin} />}
</div>
); );
};
} }
export default { SidebarContent.propTypes = {
controller: SidebarContentController, // Callbacks for log in and out
controllerAs: 'vm', onLogin: propTypes.func.isRequired,
bindings: { onSignUp: propTypes.func.isRequired,
auth: '<',
onLogin: '&', // Injected
onSignUp: '&', frameSync: propTypes.object,
}, loadAnnotationsService: propTypes.object,
template: require('../templates/sidebar-content.html'), rootThread: propTypes.object,
streamer: propTypes.object,
}; };
SidebarContent.injectedProps = [
'frameSync',
'loadAnnotationsService',
'rootThread',
'streamer',
];
export default withServices(SidebarContent);
import angular from 'angular'; import { mount } from 'enzyme';
import EventEmitter from 'tiny-emitter'; import { createElement } from 'preact';
import events from '../../events';
import storeFactory from '../../store';
import sidebarContent from '../sidebar-content';
class FakeRootThread extends EventEmitter {
constructor() {
super();
this.thread = sinon.stub().returns({
totalChildren: 0,
});
}
}
describe('sidebar.components.sidebar-content', function () {
let $rootScope;
let $scope;
let store;
let ctrl;
let fakeAnalytics;
let fakeLoadAnnotationsService;
let fakeFrameSync;
let fakeRootThread;
let fakeSettings;
let fakeStreamer;
let sandbox;
before(function () {
angular
.module('h', [])
.service('store', storeFactory)
.component('sidebarContent', sidebarContent);
});
beforeEach(angular.mock.module('h')); import SidebarContent from '../sidebar-content';
import { $imports } from '../sidebar-content';
beforeEach(() => { import { checkAccessibility } from '../../../test-util/accessibility';
angular.mock.module(function ($provide) { import mockImportedComponents from '../../../test-util/mock-imported-components';
sandbox = sinon.createSandbox();
fakeAnalytics = { describe('SidebarContent', () => {
track: sandbox.stub(), let fakeFrameSync;
events: {}, let fakeLoadAnnotationsService;
}; let fakeRootThreadService;
let fakeStore;
let fakeStreamer;
let fakeTabsUtil;
const createComponent = props =>
mount(
<SidebarContent
onLogin={() => null}
onSignUp={() => null}
frameSync={fakeFrameSync}
loadAnnotationsService={fakeLoadAnnotationsService}
rootThread={fakeRootThreadService}
streamer={fakeStreamer}
{...props}
/>
);
beforeEach(() => {
fakeFrameSync = { fakeFrameSync = {
focusAnnotations: sinon.stub(), focusAnnotations: sinon.stub(),
scrollToAnnotation: sinon.stub(), scrollToAnnotation: sinon.stub(),
}; };
fakeStreamer = {
setConfig: sandbox.stub(),
connect: sandbox.stub(),
reconnect: sandbox.stub(),
};
fakeLoadAnnotationsService = { fakeLoadAnnotationsService = {
load: sinon.stub(), load: sinon.stub(),
}; };
fakeRootThreadService = {
fakeRootThread = new FakeRootThread(); thread: sinon.stub().returns({}),
};
fakeSettings = {}; fakeStreamer = {
connect: sinon.stub(),
$provide.value('analytics', fakeAnalytics); };
$provide.value('frameSync', fakeFrameSync); fakeStore = {
$provide.value('rootThread', fakeRootThread); // actions
$provide.value('streamer', fakeStreamer); clearSelectedAnnotations: sinon.stub(),
$provide.value('loadAnnotationsService', fakeLoadAnnotationsService); selectTab: sinon.stub(),
$provide.value('settings', fakeSettings); // selectors
}); annotationExists: sinon.stub(),
}); directLinkedAnnotationId: sinon.stub(),
directLinkedGroupFetchFailed: sinon.stub(),
function setFrames(frames) { findAnnotationByID: sinon.stub(),
frames.forEach(function (frame) { focusedGroupId: sinon.stub(),
store.connectFrame(frame); focusModeEnabled: sinon.stub(),
}); hasAppliedFilter: sinon.stub(),
} hasFetchedAnnotations: sinon.stub(),
hasSidebarOpened: sinon.stub(),
const makeSidebarContentController = () => { isFetchingAnnotations: sinon.stub(),
angular.mock.inject(function ($componentController, _store_, _$rootScope_) { isLoggedIn: sinon.stub(),
$rootScope = _$rootScope_; getState: sinon.stub(),
$scope = $rootScope.$new(); profile: sinon.stub().returns({ userid: null }),
searchUris: sinon.stub().returns([]),
store = _store_;
store.updateFrameAnnotationFetchStatus = sinon.stub();
store.clearGroups();
store.loadGroups([{ id: 'group-id' }]);
store.focusGroup('group-id');
ctrl = $componentController(
'sidebarContent',
{ $scope: $scope },
{
auth: { status: 'unknown' },
}
);
});
}; };
beforeEach(() => { fakeTabsUtil = {
makeSidebarContentController(); tabForAnnotation: sinon.stub().returns('annotation'),
}); };
afterEach(function () {
return sandbox.restore();
});
describe('isLoading', () => { $imports.$mock(mockImportedComponents());
it("returns true if the document's url isn't known", () => { $imports.$mock({
assert.isTrue(ctrl.isLoading()); '../store/use-store': callback => callback(fakeStore),
'../util/tabs': fakeTabsUtil,
}); });
it('returns true if annotations are still being fetched', () => {
setFrames([{ uri: 'http://www.example.com' }]);
store.annotationFetchStarted('tag:foo');
assert.isTrue(ctrl.isLoading());
}); });
it('returns false if annotations have been fetched', () => { afterEach(() => {
setFrames([{ uri: 'http://www.example.com' }]); $imports.$restore();
assert.isFalse(ctrl.isLoading());
});
}); });
describe('showSelectedTabs', () => { describe('loading annotations', () => {
let wrapper;
beforeEach(() => { beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]); fakeStore.focusedGroupId.returns('47');
}); fakeStore.searchUris.returns(['foobar']);
fakeStore.profile.returns({ userid: 'somebody' });
it('returns false if there is a search query', () => { wrapper = createComponent();
store.setFilterQuery('tag:foo'); fakeLoadAnnotationsService.load.resetHistory();
assert.isFalse(ctrl.showSelectedTabs());
}); });
it('returns false if selected group is unavailable', () => { it('loads annotations when userId changes', () => {
fakeSettings.group = 'group-id'; fakeStore.profile.returns({ userid: 'somethingElse' });
store.setDirectLinkedGroupFetchFailed(); wrapper.setProps({});
$scope.$digest(); assert.calledOnce(fakeLoadAnnotationsService.load);
assert.isFalse(ctrl.showSelectedTabs()); assert.notCalled(fakeStore.clearSelectedAnnotations);
}); });
it('returns false if selected annotation is unavailable', () => { it('clears selected annotations and loads annotations when groupId changes', () => {
store.selectAnnotations(['missing']); fakeStore.focusedGroupId.returns('affable');
$scope.$digest(); wrapper.setProps({});
assert.isFalse(ctrl.showSelectedTabs()); assert.calledOnce(fakeLoadAnnotationsService.load);
assert.calledOnce(fakeStore.clearSelectedAnnotations);
}); });
it('returns true in all other cases', () => { it('loads annotations when searchURIs change', () => {
assert.isTrue(ctrl.showSelectedTabs()); fakeStore.searchUris.returns(['abandon-ship']);
wrapper.setProps({});
assert.calledOnce(fakeLoadAnnotationsService.load);
assert.notCalled(fakeStore.clearSelectedAnnotations);
}); });
}); });
describe('showFocusedHeader', () => { context('when viewing a direct-linked annotation', () => {
it('returns true if focus mode is enabled', () => { context('successful direct-linked annotation', () => {
store.focusModeEnabled = sinon.stub().returns(true); beforeEach(() => {
assert.isTrue(ctrl.showFocusedHeader()); fakeStore.hasFetchedAnnotations.returns(true);
}); fakeStore.isFetchingAnnotations.returns(false);
it('returns false if focus mode is not enabled', () => { fakeStore.annotationExists.withArgs('someId').returns(true);
store.focusModeEnabled = sinon.stub().returns(false); fakeStore.directLinkedAnnotationId.returns('someId');
assert.isFalse(ctrl.showFocusedHeader()); fakeStore.findAnnotationByID
}); .withArgs('someId')
.returns({ $orphan: false, $tag: 'myTag' });
});
it('focuses and scrolls to direct-linked annotations once anchored', () => {
createComponent();
assert.calledOnce(fakeFrameSync.scrollToAnnotation);
assert.calledWith(fakeFrameSync.scrollToAnnotation, 'myTag');
assert.calledOnce(fakeFrameSync.focusAnnotations);
assert.calledWith(
fakeFrameSync.focusAnnotations,
sinon.match(['myTag'])
);
}); });
function connectFrameAndPerformInitialFetch() { it('selects the correct tab for direct-linked annotations once anchored', () => {
setFrames([{ uri: 'https://a-page.com' }]); createComponent();
$scope.$digest(); assert.calledOnce(fakeStore.selectTab);
fakeLoadAnnotationsService.load.reset(); assert.calledWith(fakeStore.selectTab, 'annotation');
}
it('generates the thread list', () => {
const thread = fakeRootThread.thread(store.getState());
assert.equal(ctrl.rootThread(), thread);
}); });
context('when the search URIs of connected frames change', () => { it('renders a logged-out message CTA if user is not logged in', () => {
beforeEach(connectFrameAndPerformInitialFetch); fakeStore.isLoggedIn.returns(false);
const wrapper = createComponent();
it('reloads annotations', () => { assert.isTrue(wrapper.find('LoggedOutMessage').exists());
setFrames([{ uri: 'https://new-frame.com' }]);
$scope.$digest();
assert.calledWith(
fakeLoadAnnotationsService.load,
['https://a-page.com', 'https://new-frame.com'],
'group-id'
);
}); });
}); });
context('when the profile changes', () => { context('error on direct-linked annotation', () => {
beforeEach(connectFrameAndPerformInitialFetch); beforeEach(() => {
// This puts us into a "direct-linked annotation" state
fakeStore.hasFetchedAnnotations.returns(true);
fakeStore.isFetchingAnnotations.returns(false);
fakeStore.directLinkedAnnotationId.returns('someId');
it('reloads annotations if the user ID changed', () => { // This puts us into an error state
const newProfile = Object.assign({}, store.profile(), { fakeStore.findAnnotationByID.withArgs('someId').returns(undefined);
userid: 'different-user@hypothes.is', fakeStore.annotationExists.withArgs('someId').returns(false);
}); });
store.updateProfile(newProfile); it('renders a content error', () => {
$scope.$digest(); const wrapper = createComponent();
assert.calledWith( assert.isTrue(
fakeLoadAnnotationsService.load, wrapper
['https://a-page.com'], .find('SidebarContentError')
'group-id' .filter({ errorType: 'annotation' })
.exists()
); );
}); });
it('does not reload annotations if the user ID is the same', () => { it('does not render tabs', () => {
const newProfile = Object.assign({}, store.profile(), { const wrapper = createComponent();
user_info: {
display_name: 'New display name',
},
});
store.updateProfile(newProfile); assert.isFalse(wrapper.find('SelectionTabs').exists());
$scope.$digest();
assert.notCalled(fakeLoadAnnotationsService.load);
});
}); });
describe('when an annotation is anchored', function () {
it('focuses and scrolls to the annotation if already selected', function () {
const uri = 'http://example.com';
store.getSelectedAnnotationMap = sinon.stub().returns({ '123': true });
setFrames([{ uri: uri }]);
const annot = {
$tag: 'atag',
id: '123',
};
store.addAnnotations([annot]);
$scope.$digest();
$rootScope.$broadcast(events.ANNOTATIONS_SYNCED, ['atag']);
assert.calledWith(fakeFrameSync.focusAnnotations, ['atag']);
assert.calledWith(fakeFrameSync.scrollToAnnotation, 'atag');
}); });
}); });
describe('when the focused group changes', () => { context('error with direct-linked group', () => {
const uri = 'http://example.com';
beforeEach(() => { beforeEach(() => {
// Setup an initial state with frames connected, a group focused and some fakeStore.hasFetchedAnnotations.returns(true);
// annotations loaded. fakeStore.isFetchingAnnotations.returns(false);
store.addAnnotations([{ id: '123' }]); fakeStore.directLinkedGroupFetchFailed.returns(true);
store.addAnnotations = sinon.stub();
setFrames([{ uri: uri }]);
$scope.$digest();
fakeLoadAnnotationsService.load = sinon.stub();
}); });
it('should load annotations for the new group', () => { it('renders a content error', () => {
store.loadGroups([{ id: 'different-group' }]); const wrapper = createComponent();
store.focusGroup('different-group');
$scope.$digest(); assert.isTrue(
wrapper
assert.calledWith( .find('SidebarContentError')
fakeLoadAnnotationsService.load, .filter({ errorType: 'group' })
['http://example.com'], .exists()
'different-group'
); );
}); });
it('should clear the selection', () => { it('does not render tabs', () => {
store.selectAnnotations(['123']); const wrapper = createComponent();
store.loadGroups([{ id: 'different-group' }]);
store.focusGroup('different-group');
$scope.$digest(); assert.isFalse(wrapper.find('SelectionTabs').exists());
assert.isFalse(store.hasSelectedAnnotations());
}); });
}); });
describe('direct linking messages', function () { describe('streamer', () => {
/** it('connects to streamer when sidebar is opened', () => {
* Connect a frame, indicating that the document has finished initial const wrapper = createComponent();
* loading. fakeStreamer.connect.resetHistory();
* fakeStore.hasSidebarOpened.returns(true);
* In the case of an HTML document, this usually happens immediately. For wrapper.setProps({});
* PDFs, this happens once the entire PDF has been downloaded and the assert.calledOnce(fakeStreamer.connect);
* document's metadata has been read.
*/
function addFrame() {
setFrames([
{
uri: 'http://www.example.com',
},
]);
}
beforeEach(function () {
store.setDirectLinkedAnnotationId('test');
}); });
it('displays a message if the selection is unavailable', function () { it('connects to streamer when user logs in', () => {
addFrame(); const wrapper = createComponent();
store.selectAnnotations(['missing']); fakeStreamer.connect.resetHistory();
$scope.$digest(); fakeStore.isLoggedIn.returns(true);
assert.isTrue(ctrl.selectedAnnotationUnavailable()); wrapper.setProps({});
assert.calledOnce(fakeStreamer.connect);
}); });
it('does not show a message if the selection is available', function () {
addFrame();
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.selectedAnnotationUnavailable());
}); });
it('does not a show a message if there is no selection', function () { it('renders a focused header if in focused mode', () => {
addFrame(); fakeStore.focusModeEnabled.returns(true);
store.selectAnnotations([]); const wrapper = createComponent();
$scope.$digest();
assert.isFalse(ctrl.selectedAnnotationUnavailable());
});
it("doesn't show a message if the document isn't loaded yet", function () { assert.isTrue(wrapper.find('FocusedModeHeader').exists());
// There is a selection but the selected annotation isn't available.
store.selectAnnotations(['missing']);
store.annotationFetchStarted();
$scope.$digest();
assert.isFalse(ctrl.selectedAnnotationUnavailable());
}); });
it('shows logged out message if selection is available', function () { it('renders search status', () => {
addFrame(); fakeStore.hasFetchedAnnotations.returns(true);
ctrl.auth = { fakeStore.isFetchingAnnotations.returns(false);
status: 'logged-out',
};
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isTrue(ctrl.shouldShowLoggedOutMessage());
});
it('does not show loggedout message if selection is unavailable', function () { const wrapper = createComponent();
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.selectAnnotations(['missing']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
it('does not show loggedout message if there is no selection', function () { assert.isTrue(wrapper.find('SearchStatusBar').exists());
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.selectAnnotations([]);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
}); });
it('does not show loggedout message if user is not logged out', function () { it('does not render search status if annotations are loading', () => {
addFrame(); fakeStore.hasFetchedAnnotations.returns(false);
ctrl.auth = {
status: 'logged-in',
};
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
it('does not show loggedout message if not a direct link', function () { const wrapper = createComponent();
addFrame();
ctrl.auth = { assert.isFalse(wrapper.find('SearchStatusBar').exists());
status: 'logged-out',
};
store.setDirectLinkedAnnotationId(null);
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
}); });
it('does not show loggedout message if using third-party accounts', function () { describe('selection tabs', () => {
fakeSettings.services = [{ authority: 'publisher.com' }]; it('renders tabs', () => {
addFrame(); const wrapper = createComponent();
ctrl.auth = { status: 'logged-out' };
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage()); assert.isTrue(wrapper.find('SelectionTabs').exists());
});
}); });
describe('deferred websocket connection', function () { it('does not render tabs if there is an applied filter', () => {
it('should connect the websocket the first time the sidebar opens', function () { fakeStore.hasAppliedFilter.returns(true);
$rootScope.$broadcast('sidebarOpened');
assert.called(fakeStreamer.connect);
});
describe('when logged in user changes', function () { const wrapper = createComponent();
it('should not reconnect if the sidebar is closed', function () {
$rootScope.$broadcast(events.USER_CHANGED);
assert.calledOnce(fakeStreamer.reconnect);
});
it('should reconnect if the sidebar is open', function () { assert.isFalse(wrapper.find('SelectionTabs').exists());
$rootScope.$broadcast('sidebarOpened');
fakeStreamer.connect.reset();
$rootScope.$broadcast(events.USER_CHANGED);
assert.called(fakeStreamer.reconnect);
});
}); });
}); });
it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
})
);
}); });
...@@ -108,14 +108,10 @@ registerIcons(iconSet); ...@@ -108,14 +108,10 @@ registerIcons(iconSet);
// Preact UI components that are wrapped for use within Angular templates. // Preact UI components that are wrapped for use within Angular templates.
import AnnotationViewerContent from './components/annotation-viewer-content'; import AnnotationViewerContent from './components/annotation-viewer-content';
import FocusedModeHeader from './components/focused-mode-header';
import HelpPanel from './components/help-panel'; import HelpPanel from './components/help-panel';
import LoggedOutMessage from './components/logged-out-message';
import LoginPromptPanel from './components/login-prompt-panel'; import LoginPromptPanel from './components/login-prompt-panel';
import SearchStatusBar from './components/search-status-bar';
import SelectionTabs from './components/selection-tabs';
import ShareAnnotationsPanel from './components/share-annotations-panel'; import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContentError from './components/sidebar-content-error'; import SidebarContent from './components/sidebar-content';
import StreamContent from './components/stream-content'; import StreamContent from './components/stream-content';
import ThreadList from './components/thread-list'; import ThreadList from './components/thread-list';
import ToastMessages from './components/toast-messages'; import ToastMessages from './components/toast-messages';
...@@ -124,7 +120,6 @@ import TopBar from './components/top-bar'; ...@@ -124,7 +120,6 @@ import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular. // Remaining UI components that are still built with Angular.
import hypothesisApp from './components/hypothesis-app'; import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content';
// Services. // Services.
...@@ -243,12 +238,7 @@ function startAngularApp(config) { ...@@ -243,12 +238,7 @@ function startAngularApp(config) {
) )
.component('helpPanel', wrapComponent(HelpPanel)) .component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel)) .component('loginPromptPanel', wrapComponent(LoginPromptPanel))
.component('loggedOutMessage', wrapComponent(LoggedOutMessage)) .component('sidebarContent', wrapComponent(SidebarContent))
.component('searchStatusBar', wrapComponent(SearchStatusBar))
.component('focusedModeHeader', wrapComponent(FocusedModeHeader))
.component('selectionTabs', wrapComponent(SelectionTabs))
.component('sidebarContent', sidebarContent)
.component('sidebarContentError', wrapComponent(SidebarContentError))
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel)) .component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', wrapComponent(StreamContent)) .component('streamContent', wrapComponent(StreamContent))
.component('threadList', wrapComponent(ThreadList)) .component('threadList', wrapComponent(ThreadList))
......
...@@ -3,6 +3,7 @@ import * as queryString from 'query-string'; ...@@ -3,6 +3,7 @@ import * as queryString from 'query-string';
import warnOnce from '../../shared/warn-once'; import warnOnce from '../../shared/warn-once';
import { generateHexString } from '../util/random'; import { generateHexString } from '../util/random';
import Socket from '../websocket'; import Socket from '../websocket';
import { watch } from '../util/watch';
/** /**
* Open a new WebSocket connection to the Hypothesis push notification service. * Open a new WebSocket connection to the Hypothesis push notification service.
...@@ -160,6 +161,26 @@ export default function Streamer(store, auth, groups, session, settings) { ...@@ -160,6 +161,26 @@ export default function Streamer(store, auth, groups, session, settings) {
}); });
}; };
let reconnectSetUp = false;
/**
* Set up automatic reconnecting when user changes.
*/
function setUpAutoReconnect() {
if (reconnectSetUp) {
return;
}
reconnectSetUp = true;
// Reconnect when user changes, as auth token will have changed
watch(
store.subscribe,
() => store.profile().userid,
() => {
reconnect();
}
);
}
/** /**
* Connect to the Hypothesis real time update service. * Connect to the Hypothesis real time update service.
* *
...@@ -169,10 +190,10 @@ export default function Streamer(store, auth, groups, session, settings) { ...@@ -169,10 +190,10 @@ export default function Streamer(store, auth, groups, session, settings) {
* process has started. * process has started.
*/ */
function connect() { function connect() {
setUpAutoReconnect();
if (socket) { if (socket) {
return Promise.resolve(); return Promise.resolve();
} }
return _connect(); return _connect();
} }
......
import EventEmitter from 'tiny-emitter'; import EventEmitter from 'tiny-emitter';
import fakeReduxStore from '../../test/fake-redux-store';
import Streamer from '../streamer'; import Streamer from '../streamer';
import { $imports } from '../streamer'; import { $imports } from '../streamer';
...@@ -43,12 +44,14 @@ const fixtures = { ...@@ -43,12 +44,14 @@ const fixtures = {
// the most recently created FakeSocket instance // the most recently created FakeSocket instance
let fakeWebSocket = null; let fakeWebSocket = null;
let fakeWebSockets = [];
class FakeSocket extends EventEmitter { class FakeSocket extends EventEmitter {
constructor(url) { constructor(url) {
super(); super();
fakeWebSocket = this; // eslint-disable-line consistent-this fakeWebSocket = this; // eslint-disable-line consistent-this
fakeWebSockets.push(this);
this.url = url; this.url = url;
this.messages = []; this.messages = [];
...@@ -95,7 +98,9 @@ describe('Streamer', function () { ...@@ -95,7 +98,9 @@ describe('Streamer', function () {
}, },
}; };
fakeStore = { fakeStore = fakeReduxStore(
{},
{
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
annotationExists: sinon.stub().returns(false), annotationExists: sinon.stub().returns(false),
clearPendingUpdates: sinon.stub(), clearPendingUpdates: sinon.stub(),
...@@ -107,7 +112,8 @@ describe('Streamer', function () { ...@@ -107,7 +112,8 @@ describe('Streamer', function () {
receiveRealTimeUpdates: sinon.stub(), receiveRealTimeUpdates: sinon.stub(),
removeAnnotations: sinon.stub(), removeAnnotations: sinon.stub(),
route: sinon.stub().returns('sidebar'), route: sinon.stub().returns('sidebar'),
}; }
);
fakeGroups = { fakeGroups = {
focused: sinon.stub().returns({ id: 'public' }), focused: sinon.stub().returns({ id: 'public' }),
...@@ -130,6 +136,7 @@ describe('Streamer', function () { ...@@ -130,6 +136,7 @@ describe('Streamer', function () {
afterEach(function () { afterEach(function () {
$imports.$restore(); $imports.$restore();
activeStreamer = null; activeStreamer = null;
fakeWebSockets = [];
}); });
it('should not create a websocket connection if websocketUrl is not provided', function () { it('should not create a websocket connection if websocketUrl is not provided', function () {
...@@ -246,6 +253,47 @@ describe('Streamer', function () { ...@@ -246,6 +253,47 @@ describe('Streamer', function () {
}); });
}); });
describe('Automatic reconnection', function () {
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
it('should reconnect when user changes', function () {
let oldWebSocket;
createDefaultStreamer();
return activeStreamer
.connect()
.then(function () {
oldWebSocket = fakeWebSocket;
fakeStore.profile.returns({ userid: 'somebody' });
return fakeStore.setState({});
})
.then(function () {
assert.ok(oldWebSocket.didClose);
assert.ok(!fakeWebSocket.didClose);
});
});
it('should only set up auto-reconnect once', async () => {
createDefaultStreamer();
// This should register auto-reconnect
await activeStreamer.connect();
// Call connect again: this should not "re-register" auto-reconnect
await activeStreamer.connect();
// This should trigger auto-reconnect, but only once, proving that
// only one registration happened
fakeStore.profile.returns({ userid: 'somebody' });
fakeStore.setState({});
await delay(1);
// Total number of web sockets blown through in this test should be 2
// 3+ would indicate `reconnect` fired more than once
assert.lengthOf(fakeWebSockets, 2);
});
});
describe('annotation notifications', function () { describe('annotation notifications', function () {
beforeEach(function () { beforeEach(function () {
createDefaultStreamer(); createDefaultStreamer();
......
...@@ -15,11 +15,7 @@ ...@@ -15,11 +15,7 @@
<main ng-if="vm.route()"> <main ng-if="vm.route()">
<annotation-viewer-content ng-if="vm.route() == 'annotation'"></annotation-viewer-content> <annotation-viewer-content ng-if="vm.route() == 'annotation'"></annotation-viewer-content>
<stream-content ng-if="vm.route() == 'stream'"></stream-content> <stream-content ng-if="vm.route() == 'stream'"></stream-content>
<sidebar-content <sidebar-content ng-if="vm.route() == 'sidebar'" on-login="vm.login()" on-signUp="vm.signUp()"></sidebar-content>
ng-if="vm.route() == 'sidebar'"
auth="vm.auth"
on-login="vm.login()"
on-sign-up="vm.signUp()"></sidebar-content>
</main> </main>
</div> </div>
</div> </div>
<focused-mode-header
ng-if="vm.showFocusedHeader()">
</focused-mode-header>
<login-prompt-panel on-login="vm.onLogin()" on-sign-up="vm.onSignUp()"></login-prompt-panel>
<!-- Display error message if direct-linked annotation fetch failed. -->
<sidebar-content-error
error-type="'annotation'"
on-login-request="vm.onLogin()"
ng-if="vm.selectedAnnotationUnavailable()"
>
</sidebar-content-error>
<!-- Display error message if direct-linked group fetch failed. -->
<sidebar-content-error
error-type="'group'"
on-login-request="vm.onLogin()"
ng-if="vm.selectedGroupUnavailable()"
>
</sidebar-content-error>
<selection-tabs
ng-if="vm.showSelectedTabs()"
is-loading="vm.isLoading()">
</selection-tabs>
<search-status-bar
ng-if="!vm.isLoading() && !(vm.selectedAnnotationUnavailable() || vm.selectedGroupUnavailable())">
</search-status-bar>
<thread-list thread="vm.rootThread()"></thread-list>
<logged-out-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()">
</logged-out-message>
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