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';
import LoginPromptPanel from './login-prompt-panel';
import SearchStatusBar from './search-status-bar';
import SelectionTabs from './selection-tabs';
import SidebarContentError from './sidebar-content-error';
import ThreadList from './thread-list';
/**
* Render the sidebar and its components
*/
function SidebarContent({
frameSync, frameSync,
rootThread, onLogin,
settings, onSignUp,
streamer loadAnnotationsService,
) { rootThread: rootThreadService,
const self = this; streamer,
}) {
this.rootThread = () => rootThread.thread(store.getState()); const rootThread = useStore(store =>
rootThreadService.thread(store.getState())
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() {
const selectedAnnotationMap = store.getSelectedAnnotationMap();
if (selectedAnnotationMap) {
const id = Object.keys(selectedAnnotationMap)[0];
return store.getState().annotations.annotations.find(function (annot) {
return annot.id === id;
});
} else {
return null;
}
}
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(); // Store state values
const focusedGroupId = useStore(store => store.focusedGroupId());
const hasAppliedFilter = useStore(store => store.hasAppliedFilter());
const isFocusedMode = useStore(store => store.focusModeEnabled());
const annotationsLoading = useStore(store => {
return !store.hasFetchedAnnotations() || store.isFetchingAnnotations();
});
const isLoggedIn = useStore(store => store.isLoggedIn());
const linkedAnnotationId = useStore(store =>
store.directLinkedAnnotationId()
);
const linkedAnnotation = useStore(store => {
return linkedAnnotationId
? 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);
this.$onInit = () => { // If, after loading completes, no `linkedAnnotation` object is present when
// If the user is logged in, we connect nevertheless // a `linkedAnnotationId` is set, that indicates an error
if (this.auth.status === 'logged-in') { const hasDirectLinkedAnnotationError =
streamer.connect(); !annotationsLoading && linkedAnnotationId ? !linkedAnnotation : false;
}
};
$scope.$on(events.USER_CHANGED, function () { const hasDirectLinkedGroupError = useStore(store =>
streamer.reconnect(); store.directLinkedGroupFetchFailed()
}); );
$scope.$on(events.ANNOTATIONS_SYNCED, function (event, tags) { const hasContentError =
// When a direct-linked annotation is successfully anchored in the page, hasDirectLinkedAnnotationError || hasDirectLinkedGroupError;
// focus and scroll to it
const selectedAnnot = firstSelectedAnnotation();
if (!selectedAnnot) {
return;
}
const matchesSelection = tags.some(function (tag) {
return tag === selectedAnnot.$tag;
});
if (!matchesSelection) {
return;
}
focusAnnotation(selectedAnnot);
scrollToAnnotation(selectedAnnot);
store.selectTab(tabs.tabForAnnotation(selectedAnnot)); const showTabs = !hasContentError && !hasAppliedFilter;
}); const showSearchStatus = !hasContentError && !annotationsLoading;
// Re-fetch annotations when focused group, logged-in user or connected frames // Show a CTA to log in if successfully viewing a direct-linked annotation
// change. // and not logged in
$scope.$watch( const showLoggedOutMessage =
() => [ linkedAnnotationId &&
store.focusedGroupId(), !isLoggedIn &&
store.profile().userid, !hasDirectLinkedAnnotationError &&
...store.searchUris(), !annotationsLoading;
],
([currentGroupId], [prevGroupId]) => { const prevGroupId = useRef(focusedGroupId);
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();
loadAnnotationsService.load(searchUris, currentGroupId);
},
true
);
this.showFocusedHeader = () => { // Reload annotations when group, user or document search URIs change
return store.focusModeEnabled(); useEffect(() => {
}; if (!prevGroupId.current || prevGroupId.current !== focusedGroupId) {
// Clear any selected annotations when the group ID changes
this.showSelectedTabs = function () { clearSelectedAnnotations();
if ( prevGroupId.current = focusedGroupId;
this.selectedAnnotationUnavailable() ||
this.selectedGroupUnavailable() ||
store.getState().selection.filterQuery
) {
return false;
} else if (store.focusModeFocused()) {
return false;
} else {
return true;
} }
}; if (focusedGroupId && searchUris.length) {
loadAnnotationsService.load(searchUris, focusedGroupId);
this.setCollapsed = function (id, collapsed) {
store.setCollapsed(id, collapsed);
};
this.focus = focusAnnotation;
this.scrollTo = scrollToAnnotation;
this.selectedGroupUnavailable = function () {
return store.getState().directLinked.directLinkedGroupFetchFailed;
};
this.selectedAnnotationUnavailable = function () {
const selectedID = store.getFirstSelectedAnnotationId();
return (
!this.isLoading() && !!selectedID && !store.annotationExists(selectedID)
);
};
this.shouldShowLoggedOutMessage = function () {
// If user is not logged out, don't show CTA.
if (self.auth.status !== 'logged-out') {
return false;
} }
}, [
// If user has not landed on a direct linked annotation clearSelectedAnnotations,
// don't show the CTA. loadAnnotationsService,
if (!store.getState().directLinked.directLinkedAnnotationId) { 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]);
// The CTA text and links are only applicable when using Hypothesis // Connect to the streamer when the sidebar has opened or if user is logged in
// accounts. useEffect(() => {
if (isThirdPartyService(settings)) { if (sidebarHasOpened || isLoggedIn) {
return false; 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 return (
// selection is available to the user, show the CTA. <div>
const selectedID = store.getFirstSelectedAnnotationId(); {isFocusedMode && <FocusedModeHeader />}
return ( <LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
!store.isFetchingAnnotations() && {hasDirectLinkedAnnotationError && (
!!selectedID && <SidebarContentError errorType="annotation" onLoginRequest={onLogin} />
store.annotationExists(selectedID) )}
); {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 () { import SidebarContent from '../sidebar-content';
angular import { $imports } from '../sidebar-content';
.module('h', [])
.service('store', storeFactory)
.component('sidebarContent', sidebarContent);
});
beforeEach(angular.mock.module('h')); import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
beforeEach(() => {
angular.mock.module(function ($provide) {
sandbox = sinon.createSandbox();
fakeAnalytics = {
track: sandbox.stub(),
events: {},
};
fakeFrameSync = {
focusAnnotations: sinon.stub(),
scrollToAnnotation: sinon.stub(),
};
fakeStreamer = {
setConfig: sandbox.stub(),
connect: sandbox.stub(),
reconnect: sandbox.stub(),
};
fakeLoadAnnotationsService = {
load: sinon.stub(),
};
fakeRootThread = new FakeRootThread();
fakeSettings = {};
$provide.value('analytics', fakeAnalytics);
$provide.value('frameSync', fakeFrameSync);
$provide.value('rootThread', fakeRootThread);
$provide.value('streamer', fakeStreamer);
$provide.value('loadAnnotationsService', fakeLoadAnnotationsService);
$provide.value('settings', fakeSettings);
});
});
function setFrames(frames) { describe('SidebarContent', () => {
frames.forEach(function (frame) { let fakeFrameSync;
store.connectFrame(frame); let fakeLoadAnnotationsService;
}); let fakeRootThreadService;
} let fakeStore;
let fakeStreamer;
const makeSidebarContentController = () => { let fakeTabsUtil;
angular.mock.inject(function ($componentController, _store_, _$rootScope_) {
$rootScope = _$rootScope_; const createComponent = props =>
$scope = $rootScope.$new(); mount(
<SidebarContent
store = _store_; onLogin={() => null}
store.updateFrameAnnotationFetchStatus = sinon.stub(); onSignUp={() => null}
store.clearGroups(); frameSync={fakeFrameSync}
store.loadGroups([{ id: 'group-id' }]); loadAnnotationsService={fakeLoadAnnotationsService}
store.focusGroup('group-id'); rootThread={fakeRootThreadService}
streamer={fakeStreamer}
ctrl = $componentController( {...props}
'sidebarContent', />
{ $scope: $scope }, );
{
auth: { status: 'unknown' },
}
);
});
};
beforeEach(() => { beforeEach(() => {
makeSidebarContentController(); fakeFrameSync = {
}); focusAnnotations: sinon.stub(),
scrollToAnnotation: sinon.stub(),
afterEach(function () { };
return sandbox.restore(); fakeLoadAnnotationsService = {
}); load: sinon.stub(),
};
describe('isLoading', () => { fakeRootThreadService = {
it("returns true if the document's url isn't known", () => { thread: sinon.stub().returns({}),
assert.isTrue(ctrl.isLoading()); };
}); fakeStreamer = {
connect: sinon.stub(),
it('returns true if annotations are still being fetched', () => { };
setFrames([{ uri: 'http://www.example.com' }]); fakeStore = {
store.annotationFetchStarted('tag:foo'); // actions
assert.isTrue(ctrl.isLoading()); clearSelectedAnnotations: sinon.stub(),
selectTab: sinon.stub(),
// selectors
annotationExists: sinon.stub(),
directLinkedAnnotationId: sinon.stub(),
directLinkedGroupFetchFailed: sinon.stub(),
findAnnotationByID: sinon.stub(),
focusedGroupId: sinon.stub(),
focusModeEnabled: sinon.stub(),
hasAppliedFilter: sinon.stub(),
hasFetchedAnnotations: sinon.stub(),
hasSidebarOpened: sinon.stub(),
isFetchingAnnotations: sinon.stub(),
isLoggedIn: sinon.stub(),
getState: sinon.stub(),
profile: sinon.stub().returns({ userid: null }),
searchUris: sinon.stub().returns([]),
};
fakeTabsUtil = {
tabForAnnotation: sinon.stub().returns('annotation'),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/tabs': fakeTabsUtil,
}); });
});
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' });
});
function connectFrameAndPerformInitialFetch() {
setFrames([{ uri: 'https://a-page.com' }]);
$scope.$digest();
fakeLoadAnnotationsService.load.reset();
}
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', () => {
beforeEach(connectFrameAndPerformInitialFetch);
it('reloads annotations', () => { it('focuses and scrolls to direct-linked annotations once anchored', () => {
setFrames([{ uri: 'https://new-frame.com' }]); createComponent();
assert.calledOnce(fakeFrameSync.scrollToAnnotation);
assert.calledWith(fakeFrameSync.scrollToAnnotation, 'myTag');
assert.calledOnce(fakeFrameSync.focusAnnotations);
assert.calledWith(
fakeFrameSync.focusAnnotations,
sinon.match(['myTag'])
);
});
$scope.$digest(); it('selects the correct tab for direct-linked annotations once anchored', () => {
createComponent();
assert.calledOnce(fakeStore.selectTab);
assert.calledWith(fakeStore.selectTab, 'annotation');
});
assert.calledWith( it('renders a logged-out message CTA if user is not logged in', () => {
fakeLoadAnnotationsService.load, fakeStore.isLoggedIn.returns(false);
['https://a-page.com', 'https://new-frame.com'], const wrapper = createComponent();
'group-id' assert.isTrue(wrapper.find('LoggedOutMessage').exists());
); });
}); });
});
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', () => {
const newProfile = Object.assign({}, store.profile(), {
user_info: {
display_name: 'New display name',
},
}); });
store.updateProfile(newProfile); it('does not render tabs', () => {
$scope.$digest(); const wrapper = createComponent();
assert.notCalled(fakeLoadAnnotationsService.load); assert.isFalse(wrapper.find('SelectionTabs').exists());
}); });
});
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(store.hasSelectedAnnotations()); assert.isFalse(wrapper.find('SelectionTabs').exists());
}); });
}); });
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 () {
addFrame();
store.selectAnnotations(['missing']);
$scope.$digest();
assert.isTrue(ctrl.selectedAnnotationUnavailable());
}); });
it('does not show a message if the selection is available', function () { it('connects to streamer when user logs in', () => {
addFrame(); const wrapper = createComponent();
store.addAnnotations([{ id: '123' }]); fakeStreamer.connect.resetHistory();
store.selectAnnotations(['123']); fakeStore.isLoggedIn.returns(true);
$scope.$digest(); wrapper.setProps({});
assert.isFalse(ctrl.selectedAnnotationUnavailable()); assert.calledOnce(fakeStreamer.connect);
}); });
});
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('renders search status', () => {
}); fakeStore.hasFetchedAnnotations.returns(true);
fakeStore.isFetchingAnnotations.returns(false);
it('shows logged out message if selection is available', function () { const wrapper = createComponent();
addFrame();
ctrl.auth = {
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 () { assert.isTrue(wrapper.find('SearchStatusBar').exists());
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 () { it('does not render search status if annotations are loading', () => {
addFrame(); fakeStore.hasFetchedAnnotations.returns(false);
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 () { const wrapper = createComponent();
addFrame();
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 () { assert.isFalse(wrapper.find('SearchStatusBar').exists());
addFrame(); });
ctrl.auth = {
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,19 +98,22 @@ describe('Streamer', function () { ...@@ -95,19 +98,22 @@ describe('Streamer', function () {
}, },
}; };
fakeStore = { fakeStore = fakeReduxStore(
addAnnotations: sinon.stub(), {},
annotationExists: sinon.stub().returns(false), {
clearPendingUpdates: sinon.stub(), addAnnotations: sinon.stub(),
pendingUpdates: sinon.stub().returns({}), annotationExists: sinon.stub().returns(false),
pendingDeletions: sinon.stub().returns({}), clearPendingUpdates: sinon.stub(),
profile: sinon.stub().returns({ pendingUpdates: sinon.stub().returns({}),
userid: 'jim@hypothes.is', pendingDeletions: sinon.stub().returns({}),
}), profile: sinon.stub().returns({
receiveRealTimeUpdates: sinon.stub(), userid: 'jim@hypothes.is',
removeAnnotations: sinon.stub(), }),
route: sinon.stub().returns('sidebar'), receiveRealTimeUpdates: sinon.stub(),
}; removeAnnotations: sinon.stub(),
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