Commit b52f00e0 authored by Robert Knight's avatar Robert Knight

Make Sidebar collaborate with rather than inherit from Guest

Change the relationship between the `Guest` and `Sidebar` classes from
one of inheritance to one where `Sidebar` receives a reference to the
`Guest` instance and calls methods on it or subscribes to events from
it. The `Guest` also no longer "owns" references to the bucket bar /
toolbar objects that logically belong to the sidebar. Instead it emits
events which the sidebar responds to.

This change makes the interface of the `Guest` used by the `Sidebar`
more explicit and ensures a better boundary between the two. This also
makes it easier for the `Sidebar` tests to be concerned only with the
interface of the `Guest` and not its implementation details. In future
this change will also make it possible to have a frame which does not
contain a sidebar but is not annotateable. We had a need for this
historically when integrating with epub viewers, although it was never
implemented.

Updating the tests for `PdfSidebar` was complicated by the fact that the
`PdfSidebar` tests are not pure unit tests. They instantiate the
`Sidebar` base class and so depend on many implementation details of it.
To make this change and others easier, the `PdfSidebar` tests have been
changed to mock the `Sidebar` base class. The steps involved in this are
non-obvious so I extracted the logic into a utility function.

 - Change `Sidebar` to no longer inherit `Guest` but accept it as a
   constructor argument
 - Remove direct references to the `BucketBar` and `ToolbarController`
   instances from the `Guest` class and instead emit events from the
   `Guest` which the `Sidebar` responds to.
 - Add a `mockBaseClass` testing helper in `src/test-util/mock-base.js`
   and change the `PdfSidebar` tests to use it, so that they are less
   coupled to `Sidebar` implementation details.
