Unverified Commit d0963217 authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #2103 from hypothesis/migrate-sidebar-content

Migrate `SidebarContent` to preact
parents f2d53d7b 30389104
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(),
})
);
}); });
...@@ -19,7 +19,4 @@ export default { ...@@ -19,7 +19,4 @@ export default {
/** A new annotation has been created locally. */ /** A new annotation has been created locally. */
BEFORE_ANNOTATION_CREATED: 'beforeAnnotationCreated', BEFORE_ANNOTATION_CREATED: 'beforeAnnotationCreated',
/** Annotations were anchored in a connected document. */
ANNOTATIONS_SYNCED: 'sync',
}; };
...@@ -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))
......
...@@ -144,10 +144,6 @@ export default function FrameSync($rootScope, $window, store, bridge) { ...@@ -144,10 +144,6 @@ export default function FrameSync($rootScope, $window, store, bridge) {
let anchoringStatusUpdates = {}; let anchoringStatusUpdates = {};
const scheduleAnchoringStatusUpdate = debounce(() => { const scheduleAnchoringStatusUpdate = debounce(() => {
store.updateAnchorStatus(anchoringStatusUpdates); store.updateAnchorStatus(anchoringStatusUpdates);
$rootScope.$broadcast(
events.ANNOTATIONS_SYNCED,
Object.keys(anchoringStatusUpdates)
);
anchoringStatusUpdates = {}; anchoringStatusUpdates = {};
}, 10); }, 10);
...@@ -176,7 +172,7 @@ export default function FrameSync($rootScope, $window, store, bridge) { ...@@ -176,7 +172,7 @@ export default function FrameSync($rootScope, $window, store, bridge) {
}); });
bridge.on('sidebarOpened', function () { bridge.on('sidebarOpened', function () {
$rootScope.$broadcast('sidebarOpened'); store.setSidebarOpened(true);
}); });
// These invoke the matching methods by name on the Guests // These invoke the matching methods by name on the Guests
......
...@@ -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();
} }
......
...@@ -66,6 +66,7 @@ describe('sidebar/services/frame-sync', function () { ...@@ -66,6 +66,7 @@ describe('sidebar/services/frame-sync', function () {
openSidebarPanel: sinon.stub(), openSidebarPanel: sinon.stub(),
selectAnnotations: sinon.stub(), selectAnnotations: sinon.stub(),
selectTab: sinon.stub(), selectTab: sinon.stub(),
setSidebarOpened: sinon.stub(),
toggleSelectedAnnotations: sinon.stub(), toggleSelectedAnnotations: sinon.stub(),
updateAnchorStatus: sinon.stub(), updateAnchorStatus: sinon.stub(),
} }
...@@ -305,14 +306,6 @@ describe('sidebar/services/frame-sync', function () { ...@@ -305,14 +306,6 @@ describe('sidebar/services/frame-sync', function () {
t2: 'orphan', t2: 'orphan',
}); });
}); });
it('emits an ANNOTATIONS_SYNCED event', function () {
fakeBridge.emit('sync', [{ tag: 't1', msg: { $orphan: false } }]);
expireDebounceTimeout();
assert.calledWith($rootScope.$broadcast, events.ANNOTATIONS_SYNCED, [
't1',
]);
});
}); });
context('when a new frame connects', function () { context('when a new frame connects', function () {
...@@ -376,9 +369,10 @@ describe('sidebar/services/frame-sync', function () { ...@@ -376,9 +369,10 @@ describe('sidebar/services/frame-sync', function () {
}); });
describe('on "sidebarOpened" message', function () { describe('on "sidebarOpened" message', function () {
it('broadcasts a sidebarOpened event', function () { it('sets the sidebar open in the store', function () {
fakeBridge.emit('sidebarOpened'); fakeBridge.emit('sidebarOpened');
assert.calledWith($rootScope.$broadcast, 'sidebarOpened');
assert.calledWith(fakeStore.setSidebarOpened, true);
}); });
}); });
......
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();
......
...@@ -19,6 +19,10 @@ function init() { ...@@ -19,6 +19,10 @@ function init() {
* The number of annotation fetches that have started and not yet completed. * The number of annotation fetches that have started and not yet completed.
*/ */
activeAnnotationFetches: 0, activeAnnotationFetches: 0,
/**
* Have annotations ever been fetched?
*/
hasFetchedAnnotations: false,
}; };
} }
...@@ -86,6 +90,7 @@ const update = { ...@@ -86,6 +90,7 @@ const update = {
return { return {
...state, ...state,
hasFetchedAnnotations: true,
activeAnnotationFetches: state.activeAnnotationFetches - 1, activeAnnotationFetches: state.activeAnnotationFetches - 1,
}; };
}, },
...@@ -127,6 +132,10 @@ function apiRequestFinished() { ...@@ -127,6 +132,10 @@ function apiRequestFinished() {
/** Selectors */ /** Selectors */
function hasFetchedAnnotations(state) {
return state.activity.hasFetchedAnnotations;
}
/** /**
* Return true when annotations are actively being fetched. * Return true when annotations are actively being fetched.
*/ */
...@@ -173,6 +182,7 @@ export default { ...@@ -173,6 +182,7 @@ export default {
}, },
selectors: { selectors: {
hasFetchedAnnotations,
isLoading, isLoading,
isFetchingAnnotations, isFetchingAnnotations,
isSavingAnnotation, isSavingAnnotation,
......
...@@ -136,6 +136,10 @@ function directLinkedGroupId(state) { ...@@ -136,6 +136,10 @@ function directLinkedGroupId(state) {
return state.directLinked.directLinkedGroupId; return state.directLinked.directLinkedGroupId;
} }
function directLinkedGroupFetchFailed(state) {
return state.directLinked.directLinkedGroupFetchFailed;
}
export default { export default {
init, init,
namespace: 'directLinked', namespace: 'directLinked',
...@@ -149,6 +153,7 @@ export default { ...@@ -149,6 +153,7 @@ export default {
}, },
selectors: { selectors: {
directLinkedAnnotationId, directLinkedAnnotationId,
directLinkedGroupFetchFailed,
directLinkedGroupId, directLinkedGroupId,
}, },
}; };
import { createSelector } from 'reselect'; import {
createSelector,
createSelectorCreator,
defaultMemoize,
} from 'reselect';
import shallowEqual from 'shallowequal';
import * as util from '../util'; import * as util from '../util';
...@@ -103,15 +108,23 @@ function searchUrisForFrame(frame) { ...@@ -103,15 +108,23 @@ function searchUrisForFrame(frame) {
return uris; return uris;
} }
/** // "selector creator" that uses `shallowEqual` instead of `===` for memoization
* Return the set of URIs that should be used to search for annotations on the const createShallowEqualSelector = createSelectorCreator(
* current page. defaultMemoize,
*/ shallowEqual
function searchUris(state) { );
return state.frames.reduce(function (uris, frame) {
return uris.concat(searchUrisForFrame(frame)); // Memoized selector will return the same array (of URIs) reference unless the
}, []); // values of the array change (are not shallow-equal).
} const searchUris = createShallowEqualSelector(
state => {
return state.frames.reduce(
(uris, frame) => uris.concat(searchUrisForFrame(frame)),
[]
);
},
uris => uris
);
export default { export default {
init: init, init: init,
......
...@@ -519,6 +519,21 @@ function getSelectedAnnotationMap(state) { ...@@ -519,6 +519,21 @@ function getSelectedAnnotationMap(state) {
return state.selection.selectedAnnotationMap; return state.selection.selectedAnnotationMap;
} }
/**
* Is any sort of filtering currently applied to the list of annotations? This
* includes a search query, but also if annotations are selected or a user
* is focused.
*
* @return {boolean}
*/
const hasAppliedFilter = createSelector(
filterQuery,
focusModeFocused,
hasSelectedAnnotations,
(filterQuery, focusModeFocused, hasSelectedAnnotations) =>
!!filterQuery || focusModeFocused || hasSelectedAnnotations
);
export default { export default {
init: init, init: init,
namespace: 'selection', namespace: 'selection',
...@@ -541,7 +556,6 @@ export default { ...@@ -541,7 +556,6 @@ export default {
}, },
selectors: { selectors: {
hasSelectedAnnotations,
expandedThreads, expandedThreads,
filterQuery, filterQuery,
focusModeFocused, focusModeFocused,
...@@ -553,5 +567,7 @@ export default { ...@@ -553,5 +567,7 @@ export default {
isAnnotationSelected, isAnnotationSelected,
getFirstSelectedAnnotationId, getFirstSelectedAnnotationId,
getSelectedAnnotationMap, getSelectedAnnotationMap,
hasAppliedFilter,
hasSelectedAnnotations,
}, },
}; };
...@@ -8,6 +8,23 @@ describe('sidebar/store/modules/activity', () => { ...@@ -8,6 +8,23 @@ describe('sidebar/store/modules/activity', () => {
store = createStore([activity]); store = createStore([activity]);
}); });
describe('hasFetchedAnnotations', () => {
it('returns false if no fetches have completed yet', () => {
assert.isFalse(store.hasFetchedAnnotations());
});
it('returns false after fetch(es) started', () => {
store.annotationFetchStarted();
assert.isFalse(store.hasFetchedAnnotations());
});
it('returns true once a fetch has finished', () => {
store.annotationFetchStarted();
store.annotationFetchFinished();
assert.isTrue(store.hasFetchedAnnotations());
});
});
describe('#isLoading', () => { describe('#isLoading', () => {
it('returns false with the initial state', () => { it('returns false with the initial state', () => {
assert.equal(store.isLoading(), false); assert.equal(store.isLoading(), false);
......
...@@ -88,6 +88,13 @@ describe('sidebar/store/modules/direct-linked', () => { ...@@ -88,6 +88,13 @@ describe('sidebar/store/modules/direct-linked', () => {
}); });
}); });
describe('#directLinkedGroupFetchFailed', () => {
it('should return the group fetch failed status', () => {
store.setDirectLinkedGroupFetchFailed(true);
assert.isTrue(store.directLinkedGroupFetchFailed());
});
});
describe('#directLinkedGroupId', () => { describe('#directLinkedGroupId', () => {
it('should return the current direct-linked group ID', () => { it('should return the current direct-linked group ID', () => {
store.setDirectLinkedGroupId('group-id'); store.setDirectLinkedGroupId('group-id');
......
...@@ -157,7 +157,12 @@ describe('sidebar/store/modules/frames', function () { ...@@ -157,7 +157,12 @@ describe('sidebar/store/modules/frames', function () {
testCase.frames.forEach(frame => { testCase.frames.forEach(frame => {
store.connectFrame(frame); store.connectFrame(frame);
}); });
assert.deepEqual(store.searchUris(), testCase.searchUris); const firstResults = store.searchUris();
const secondResults = store.searchUris();
assert.deepEqual(firstResults, testCase.searchUris);
// The selector is memoized and should return the same Array reference
// assuming the list of search URIs hasn't changed
assert.equal(firstResults, secondResults);
}); });
}); });
}); });
......
...@@ -62,6 +62,34 @@ describe('sidebar/store/modules/selection', () => { ...@@ -62,6 +62,34 @@ describe('sidebar/store/modules/selection', () => {
}); });
}); });
describe('hasAppliedFilter', () => {
it('returns true if there is a search query set', () => {
store.setFilterQuery('foobar');
assert.isTrue(store.hasAppliedFilter());
});
it('returns true if in user-focused mode', () => {
store = createStore([selection], [{ focus: { user: {} } }]);
store.setFocusModeFocused(true);
assert.isTrue(store.hasAppliedFilter());
});
it('returns true if there are selected annotations', () => {
store.selectAnnotations([1]);
assert.isTrue(store.hasAppliedFilter());
});
it('returns false after selection is cleared', () => {
store.setFilterQuery('foobar');
store.clearSelection();
assert.isFalse(store.hasAppliedFilter());
});
});
describe('hasSelectedAnnotations', function () { describe('hasSelectedAnnotations', function () {
it('returns true if there are any selected annotations', function () { it('returns true if there are any selected annotations', function () {
store.selectAnnotations([1]); store.selectAnnotations([1]);
......
...@@ -19,4 +19,23 @@ describe('store/modules/viewer', function () { ...@@ -19,4 +19,23 @@ describe('store/modules/viewer', function () {
assert.isFalse(store.getState().viewer.visibleHighlights); assert.isFalse(store.getState().viewer.visibleHighlights);
}); });
}); });
describe('hasSidebarOpened', () => {
it('is `false` if sidebar has never been opened', () => {
assert.isFalse(store.hasSidebarOpened());
store.setSidebarOpened(false);
assert.isFalse(store.hasSidebarOpened());
});
it('is `true` if sidebar has been opened', () => {
store.setSidebarOpened(true);
assert.isTrue(store.hasSidebarOpened());
});
it('is `true` if sidebar is closed after being opened', () => {
store.setSidebarOpened(true);
store.setSidebarOpened(false);
assert.isTrue(store.hasSidebarOpened());
});
});
}); });
...@@ -7,6 +7,9 @@ import * as util from '../util'; ...@@ -7,6 +7,9 @@ import * as util from '../util';
function init() { function init() {
return { return {
// Has the sidebar ever been opened? NB: This is not necessarily the
// current state of the sidebar, but tracks whether it has ever been open
sidebarHasOpened: false,
visibleHighlights: false, visibleHighlights: false,
}; };
} }
...@@ -15,10 +18,20 @@ const update = { ...@@ -15,10 +18,20 @@ const update = {
SET_HIGHLIGHTS_VISIBLE: function (state, action) { SET_HIGHLIGHTS_VISIBLE: function (state, action) {
return { visibleHighlights: action.visible }; return { visibleHighlights: action.visible };
}, },
SET_SIDEBAR_OPENED: (state, action) => {
if (action.opened === true) {
// If the sidebar is open, track that it has ever been opened
return { sidebarHasOpened: true };
}
// Otherwise, nothing to do here
return {};
},
}; };
const actions = util.actionTypes(update); const actions = util.actionTypes(update);
// Action creators
/** /**
* Sets whether annotation highlights in connected documents are shown * Sets whether annotation highlights in connected documents are shown
* or not. * or not.
...@@ -27,12 +40,28 @@ function setShowHighlights(show) { ...@@ -27,12 +40,28 @@ function setShowHighlights(show) {
return { type: actions.SET_HIGHLIGHTS_VISIBLE, visible: show }; return { type: actions.SET_HIGHLIGHTS_VISIBLE, visible: show };
} }
/**
* @param {boolean} sidebarState - If the sidebar is open
*/
function setSidebarOpened(opened) {
return { type: actions.SET_SIDEBAR_OPENED, opened };
}
// Selectors
function hasSidebarOpened(state) {
return state.viewer.sidebarHasOpened;
}
export default { export default {
init: init, init: init,
namespace: 'viewer', namespace: 'viewer',
update: update, update: update,
actions: { actions: {
setShowHighlights: setShowHighlights, setShowHighlights,
setSidebarOpened,
},
selectors: {
hasSidebarOpened,
}, },
selectors: {},
}; };
...@@ -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