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
This diff is collapsed.
...@@ -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