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 isThirdPartyService from '../util/is-third-party-service';
import * as tabs from '../util/tabs';
// @ngInject
function SidebarContentController(
$scope,
analytics,
loadAnnotationsService,
store,
import { createElement } from 'preact';
import propTypes from 'prop-types';
import { useEffect, useRef } from 'preact/hooks';
import { withServices } from '../util/service-context';
import useStore from '../store/use-store';
import { tabForAnnotation } from '../util/tabs';
import FocusedModeHeader from './focused-mode-header';
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,
rootThread,
settings,
streamer
) {
const self = this;
this.rootThread = () => rootThread.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);
onLogin,
onSignUp,
loadAnnotationsService,
rootThread: rootThreadService,
streamer,
}) {
const rootThread = useStore(store =>
rootThreadService.thread(store.getState())
);
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 the user is logged in, we connect nevertheless
if (this.auth.status === 'logged-in') {
streamer.connect();
}
};
// If, after loading completes, no `linkedAnnotation` object is present when
// a `linkedAnnotationId` is set, that indicates an error
const hasDirectLinkedAnnotationError =
!annotationsLoading && linkedAnnotationId ? !linkedAnnotation : false;
$scope.$on(events.USER_CHANGED, function () {
streamer.reconnect();
});
const hasDirectLinkedGroupError = useStore(store =>
store.directLinkedGroupFetchFailed()
);
$scope.$on(events.ANNOTATIONS_SYNCED, function (event, tags) {
// When a direct-linked annotation is successfully anchored in the page,
// 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);
const hasContentError =
hasDirectLinkedAnnotationError || hasDirectLinkedGroupError;
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
// change.
$scope.$watch(
() => [
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();
loadAnnotationsService.load(searchUris, currentGroupId);
},
true
);
// Show a CTA to log in if successfully viewing a direct-linked annotation
// and not logged in
const showLoggedOutMessage =
linkedAnnotationId &&
!isLoggedIn &&
!hasDirectLinkedAnnotationError &&
!annotationsLoading;
const prevGroupId = useRef(focusedGroupId);
this.showFocusedHeader = () => {
return store.focusModeEnabled();
};
this.showSelectedTabs = function () {
if (
this.selectedAnnotationUnavailable() ||
this.selectedGroupUnavailable() ||
store.getState().selection.filterQuery
) {
return false;
} else if (store.focusModeFocused()) {
return false;
} else {
return true;
// Reload annotations when group, user or document search URIs change
useEffect(() => {
if (!prevGroupId.current || prevGroupId.current !== focusedGroupId) {
// Clear any selected annotations when the group ID changes
clearSelectedAnnotations();
prevGroupId.current = 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 (focusedGroupId && searchUris.length) {
loadAnnotationsService.load(searchUris, focusedGroupId);
}
// If user has not landed on a direct linked annotation
// don't show the CTA.
if (!store.getState().directLinked.directLinkedAnnotationId) {
return false;
}, [
clearSelectedAnnotations,
loadAnnotationsService,
focusedGroupId,
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
// accounts.
if (isThirdPartyService(settings)) {
return false;
// Connect to the streamer when the sidebar has opened or if user is logged in
useEffect(() => {
if (sidebarHasOpened || isLoggedIn) {
streamer.connect();
}
// 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 (
!store.isFetchingAnnotations() &&
!!selectedID &&
store.annotationExists(selectedID)
);
};
}, [streamer, sidebarHasOpened, isLoggedIn]);
return (
<div>
{isFocusedMode && <FocusedModeHeader />}
<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 {
controller: SidebarContentController,
controllerAs: 'vm',
bindings: {
auth: '<',
onLogin: '&',
onSignUp: '&',
},
template: require('../templates/sidebar-content.html'),
SidebarContent.propTypes = {
// Callbacks for log in and out
onLogin: propTypes.func.isRequired,
onSignUp: propTypes.func.isRequired,
// Injected
frameSync: propTypes.object,
loadAnnotationsService: propTypes.object,
rootThread: propTypes.object,
streamer: propTypes.object,
};
SidebarContent.injectedProps = [
'frameSync',
'loadAnnotationsService',
'rootThread',
'streamer',
];
export default withServices(SidebarContent);
import angular from 'angular';
import EventEmitter from 'tiny-emitter';
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;
import { mount } from 'enzyme';
import { createElement } from 'preact';
before(function () {
angular
.module('h', [])
.service('store', storeFactory)
.component('sidebarContent', sidebarContent);
});
import SidebarContent from '../sidebar-content';
import { $imports } from '../sidebar-content';
beforeEach(angular.mock.module('h'));
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);
});
});
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
function setFrames(frames) {
frames.forEach(function (frame) {
store.connectFrame(frame);
});
}
const makeSidebarContentController = () => {
angular.mock.inject(function ($componentController, _store_, _$rootScope_) {
$rootScope = _$rootScope_;
$scope = $rootScope.$new();
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' },
}
);
});
};
describe('SidebarContent', () => {
let fakeFrameSync;
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(() => {
makeSidebarContentController();
});
afterEach(function () {
return sandbox.restore();
});
describe('isLoading', () => {
it("returns true if the document's url isn't known", () => {
assert.isTrue(ctrl.isLoading());
});
it('returns true if annotations are still being fetched', () => {
setFrames([{ uri: 'http://www.example.com' }]);
store.annotationFetchStarted('tag:foo');
assert.isTrue(ctrl.isLoading());
fakeFrameSync = {
focusAnnotations: sinon.stub(),
scrollToAnnotation: sinon.stub(),
};
fakeLoadAnnotationsService = {
load: sinon.stub(),
};
fakeRootThreadService = {
thread: sinon.stub().returns({}),
};
fakeStreamer = {
connect: sinon.stub(),
};
fakeStore = {
// actions
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', () => {
setFrames([{ uri: 'http://www.example.com' }]);
assert.isFalse(ctrl.isLoading());
});
afterEach(() => {
$imports.$restore();
});
describe('showSelectedTabs', () => {
describe('loading annotations', () => {
let wrapper;
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
});
it('returns false if there is a search query', () => {
store.setFilterQuery('tag:foo');
assert.isFalse(ctrl.showSelectedTabs());
fakeStore.focusedGroupId.returns('47');
fakeStore.searchUris.returns(['foobar']);
fakeStore.profile.returns({ userid: 'somebody' });
wrapper = createComponent();
fakeLoadAnnotationsService.load.resetHistory();
});
it('returns false if selected group is unavailable', () => {
fakeSettings.group = 'group-id';
store.setDirectLinkedGroupFetchFailed();
$scope.$digest();
assert.isFalse(ctrl.showSelectedTabs());
it('loads annotations when userId changes', () => {
fakeStore.profile.returns({ userid: 'somethingElse' });
wrapper.setProps({});
assert.calledOnce(fakeLoadAnnotationsService.load);
assert.notCalled(fakeStore.clearSelectedAnnotations);
});
it('returns false if selected annotation is unavailable', () => {
store.selectAnnotations(['missing']);
$scope.$digest();
assert.isFalse(ctrl.showSelectedTabs());
it('clears selected annotations and loads annotations when groupId changes', () => {
fakeStore.focusedGroupId.returns('affable');
wrapper.setProps({});
assert.calledOnce(fakeLoadAnnotationsService.load);
assert.calledOnce(fakeStore.clearSelectedAnnotations);
});
it('returns true in all other cases', () => {
assert.isTrue(ctrl.showSelectedTabs());
it('loads annotations when searchURIs change', () => {
fakeStore.searchUris.returns(['abandon-ship']);
wrapper.setProps({});
assert.calledOnce(fakeLoadAnnotationsService.load);
assert.notCalled(fakeStore.clearSelectedAnnotations);
});
});
describe('showFocusedHeader', () => {
it('returns true if focus mode is enabled', () => {
store.focusModeEnabled = sinon.stub().returns(true);
assert.isTrue(ctrl.showFocusedHeader());
});
it('returns false if focus mode is not enabled', () => {
store.focusModeEnabled = sinon.stub().returns(false);
assert.isFalse(ctrl.showFocusedHeader());
});
});
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);
context('when viewing a direct-linked annotation', () => {
context('successful direct-linked annotation', () => {
beforeEach(() => {
fakeStore.hasFetchedAnnotations.returns(true);
fakeStore.isFetchingAnnotations.returns(false);
fakeStore.annotationExists.withArgs('someId').returns(true);
fakeStore.directLinkedAnnotationId.returns('someId');
fakeStore.findAnnotationByID
.withArgs('someId')
.returns({ $orphan: false, $tag: 'myTag' });
});
it('reloads annotations', () => {
setFrames([{ uri: 'https://new-frame.com' }]);
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'])
);
});
$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(
fakeLoadAnnotationsService.load,
['https://a-page.com', 'https://new-frame.com'],
'group-id'
);
it('renders a logged-out message CTA if user is not logged in', () => {
fakeStore.isLoggedIn.returns(false);
const wrapper = createComponent();
assert.isTrue(wrapper.find('LoggedOutMessage').exists());
});
});
});
context('when the profile changes', () => {
beforeEach(connectFrameAndPerformInitialFetch);
context('error on direct-linked annotation', () => {
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', () => {
const newProfile = Object.assign({}, store.profile(), {
userid: 'different-user@hypothes.is',
// This puts us into an error state
fakeStore.findAnnotationByID.withArgs('someId').returns(undefined);
fakeStore.annotationExists.withArgs('someId').returns(false);
});
store.updateProfile(newProfile);
$scope.$digest();
it('renders a content error', () => {
const wrapper = createComponent();
assert.calledWith(
fakeLoadAnnotationsService.load,
['https://a-page.com'],
'group-id'
);
});
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',
},
assert.isTrue(
wrapper
.find('SidebarContentError')
.filter({ errorType: 'annotation' })
.exists()
);
});
store.updateProfile(newProfile);
$scope.$digest();
it('does not render tabs', () => {
const wrapper = createComponent();
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');
assert.isFalse(wrapper.find('SelectionTabs').exists());
});
});
});
describe('when the focused group changes', () => {
const uri = 'http://example.com';
context('error with direct-linked group', () => {
beforeEach(() => {
// Setup an initial state with frames connected, a group focused and some
// annotations loaded.
store.addAnnotations([{ id: '123' }]);
store.addAnnotations = sinon.stub();
setFrames([{ uri: uri }]);
$scope.$digest();
fakeLoadAnnotationsService.load = sinon.stub();
fakeStore.hasFetchedAnnotations.returns(true);
fakeStore.isFetchingAnnotations.returns(false);
fakeStore.directLinkedGroupFetchFailed.returns(true);
});
it('should load annotations for the new group', () => {
store.loadGroups([{ id: 'different-group' }]);
store.focusGroup('different-group');
it('renders a content error', () => {
const wrapper = createComponent();
$scope.$digest();
assert.calledWith(
fakeLoadAnnotationsService.load,
['http://example.com'],
'different-group'
assert.isTrue(
wrapper
.find('SidebarContentError')
.filter({ errorType: 'group' })
.exists()
);
});
it('should clear the selection', () => {
store.selectAnnotations(['123']);
store.loadGroups([{ id: 'different-group' }]);
store.focusGroup('different-group');
$scope.$digest();
it('does not render tabs', () => {
const wrapper = createComponent();
assert.isFalse(store.hasSelectedAnnotations());
assert.isFalse(wrapper.find('SelectionTabs').exists());
});
});
describe('direct linking messages', function () {
/**
* Connect a frame, indicating that the document has finished initial
* loading.
*
* In the case of an HTML document, this usually happens immediately. For
* PDFs, this happens once the entire PDF has been downloaded and the
* 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());
describe('streamer', () => {
it('connects to streamer when sidebar is opened', () => {
const wrapper = createComponent();
fakeStreamer.connect.resetHistory();
fakeStore.hasSidebarOpened.returns(true);
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('connects to streamer when user logs in', () => {
const wrapper = createComponent();
fakeStreamer.connect.resetHistory();
fakeStore.isLoggedIn.returns(true);
wrapper.setProps({});
assert.calledOnce(fakeStreamer.connect);
});
});
it('does not a show a message if there is no selection', function () {
addFrame();
store.selectAnnotations([]);
$scope.$digest();
assert.isFalse(ctrl.selectedAnnotationUnavailable());
});
it('renders a focused header if in focused mode', () => {
fakeStore.focusModeEnabled.returns(true);
const wrapper = createComponent();
it("doesn't show a message if the document isn't loaded yet", function () {
// There is a selection but the selected annotation isn't available.
store.selectAnnotations(['missing']);
store.annotationFetchStarted();
$scope.$digest();
assert.isTrue(wrapper.find('FocusedModeHeader').exists());
});
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 () {
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isTrue(ctrl.shouldShowLoggedOutMessage());
});
const wrapper = createComponent();
it('does not show loggedout message if selection is unavailable', function () {
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.selectAnnotations(['missing']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
assert.isTrue(wrapper.find('SearchStatusBar').exists());
});
it('does not show loggedout message if there is no selection', function () {
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.selectAnnotations([]);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
it('does not render search status if annotations are loading', () => {
fakeStore.hasFetchedAnnotations.returns(false);
it('does not show loggedout message if user is not logged out', function () {
addFrame();
ctrl.auth = {
status: 'logged-in',
};
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
const wrapper = createComponent();
it('does not show loggedout message if not a direct link', function () {
addFrame();
ctrl.auth = {
status: 'logged-out',
};
store.setDirectLinkedAnnotationId(null);
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
});
assert.isFalse(wrapper.find('SearchStatusBar').exists());
});
it('does not show loggedout message if using third-party accounts', function () {
fakeSettings.services = [{ authority: 'publisher.com' }];
addFrame();
ctrl.auth = { status: 'logged-out' };
store.addAnnotations([{ id: '123' }]);
store.selectAnnotations(['123']);
$scope.$digest();
describe('selection tabs', () => {
it('renders tabs', () => {
const wrapper = createComponent();
assert.isFalse(ctrl.shouldShowLoggedOutMessage());
assert.isTrue(wrapper.find('SelectionTabs').exists());
});
});
describe('deferred websocket connection', function () {
it('should connect the websocket the first time the sidebar opens', function () {
$rootScope.$broadcast('sidebarOpened');
assert.called(fakeStreamer.connect);
});
it('does not render tabs if there is an applied filter', () => {
fakeStore.hasAppliedFilter.returns(true);
describe('when logged in user changes', function () {
it('should not reconnect if the sidebar is closed', function () {
$rootScope.$broadcast(events.USER_CHANGED);
assert.calledOnce(fakeStreamer.reconnect);
});
const wrapper = createComponent();
it('should reconnect if the sidebar is open', function () {
$rootScope.$broadcast('sidebarOpened');
fakeStreamer.connect.reset();
$rootScope.$broadcast(events.USER_CHANGED);
assert.called(fakeStreamer.reconnect);
});
assert.isFalse(wrapper.find('SelectionTabs').exists());
});
});
it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
})
);
});
......@@ -19,7 +19,4 @@ export default {
/** A new annotation has been created locally. */
BEFORE_ANNOTATION_CREATED: 'beforeAnnotationCreated',
/** Annotations were anchored in a connected document. */
ANNOTATIONS_SYNCED: 'sync',
};
......@@ -108,14 +108,10 @@ registerIcons(iconSet);
// Preact UI components that are wrapped for use within Angular templates.
import AnnotationViewerContent from './components/annotation-viewer-content';
import FocusedModeHeader from './components/focused-mode-header';
import HelpPanel from './components/help-panel';
import LoggedOutMessage from './components/logged-out-message';
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 SidebarContentError from './components/sidebar-content-error';
import SidebarContent from './components/sidebar-content';
import StreamContent from './components/stream-content';
import ThreadList from './components/thread-list';
import ToastMessages from './components/toast-messages';
......@@ -124,7 +120,6 @@ import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular.
import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content';
// Services.
......@@ -243,12 +238,7 @@ function startAngularApp(config) {
)
.component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel))
.component('loggedOutMessage', wrapComponent(LoggedOutMessage))
.component('searchStatusBar', wrapComponent(SearchStatusBar))
.component('focusedModeHeader', wrapComponent(FocusedModeHeader))
.component('selectionTabs', wrapComponent(SelectionTabs))
.component('sidebarContent', sidebarContent)
.component('sidebarContentError', wrapComponent(SidebarContentError))
.component('sidebarContent', wrapComponent(SidebarContent))
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', wrapComponent(StreamContent))
.component('threadList', wrapComponent(ThreadList))
......
......@@ -144,10 +144,6 @@ export default function FrameSync($rootScope, $window, store, bridge) {
let anchoringStatusUpdates = {};
const scheduleAnchoringStatusUpdate = debounce(() => {
store.updateAnchorStatus(anchoringStatusUpdates);
$rootScope.$broadcast(
events.ANNOTATIONS_SYNCED,
Object.keys(anchoringStatusUpdates)
);
anchoringStatusUpdates = {};
}, 10);
......@@ -176,7 +172,7 @@ export default function FrameSync($rootScope, $window, store, bridge) {
});
bridge.on('sidebarOpened', function () {
$rootScope.$broadcast('sidebarOpened');
store.setSidebarOpened(true);
});
// These invoke the matching methods by name on the Guests
......
......@@ -3,6 +3,7 @@ import * as queryString from 'query-string';
import warnOnce from '../../shared/warn-once';
import { generateHexString } from '../util/random';
import Socket from '../websocket';
import { watch } from '../util/watch';
/**
* Open a new WebSocket connection to the Hypothesis push notification service.
......@@ -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.
*
......@@ -169,10 +190,10 @@ export default function Streamer(store, auth, groups, session, settings) {
* process has started.
*/
function connect() {
setUpAutoReconnect();
if (socket) {
return Promise.resolve();
}
return _connect();
}
......
......@@ -66,6 +66,7 @@ describe('sidebar/services/frame-sync', function () {
openSidebarPanel: sinon.stub(),
selectAnnotations: sinon.stub(),
selectTab: sinon.stub(),
setSidebarOpened: sinon.stub(),
toggleSelectedAnnotations: sinon.stub(),
updateAnchorStatus: sinon.stub(),
}
......@@ -305,14 +306,6 @@ describe('sidebar/services/frame-sync', function () {
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 () {
......@@ -376,9 +369,10 @@ describe('sidebar/services/frame-sync', function () {
});
describe('on "sidebarOpened" message', function () {
it('broadcasts a sidebarOpened event', function () {
it('sets the sidebar open in the store', function () {
fakeBridge.emit('sidebarOpened');
assert.calledWith($rootScope.$broadcast, 'sidebarOpened');
assert.calledWith(fakeStore.setSidebarOpened, true);
});
});
......
import EventEmitter from 'tiny-emitter';
import fakeReduxStore from '../../test/fake-redux-store';
import Streamer from '../streamer';
import { $imports } from '../streamer';
......@@ -43,12 +44,14 @@ const fixtures = {
// the most recently created FakeSocket instance
let fakeWebSocket = null;
let fakeWebSockets = [];
class FakeSocket extends EventEmitter {
constructor(url) {
super();
fakeWebSocket = this; // eslint-disable-line consistent-this
fakeWebSockets.push(this);
this.url = url;
this.messages = [];
......@@ -95,19 +98,22 @@ describe('Streamer', function () {
},
};
fakeStore = {
addAnnotations: sinon.stub(),
annotationExists: sinon.stub().returns(false),
clearPendingUpdates: sinon.stub(),
pendingUpdates: sinon.stub().returns({}),
pendingDeletions: sinon.stub().returns({}),
profile: sinon.stub().returns({
userid: 'jim@hypothes.is',
}),
receiveRealTimeUpdates: sinon.stub(),
removeAnnotations: sinon.stub(),
route: sinon.stub().returns('sidebar'),
};
fakeStore = fakeReduxStore(
{},
{
addAnnotations: sinon.stub(),
annotationExists: sinon.stub().returns(false),
clearPendingUpdates: sinon.stub(),
pendingUpdates: sinon.stub().returns({}),
pendingDeletions: sinon.stub().returns({}),
profile: sinon.stub().returns({
userid: 'jim@hypothes.is',
}),
receiveRealTimeUpdates: sinon.stub(),
removeAnnotations: sinon.stub(),
route: sinon.stub().returns('sidebar'),
}
);
fakeGroups = {
focused: sinon.stub().returns({ id: 'public' }),
......@@ -130,6 +136,7 @@ describe('Streamer', function () {
afterEach(function () {
$imports.$restore();
activeStreamer = null;
fakeWebSockets = [];
});
it('should not create a websocket connection if websocketUrl is not provided', 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 () {
beforeEach(function () {
createDefaultStreamer();
......
......@@ -19,6 +19,10 @@ function init() {
* The number of annotation fetches that have started and not yet completed.
*/
activeAnnotationFetches: 0,
/**
* Have annotations ever been fetched?
*/
hasFetchedAnnotations: false,
};
}
......@@ -86,6 +90,7 @@ const update = {
return {
...state,
hasFetchedAnnotations: true,
activeAnnotationFetches: state.activeAnnotationFetches - 1,
};
},
......@@ -127,6 +132,10 @@ function apiRequestFinished() {
/** Selectors */
function hasFetchedAnnotations(state) {
return state.activity.hasFetchedAnnotations;
}
/**
* Return true when annotations are actively being fetched.
*/
......@@ -173,6 +182,7 @@ export default {
},
selectors: {
hasFetchedAnnotations,
isLoading,
isFetchingAnnotations,
isSavingAnnotation,
......
......@@ -136,6 +136,10 @@ function directLinkedGroupId(state) {
return state.directLinked.directLinkedGroupId;
}
function directLinkedGroupFetchFailed(state) {
return state.directLinked.directLinkedGroupFetchFailed;
}
export default {
init,
namespace: 'directLinked',
......@@ -149,6 +153,7 @@ export default {
},
selectors: {
directLinkedAnnotationId,
directLinkedGroupFetchFailed,
directLinkedGroupId,
},
};
import { createSelector } from 'reselect';
import {
createSelector,
createSelectorCreator,
defaultMemoize,
} from 'reselect';
import shallowEqual from 'shallowequal';
import * as util from '../util';
......@@ -103,15 +108,23 @@ function searchUrisForFrame(frame) {
return uris;
}
/**
* Return the set of URIs that should be used to search for annotations on the
* current page.
*/
function searchUris(state) {
return state.frames.reduce(function (uris, frame) {
return uris.concat(searchUrisForFrame(frame));
}, []);
}
// "selector creator" that uses `shallowEqual` instead of `===` for memoization
const createShallowEqualSelector = createSelectorCreator(
defaultMemoize,
shallowEqual
);
// 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 {
init: init,
......
......@@ -519,6 +519,21 @@ function getSelectedAnnotationMap(state) {
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 {
init: init,
namespace: 'selection',
......@@ -541,7 +556,6 @@ export default {
},
selectors: {
hasSelectedAnnotations,
expandedThreads,
filterQuery,
focusModeFocused,
......@@ -553,5 +567,7 @@ export default {
isAnnotationSelected,
getFirstSelectedAnnotationId,
getSelectedAnnotationMap,
hasAppliedFilter,
hasSelectedAnnotations,
},
};
......@@ -8,6 +8,23 @@ describe('sidebar/store/modules/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', () => {
it('returns false with the initial state', () => {
assert.equal(store.isLoading(), false);
......
......@@ -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', () => {
it('should return the current direct-linked group ID', () => {
store.setDirectLinkedGroupId('group-id');
......
......@@ -157,7 +157,12 @@ describe('sidebar/store/modules/frames', function () {
testCase.frames.forEach(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', () => {
});
});
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 () {
it('returns true if there are any selected annotations', function () {
store.selectAnnotations([1]);
......
......@@ -19,4 +19,23 @@ describe('store/modules/viewer', function () {
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';
function init() {
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,
};
}
......@@ -15,10 +18,20 @@ const update = {
SET_HIGHLIGHTS_VISIBLE: function (state, action) {
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);
// Action creators
/**
* Sets whether annotation highlights in connected documents are shown
* or not.
......@@ -27,12 +40,28 @@ function setShowHighlights(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 {
init: init,
namespace: 'viewer',
update: update,
actions: {
setShowHighlights: setShowHighlights,
setShowHighlights,
setSidebarOpened,
},
selectors: {
hasSidebarOpened,
},
selectors: {},
};
......@@ -15,11 +15,7 @@
<main ng-if="vm.route()">
<annotation-viewer-content ng-if="vm.route() == 'annotation'"></annotation-viewer-content>
<stream-content ng-if="vm.route() == 'stream'"></stream-content>
<sidebar-content
ng-if="vm.route() == 'sidebar'"
auth="vm.auth"
on-login="vm.login()"
on-sign-up="vm.signUp()"></sidebar-content>
<sidebar-content ng-if="vm.route() == 'sidebar'" on-login="vm.login()" on-signUp="vm.signUp()"></sidebar-content>
</main>
</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