parent 7b95403c
......@@ -21,7 +21,6 @@ import { normalizeURI } from './util/url';
* @typedef {import('../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../types/annotator').Anchor} Anchor
* @typedef {import('../types/api').Target} Target
* @typedef {import('./toolbar').ToolbarController} ToolbarController
*/
/**
......@@ -120,9 +119,6 @@ export default class Guest extends Delegator {
this.visibleHighlights = false;
/** @type {ToolbarController|null} */
this.toolbar = null;
this.adderToolbar = document.createElement('hypothesis-adder');
this.adderToolbar.style.display = 'none';
this.element.appendChild(this.adderToolbar);
......@@ -152,9 +148,6 @@ export default class Guest extends Delegator {
this.plugins = {};
/** @typedef {import('./bucket-bar').default|null} */
this.bucketBar = null;
/** @type {Anchor[]} */
this.anchors = [];
......@@ -483,10 +476,9 @@ export default class Guest extends Delegator {
annotation.$orphan = hasAnchorableTargets && !hasAnchoredTargets;
// Add the anchors for this annotation to instance storage.
this.anchors = this.anchors.concat(anchors);
this._updateAnchors(this.anchors.concat(anchors));
// Let plugins know about the new information.
this.bucketBar?.update();
this.plugins.CrossFrame?.sync([annotation]);
return anchors;
......@@ -534,7 +526,6 @@ export default class Guest extends Delegator {
detach(annotation) {
const anchors = [];
let unhighlight = [];
for (let anchor of this.anchors) {
if (anchor.annotation === annotation) {
unhighlight.push(...(anchor.highlights ?? []));
......@@ -542,11 +533,14 @@ export default class Guest extends Delegator {
anchors.push(anchor);
}
}
removeHighlights(unhighlight);
this.anchors = anchors;
this._updateAnchors(anchors);
}
removeHighlights(unhighlight);
this.bucketBar?.update();
_updateAnchors(anchors) {
this.anchors = anchors;
this.publish('anchorsChanged', [this.anchors]);
}
/**
......@@ -658,9 +652,7 @@ export default class Guest extends Delegator {
}
this.selectedRanges = [range];
if (this.toolbar) {
this.toolbar.newAnnotationType = 'annotation';
}
this.publish('hasSelectionChanged', [true]);
this.adderCtrl.annotationsForSelection = annotationsForSelection();
this.adderCtrl.show(focusRect, isBackwards);
......@@ -669,9 +661,7 @@ export default class Guest extends Delegator {
_onClearSelection() {
this.adderCtrl.hide();
this.selectedRanges = [];
if (this.toolbar) {
this.toolbar.newAnnotationType = 'note';
}
this.publish('hasSelectionChanged', [false]);
}
/**
......@@ -707,10 +697,7 @@ export default class Guest extends Delegator {
*/
setVisibleHighlights(shouldShowHighlights) {
setHighlightsVisible(this.element, shouldShowHighlights);
this.visibleHighlights = shouldShowHighlights;
if (this.toolbar) {
this.toolbar.highlightsVisible = shouldShowHighlights;
}
this.publish('highlightsVisibleChanged', [shouldShowHighlights]);
}
}
......@@ -48,15 +48,15 @@ const config = configFrom(window);
function init() {
const isPDF = typeof window_.PDFViewerApplication !== 'undefined';
/** @type {new (e: HTMLElement, config: any) => Guest} */
let Klass = isPDF ? PdfSidebar : Sidebar;
/** @type {(new (e: HTMLElement, config: any, guest: Guest) => Sidebar)|null} */
let SidebarClass = isPDF ? PdfSidebar : Sidebar;
if (config.subFrameIdentifier) {
// Make sure the PDF plugin is loaded if the subframe contains the PDF.js viewer.
if (isPDF) {
config.PDF = {};
}
Klass = Guest;
SidebarClass = null;
// Other modules use this to detect if this
// frame context belongs to hypothesis.
......@@ -66,11 +66,16 @@ function init() {
config.pluginClasses = pluginClasses;
const annotator = new Klass(document.body, config);
const guest = new Guest(document.body, config);
const sidebar = SidebarClass
? new SidebarClass(document.body, config, guest)
: null;
const notebook = new Notebook(document.body, config);
appLinkEl.addEventListener('destroy', function () {
annotator.destroy();
appLinkEl.addEventListener('destroy', () => {
sidebar?.destroy();
notebook.destroy();
guest.destroy();
// Remove all the `<link>`, `<script>` and `<style>` elements added to the
// page by the boot script.
......
import Sidebar from './sidebar';
/**
* @typedef {import('./guest').default} Guest
* @typedef {import('../types/annotator').HypothesisWindow} HypothesisWindow
* @typedef {import('./sidebar').LayoutState} LayoutState
*/
......@@ -21,9 +22,10 @@ export default class PdfSidebar extends Sidebar {
/**
* @param {HTMLElement} element
* @param {Record<string, any>} config
* @param {Guest} guest
*/
constructor(element, config) {
super(element, { ...defaultConfig, ...config });
constructor(element, config, guest) {
super(element, { ...defaultConfig, ...config }, guest);
this._lastSidebarLayoutState = {
expanded: false,
......
......@@ -6,12 +6,14 @@ import { createSidebarConfig } from './config/sidebar';
import events from '../shared/bridge-events';
import features from './features';
import Guest from './guest';
import Delegator from './delegator';
import { ToolbarController } from './toolbar';
import { createShadowRoot } from './util/shadow-root';
import BucketBar from './bucket-bar';
/**
* @typedef {import('./guest').default} Guest
*
* @typedef LayoutState
* @prop {boolean} expanded
* @prop {number} width
......@@ -55,16 +57,25 @@ function createSidebarIframe(config) {
* The `Sidebar` class creates the sidebar application iframe and its container,
* as well as the adjacent controls.
*/
export default class Sidebar extends Guest {
export default class Sidebar extends Delegator {
/**
* Create the sidebar iframe, its container and adjacent controls.
*
* @param {HTMLElement} element
* @param {Record<string, any>} config
* @param {Guest} guest -
* The `Guest` instance for the current frame. It is currently assumed that
* it is always possible to annotate in the frame where the sidebar is
* displayed.
*/
constructor(element, config) {
constructor(element, config, guest) {
super(element, config);
this.iframe = createSidebarIframe(config);
/** @type {BucketBar|null} */
this.bucketBar = null;
if (config.externalContainerSelector) {
this.externalFrame =
/** @type {HTMLElement} */
......@@ -78,11 +89,13 @@ export default class Sidebar extends Guest {
if (config.theme === 'clean') {
this.iframeContainer.classList.add('annotator-frame--theme-clean');
} else {
this.bucketBar = new BucketBar(
const bucketBar = new BucketBar(
this.iframeContainer,
this,
guest,
config.BucketBar
);
guest.subscribe('anchorsChanged', () => bucketBar.update());
this.bucketBar = bucketBar;
}
this.iframeContainer.appendChild(this.iframe);
......@@ -95,6 +108,8 @@ export default class Sidebar extends Guest {
element.appendChild(this.hypothesisSidebar);
}
this.guest = guest;
/** @type {RegisteredListener[]} */
this.registeredListeners = [];
......@@ -126,7 +141,7 @@ export default class Sidebar extends Guest {
// Set up the toolbar on the left edge of the sidebar.
const toolbarContainer = document.createElement('div');
this.toolbar = new ToolbarController(toolbarContainer, {
createAnnotation: () => this.createAnnotation(),
createAnnotation: () => guest.createAnnotation(),
setSidebarOpen: open => (open ? this.show() : this.hide()),
setHighlightsVisible: show => this.setAllVisibleHighlights(show),
});
......@@ -137,6 +152,13 @@ export default class Sidebar extends Guest {
this.toolbar.useMinimalControls = false;
}
this.subscribe('highlightsVisibleChanged', visible => {
this.toolbar.highlightsVisible = visible;
});
this.subscribe('hasSelectionChanged', hasSelection => {
this.toolbar.newAnnotationType = hasSelection ? 'annotation' : 'note';
});
if (this.iframeContainer) {
// If using our own container frame for the sidebar, add the toolbar to it.
this.iframeContainer.prepend(toolbarContainer);
......@@ -177,6 +199,7 @@ export default class Sidebar extends Guest {
}
destroy() {
this.bucketBar?.destroy();
this._unregisterEvents();
this._hammerManager?.destroy();
if (this.hypothesisSidebar) {
......@@ -205,20 +228,20 @@ export default class Sidebar extends Guest {
}
_setupSidebarEvents() {
annotationCounts(document.body, this.crossframe);
annotationCounts(document.body, this.guest.crossframe);
sidebarTrigger(document.body, () => this.show());
features.init(this.crossframe);
features.init(this.guest.crossframe);
this.crossframe.on('showSidebar', () => this.show());
this.crossframe.on('hideSidebar', () => this.hide());
this.guest.crossframe.on('showSidebar', () => this.show());
this.guest.crossframe.on('hideSidebar', () => this.hide());
// Re-publish the crossframe event so that anything extending Delegator
// can subscribe to it (without need for crossframe)
this.crossframe.on('showNotebook', groupId => {
this.guest.crossframe.on('showNotebook', groupId => {
this.hide();
this.publish('showNotebook', [groupId]);
});
this.crossframe.on('hideNotebook', () => {
this.guest.crossframe.on('hideNotebook', () => {
this.show();
this.publish('hideNotebook');
});
......@@ -232,7 +255,7 @@ export default class Sidebar extends Guest {
];
eventHandlers.forEach(([event, handler]) => {
if (handler) {
this.crossframe.on(event, () => handler());
this.guest.crossframe.on(event, () => handler());
}
});
}
......@@ -408,7 +431,7 @@ export default class Sidebar extends Guest {
}
show() {
this.crossframe.call('sidebarOpened');
this.guest.crossframe.call('sidebarOpened');
this.publish('sidebarOpened');
if (this.iframeContainer) {
......@@ -420,7 +443,7 @@ export default class Sidebar extends Guest {
this.toolbar.sidebarOpen = true;
if (this.options.showHighlights === 'whenSidebarOpen') {
this.setVisibleHighlights(true);
this.guest.setVisibleHighlights(true);
}
this._notifyOfLayoutChange(true);
......@@ -435,7 +458,7 @@ export default class Sidebar extends Guest {
this.toolbar.sidebarOpen = false;
if (this.options.showHighlights === 'whenSidebarOpen') {
this.setVisibleHighlights(false);
this.guest.setVisibleHighlights(false);
}
this._notifyOfLayoutChange(false);
......@@ -447,6 +470,6 @@ export default class Sidebar extends Guest {
* @param {boolean} shouldShowHighlights
*/
setAllVisibleHighlights(shouldShowHighlights) {
this.crossframe.call('setVisibleHighlights', shouldShowHighlights);
this.guest.crossframe.call('setVisibleHighlights', shouldShowHighlights);
}
}
......@@ -536,22 +536,24 @@ describe('Guest', () => {
assert.called(FakeAdder.instance.hide);
});
it("sets the toolbar's `newAnnotationType` to 'annotation' if there is a selection", () => {
it('emits `hasSelectionChanged` event with argument `true` if selection is non-empty', () => {
const guest = createGuest();
guest.toolbar = {};
const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
simulateSelectionWithText();
assert.equal(guest.toolbar.newAnnotationType, 'annotation');
assert.calledWith(callback, true);
});
it("sets the toolbar's `newAnnotationType` to 'note' if the selection is empty", () => {
it('emits `hasSelectionChanged` event with argument `false` if selection is empty', () => {
const guest = createGuest();
guest.toolbar = {};
const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
simulateSelectionWithoutText();
assert.equal(guest.toolbar.newAnnotationType, 'note');
assert.calledWith(callback, false);
});
});
......@@ -767,17 +769,26 @@ describe('Guest', () => {
.then(() => assert.notCalled(htmlAnchoring.anchor));
});
it('updates the cross frame and bucket bar plugins', () => {
it('updates the cross frame plugin', () => {
const guest = createGuest();
guest.plugins.CrossFrame = { sync: sinon.stub() };
guest.bucketBar = { update: sinon.stub() };
const annotation = {};
return guest.anchor(annotation).then(() => {
assert.called(guest.bucketBar.update);
assert.called(guest.plugins.CrossFrame.sync);
});
});
it('emits an `anchorsChanged` event', async () => {
const guest = createGuest();
const annotation = {};
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
await guest.anchor(annotation);
assert.calledWith(anchorsChanged, guest.anchors);
});
it('returns a promise of the anchors for the annotation', () => {
const guest = createGuest();
const highlights = [document.createElement('span')];
......@@ -850,15 +861,16 @@ describe('Guest', () => {
assert.equal(guest.anchors.length, 0);
});
it('updates the bucket bar plugin', () => {
it('emits an `anchorsChanged` event', () => {
const guest = createGuest();
guest.bucketBar = { update: sinon.stub() };
const annotation = {};
guest.anchors.push({ annotation });
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
guest.detach(annotation);
assert.calledOnce(guest.bucketBar.update);
assert.calledWith(anchorsChanged, guest.anchors);
});
it('removes any highlights associated with the annotation', () => {
......
import PdfSidebar from '../pdf-sidebar';
import { $imports } from '../pdf-sidebar';
import Delegator from '../delegator';
import { mockBaseClass } from '../../test-util/mock-base';
class FakeSidebar extends Delegator {
constructor(element, config, guest) {
super(element, config);
this.guest = guest;
}
_registerEvent(target, event, callback) {
target.addEventListener(event, callback);
}
}
describe('PdfSidebar', () => {
const sandbox = sinon.createSandbox();
let CrossFrame;
let fakeCrossFrame;
let fakePDFViewerApplication;
let fakePDFContainer;
let fakePDFViewerUpdate;
const sidebarConfig = { pluginClasses: {} };
const createPdfSidebar = config => {
config = { ...sidebarConfig, ...config };
const fakeGuest = {};
const element = document.createElement('div');
return new PdfSidebar(element, config);
return new PdfSidebar(element, config, fakeGuest);
};
let unmockSidebar;
beforeEach(() => {
sandbox.stub(PdfSidebar.prototype, '_setupGestures');
fakePDFContainer = document.createElement('div');
fakePDFViewerUpdate = sinon.stub();
......@@ -36,31 +46,13 @@ describe('PdfSidebar', () => {
// Can't stub an undefined property in a sandbox
window.PDFViewerApplication = fakePDFViewerApplication;
// From `Sidebar.js` tests
fakeCrossFrame = {};
fakeCrossFrame.onConnect = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.on = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.call = sandbox.spy();
fakeCrossFrame.destroy = sandbox.stub();
const fakeBucketBar = {};
fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();
CrossFrame.returns(fakeCrossFrame);
const BucketBar = sandbox.stub();
BucketBar.returns(fakeBucketBar);
sidebarConfig.pluginClasses.CrossFrame = CrossFrame;
sidebarConfig.pluginClasses.BucketBar = BucketBar;
unmockSidebar = mockBaseClass(PdfSidebar, FakeSidebar);
});
afterEach(() => {
delete window.PDFViewerApplication;
sandbox.restore();
$imports.$restore();
unmockSidebar();
});
context('side-by-side mode configured', () => {
......
import events from '../../shared/bridge-events';
import Delegator from '../delegator';
import Sidebar, { MIN_RESIZE } from '../sidebar';
import { $imports } from '../sidebar';
......@@ -9,15 +10,15 @@ const EXTERNAL_CONTAINER_SELECTOR = 'test-external-container';
describe('Sidebar', () => {
const sandbox = sinon.createSandbox();
let CrossFrame;
let fakeCrossFrame;
const sidebarConfig = { pluginClasses: {} };
let fakeGuest;
// Containers and Sidebar instances created by current test.
let containers;
let sidebars;
let FakeToolbarController;
let fakeBucketBar;
let fakeToolbar;
before(() => {
......@@ -34,14 +35,13 @@ describe('Sidebar', () => {
// Dummy sidebar app.
sidebarAppUrl: '/base/annotator/test/empty.html',
},
sidebarConfig,
config
);
const container = document.createElement('div');
document.body.appendChild(container);
containers.push(container);
const sidebar = new Sidebar(container, config);
const sidebar = new Sidebar(container, config, fakeGuest);
sidebars.push(sidebar);
return sidebar;
......@@ -62,11 +62,22 @@ describe('Sidebar', () => {
beforeEach(() => {
sidebars = [];
containers = [];
fakeCrossFrame = {};
fakeCrossFrame.onConnect = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.on = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.call = sandbox.spy();
fakeCrossFrame.destroy = sandbox.stub();
fakeCrossFrame = {
on: sandbox.stub(),
call: sandbox.stub(),
};
class FakeGuest extends Delegator {
constructor() {
const element = document.createElement('div');
super(element, {});
this.createAnnotation = sinon.stub();
this.crossframe = fakeCrossFrame;
this.setVisibleHighlights = sinon.stub();
}
}
fakeGuest = new FakeGuest();
fakeToolbar = {
getWidth: sinon.stub().returns(100),
......@@ -78,16 +89,11 @@ describe('Sidebar', () => {
};
FakeToolbarController = sinon.stub().returns(fakeToolbar);
const fakeBucketBar = {};
fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
const BucketBar = sandbox.stub();
BucketBar.returns(fakeBucketBar);
CrossFrame = sandbox.stub();
CrossFrame.returns(fakeCrossFrame);
sidebarConfig.pluginClasses.CrossFrame = CrossFrame;
fakeBucketBar = {
destroy: sinon.stub(),
update: sinon.stub(),
};
const BucketBar = sandbox.stub().returns(fakeBucketBar);
sidebars = [];
......@@ -218,11 +224,30 @@ describe('Sidebar', () => {
it('creates an annotation when toolbar button is clicked', () => {
const sidebar = createSidebar();
sinon.stub(sidebar, 'createAnnotation');
FakeToolbarController.args[0][1].createAnnotation();
assert.called(sidebar.createAnnotation);
assert.called(sidebar.guest.createAnnotation);
});
it('sets create annotation button to "Annotation" when selection becomes non-empty', () => {
const sidebar = createSidebar();
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [true]);
assert.equal(sidebar.toolbar.newAnnotationType, 'annotation');
});
it('sets create annotation button to "Page Note" when selection becomes empty', () => {
const sidebar = createSidebar();
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [false]);
assert.equal(sidebar.toolbar.newAnnotationType, 'note');
});
});
......@@ -495,30 +520,35 @@ describe('Sidebar', () => {
});
});
describe('destruction', () => {
it('the sidebar is destroyed and the frame is detached', () => {
describe('#destroy', () => {
it('removes sidebar DOM elements', () => {
const sidebar = createSidebar();
const sidebarContainer = containers[0];
sidebar.destroy();
assert.called(fakeCrossFrame.destroy);
assert.notExists(sidebarContainer.querySelector('hypothesis-sidebar'));
assert.equal(sidebar.iframeContainer.parentElement, null);
});
it('cleans up bucket bar', () => {
const sidebar = createSidebar();
sidebar.destroy();
assert.called(sidebar.bucketBar.destroy);
});
});
describe('#show', () => {
it('shows highlights if "showHighlights" is set to "whenSidebarOpen"', () => {
const sidebar = createSidebar({ showHighlights: 'whenSidebarOpen' });
assert.isFalse(sidebar.visibleHighlights);
sidebar.show();
assert.isTrue(sidebar.visibleHighlights);
assert.calledWith(sidebar.guest.setVisibleHighlights, true);
});
it('does not show highlights otherwise', () => {
const sidebar = createSidebar({ showHighlights: 'never' });
assert.isFalse(sidebar.visibleHighlights);
sidebar.show();
assert.isFalse(sidebar.visibleHighlights);
assert.notCalled(sidebar.guest.setVisibleHighlights);
});
it('updates the `sidebarOpen` property of the toolbar', () => {
......@@ -535,7 +565,7 @@ describe('Sidebar', () => {
sidebar.show();
sidebar.hide();
assert.isFalse(sidebar.visibleHighlights);
assert.calledWith(sidebar.guest.setVisibleHighlights, false);
});
it('updates the `sidebarOpen` property of the toolbar', () => {
......@@ -794,22 +824,30 @@ describe('Sidebar', () => {
});
});
describe('config', () => {
it('does have the BucketBar', () => {
describe('bucket bar state', () => {
it('displays the bucket bar by default', () => {
const sidebar = createSidebar();
assert.isNotNull(sidebar.bucketBar);
});
it('does not have the BucketBar if the clean theme is enabled', () => {
it('does not display the bucket bar if using the "clean" theme', () => {
const sidebar = createSidebar({ theme: 'clean' });
assert.isNull(sidebar.bucketBar);
});
it('does not have the BucketBar if an external container is provided', () => {
it('does not display the bucket bar if using an external container for the sidebar', () => {
const sidebar = createSidebar({
externalContainerSelector: `.${EXTERNAL_CONTAINER_SELECTOR}`,
});
assert.isNull(sidebar.bucketBar);
});
it('updates the bucket bar when an `anchorsChanged` event is received', () => {
const sidebar = createSidebar();
fakeGuest.publish('anchorsChanged');
assert.calledOnce(sidebar.bucketBar.update);
});
});
});
/**
* Mock the base class of a derived class.
*
* In unit tests for a derived class it may be useful to mock the base class
* so that the tests only depend on the interface of the base class and not
* its implementation.
*
* This cannot be done using `$imports.$mock` because the links between the
* derived and base class are set once when the derived class is initially
* evaluated.
*
* Although it is possible to mock a base class using this function, using
* composition over inheritance generally makes mocking easier.
*
* @param {object} derivedClass - The derived class constructor to mock
* @param {object} mockBase - The new mock class
* @return {() => void} A function that un-mocks the base class
*/
export function mockBaseClass(derivedClass, mockBase) {
const originalBase = derivedClass.__proto__;
// Modify the base class reference used by `super` expressions.
derivedClass.__proto__ = mockBase;
// Modify the prototype chain used by instances of the derived class to find
// methods or properties defined on the base class.
derivedClass.prototype.__proto__ = mockBase.prototype;
return () => {
derivedClass.__proto__ = originalBase;
derivedClass.prototype.__proto__ = originalBase.prototype;
};
}
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