Unverified Commit b653e01c authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Merge pull request #1333 from hypothesis/namespace-session-groups-modules

Namespace session and groups modules
parents d9a5c913 2bb2fde3
...@@ -106,7 +106,7 @@ function session( ...@@ -106,7 +106,7 @@ function session(
* @return {Profile} The updated profile data * @return {Profile} The updated profile data
*/ */
function update(model) { function update(model) {
const prevSession = store.getState().session; const prevSession = store.getRootState().session;
const userChanged = model.userid !== prevSession.userid; const userChanged = model.userid !== prevSession.userid;
// Update the session model used by the application // Update the session model used by the application
...@@ -184,7 +184,7 @@ function session( ...@@ -184,7 +184,7 @@ function session(
// this service. In future, other services which access the session state // this service. In future, other services which access the session state
// will do so directly from store or via selector functions // will do so directly from store or via selector functions
get state() { get state() {
return store.getState().session; return store.getRootState().session;
}, },
update, update,
......
...@@ -98,7 +98,7 @@ function Streamer( ...@@ -98,7 +98,7 @@ function Streamer(
} else if (message.type === 'session-change') { } else if (message.type === 'session-change') {
handleSessionChangeNotification(message); handleSessionChangeNotification(message);
} else if (message.type === 'whoyouare') { } else if (message.type === 'whoyouare') {
const userid = store.getState().session.userid; const userid = store.getRootState().session.userid;
if (message.userid !== userid) { if (message.userid !== userid) {
console.warn( console.warn(
'WebSocket user ID "%s" does not match logged-in ID "%s"', 'WebSocket user ID "%s" does not match logged-in ID "%s"',
......
...@@ -57,9 +57,11 @@ describe('groups', function() { ...@@ -57,9 +57,11 @@ describe('groups', function() {
fakeStore = fakeReduxStore( fakeStore = fakeReduxStore(
{ {
mainFrame: { uri: 'http://example.org' }, frames: [{ uri: 'http://example.org' }],
groups: {
focusedGroup: null, focusedGroup: null,
groups: [], groups: [],
},
directLinked: { directLinked: {
directLinkedGroupId: null, directLinkedGroupId: null,
directLinkedAnnotationId: null, directLinkedAnnotationId: null,
...@@ -70,19 +72,16 @@ describe('groups', function() { ...@@ -70,19 +72,16 @@ describe('groups', function() {
getGroup: sinon.stub(), getGroup: sinon.stub(),
loadGroups: sinon.stub(), loadGroups: sinon.stub(),
allGroups() { allGroups() {
return this.getState().groups; return this.getRootState().groups.groups;
},
getInScopeGroups() {
return this.getState().groups;
}, },
focusedGroup() { focusedGroup() {
return this.getState().focusedGroup; return this.getRootState().groups.focusedGroup;
}, },
mainFrame() { mainFrame() {
return this.getState().mainFrame; return this.getRootState().frames[0];
}, },
focusedGroupId() { focusedGroupId() {
const group = this.getState().focusedGroup; const group = this.getRootState().groups.focusedGroup;
return group ? group.id : null; return group ? group.id : null;
}, },
setDirectLinkedGroupFetchFailed: sinon.stub(), setDirectLinkedGroupFetchFailed: sinon.stub(),
...@@ -430,9 +429,9 @@ describe('groups', function() { ...@@ -430,9 +429,9 @@ describe('groups', function() {
it('waits for the document URL to be determined', () => { it('waits for the document URL to be determined', () => {
const svc = service(); const svc = service();
fakeStore.setState({ mainFrame: null }); fakeStore.setState({ frames: [null] });
const loaded = svc.load(); const loaded = svc.load();
fakeStore.setState({ mainFrame: { uri: 'https://asite.com' } }); fakeStore.setState({ frames: [{ uri: 'https://asite.com' }] });
return loaded.then(() => { return loaded.then(() => {
assert.calledWith(fakeApi.groups.list, { assert.calledWith(fakeApi.groups.list, {
...@@ -449,7 +448,7 @@ describe('groups', function() { ...@@ -449,7 +448,7 @@ describe('groups', function() {
}); });
it('does not wait for the document URL', () => { it('does not wait for the document URL', () => {
fakeStore.setState({ mainFrame: null }); fakeStore.setState({ frames: [null] });
const svc = service(); const svc = service();
return svc.load().then(() => { return svc.load().then(() => {
assert.calledWith(fakeApi.groups.list, { assert.calledWith(fakeApi.groups.list, {
...@@ -751,7 +750,9 @@ describe('groups', function() { ...@@ -751,7 +750,9 @@ describe('groups', function() {
describe('#focused', function() { describe('#focused', function() {
it('returns the focused group', function() { it('returns the focused group', function() {
const svc = service(); const svc = service();
fakeStore.setState({ groups: dummyGroups, focusedGroup: dummyGroups[2] }); fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[2] },
});
assert.equal(svc.focused(), dummyGroups[2]); assert.equal(svc.focused(), dummyGroups[2]);
}); });
}); });
...@@ -768,7 +769,9 @@ describe('groups', function() { ...@@ -768,7 +769,9 @@ describe('groups', function() {
it('stores the focused group id in localStorage', function() { it('stores the focused group id in localStorage', function() {
service(); service();
fakeStore.setState({ groups: dummyGroups, focusedGroup: dummyGroups[1] }); fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.calledWithMatch( assert.calledWithMatch(
fakeLocalStorage.setItem, fakeLocalStorage.setItem,
...@@ -780,7 +783,9 @@ describe('groups', function() { ...@@ -780,7 +783,9 @@ describe('groups', function() {
it('emits the GROUP_FOCUSED event if the focused group changed', function() { it('emits the GROUP_FOCUSED event if the focused group changed', function() {
service(); service();
fakeStore.setState({ groups: dummyGroups, focusedGroup: dummyGroups[1] }); fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.calledWith( assert.calledWith(
fakeRootScope.$broadcast, fakeRootScope.$broadcast,
...@@ -792,9 +797,14 @@ describe('groups', function() { ...@@ -792,9 +797,14 @@ describe('groups', function() {
it('does not emit GROUP_FOCUSED if the focused group did not change', () => { it('does not emit GROUP_FOCUSED if the focused group did not change', () => {
service(); service();
fakeStore.setState({ groups: dummyGroups, focusedGroup: dummyGroups[1] }); fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
fakeRootScope.$broadcast.reset(); fakeRootScope.$broadcast.reset();
fakeStore.setState({ groups: dummyGroups, focusedGroup: dummyGroups[1] }); fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.notCalled(fakeRootScope.$broadcast); assert.notCalled(fakeRootScope.$broadcast);
}); });
...@@ -825,7 +835,9 @@ describe('groups', function() { ...@@ -825,7 +835,9 @@ describe('groups', function() {
it('should refetch groups if main frame URL has changed', () => { it('should refetch groups if main frame URL has changed', () => {
const svc = service(); const svc = service();
fakeStore.setState({ mainFrame: { uri: 'https://domain.com/page-a' } }); fakeStore.setState({
frames: [{ uri: 'https://domain.com/page-a' }],
});
return svc return svc
.load() .load()
.then(() => { .then(() => {
...@@ -833,7 +845,7 @@ describe('groups', function() { ...@@ -833,7 +845,7 @@ describe('groups', function() {
// a single page application. // a single page application.
fakeApi.groups.list.resetHistory(); fakeApi.groups.list.resetHistory();
fakeStore.setState({ fakeStore.setState({
mainFrame: { uri: 'https://domain.com/page-b' }, frames: [{ uri: 'https://domain.com/page-b' }],
}); });
return fakeRootScope.eventCallbacks[events.FRAME_CONNECTED](); return fakeRootScope.eventCallbacks[events.FRAME_CONNECTED]();
...@@ -846,7 +858,9 @@ describe('groups', function() { ...@@ -846,7 +858,9 @@ describe('groups', function() {
it('should not refetch groups if main frame URL has not changed', () => { it('should not refetch groups if main frame URL has not changed', () => {
const svc = service(); const svc = service();
fakeStore.setState({ mainFrame: { uri: 'https://domain.com/page-a' } }); fakeStore.setState({
frames: [{ uri: 'https://domain.com/page-a' }],
});
return svc return svc
.load() .load()
.then(() => { .then(() => {
......
...@@ -35,7 +35,7 @@ describe('sidebar.session', function() { ...@@ -35,7 +35,7 @@ describe('sidebar.session', function() {
events: require('../analytics').events, events: require('../analytics').events,
}; };
const fakeStore = { const fakeStore = {
getState: function() { getRootState: function() {
return { session: state }; return { session: state };
}, },
updateSession: function(session) { updateSession: function(session) {
......
'use strict'; 'use strict';
const EventEmitter = require('tiny-emitter'); const EventEmitter = require('tiny-emitter');
const unroll = require('../../../shared/test/util').unroll;
const Streamer = require('../streamer'); const Streamer = require('../streamer');
const fixtures = { const fixtures = {
...@@ -122,7 +119,7 @@ describe('Streamer', function() { ...@@ -122,7 +119,7 @@ describe('Streamer', function() {
fakeStore = { fakeStore = {
annotationExists: sinon.stub().returns(false), annotationExists: sinon.stub().returns(false),
clearPendingUpdates: sinon.stub(), clearPendingUpdates: sinon.stub(),
getState: sinon.stub().returns({ getRootState: sinon.stub().returns({
session: { session: {
userid: 'jim@hypothes.is', userid: 'jim@hypothes.is',
}, },
...@@ -400,10 +397,18 @@ describe('Streamer', function() { ...@@ -400,10 +397,18 @@ describe('Streamer', function() {
console.warn.restore(); console.warn.restore();
}); });
unroll( [
'does nothing if the userid matches the logged-in userid', {
function(testCase) { userid: 'acct:mr_bond@hypothes.is',
fakeStore.getState.returns({ websocketUserid: 'acct:mr_bond@hypothes.is',
},
{
userid: null,
websocketUserid: null,
},
].forEach(testCase => {
it('does nothing if the userid matches the logged-in userid', () => {
fakeStore.getRootState.returns({
session: { session: {
userid: testCase.userid, userid: testCase.userid,
}, },
...@@ -416,23 +421,21 @@ describe('Streamer', function() { ...@@ -416,23 +421,21 @@ describe('Streamer', function() {
}); });
assert.notCalled(console.warn); assert.notCalled(console.warn);
}); });
}, });
});
[ [
{ {
userid: 'acct:mr_bond@hypothes.is', userid: 'acct:mr_bond@hypothes.is',
websocketUserid: 'acct:mr_bond@hypothes.is', websocketUserid: 'acct:the_spanish_inquisition@hypothes.is',
}, },
{ {
userid: null, userid: null,
websocketUserid: null, websocketUserid: 'acct:the_spanish_inquisition@hypothes.is',
}, },
] ].forEach(testCase => {
); it('logs a warning if the userid does not match the logged-in userid', () => {
fakeStore.getRootState.returns({
unroll(
'logs a warning if the userid does not match the logged-in userid',
function(testCase) {
fakeStore.getState.returns({
session: { session: {
userid: testCase.userid, userid: testCase.userid,
}, },
...@@ -445,18 +448,8 @@ describe('Streamer', function() { ...@@ -445,18 +448,8 @@ describe('Streamer', function() {
}); });
assert.called(console.warn); assert.called(console.warn);
}); });
}, });
[ });
{
userid: 'acct:mr_bond@hypothes.is',
websocketUserid: 'acct:the_spanish_inquisition@hypothes.is',
},
{
userid: null,
websocketUserid: 'acct:the_spanish_inquisition@hypothes.is',
},
]
);
}); });
describe('reconnections', function() { describe('reconnections', function() {
......
...@@ -102,10 +102,10 @@ function loadGroups(groups) { ...@@ -102,10 +102,10 @@ function loadGroups(groups) {
* @return {Group|null} * @return {Group|null}
*/ */
function focusedGroup(state) { function focusedGroup(state) {
if (!state.focusedGroupId) { if (!state.groups.focusedGroupId) {
return null; return null;
} }
return getGroup(state, state.focusedGroupId); return getGroup(state, state.groups.focusedGroupId);
} }
/** /**
...@@ -114,7 +114,7 @@ function focusedGroup(state) { ...@@ -114,7 +114,7 @@ function focusedGroup(state) {
* @return {string|null} * @return {string|null}
*/ */
function focusedGroupId(state) { function focusedGroupId(state) {
return state.focusedGroupId; return state.groups.focusedGroupId;
} }
/** /**
...@@ -123,16 +123,17 @@ function focusedGroupId(state) { ...@@ -123,16 +123,17 @@ function focusedGroupId(state) {
* @return {Group[]} * @return {Group[]}
*/ */
function allGroups(state) { function allGroups(state) {
return state.groups; return state.groups.groups;
} }
/** /**
* Return the group with the given ID. * Return the group with the given ID.
* *
* @param {string} id
* @return {Group|undefined} * @return {Group|undefined}
*/ */
function getGroup(state, id) { function getGroup(state, id) {
return state.groups.find(g => g.id === id); return state.groups.groups.find(g => g.id === id);
} }
/** /**
...@@ -141,7 +142,7 @@ function getGroup(state, id) { ...@@ -141,7 +142,7 @@ function getGroup(state, id) {
* @return {Group[]} * @return {Group[]}
*/ */
const getMyGroups = createSelector( const getMyGroups = createSelector(
state => state.groups, state => state.groups.groups,
isLoggedIn, isLoggedIn,
(groups, loggedIn) => { (groups, loggedIn) => {
// If logged out, the Public group still has isMember set to true so only // If logged out, the Public group still has isMember set to true so only
...@@ -159,7 +160,7 @@ const getMyGroups = createSelector( ...@@ -159,7 +160,7 @@ const getMyGroups = createSelector(
* @return {Group[]} * @return {Group[]}
*/ */
const getFeaturedGroups = createSelector( const getFeaturedGroups = createSelector(
state => state.groups, state => state.groups.groups,
groups => groups.filter(group => !group.isMember && group.isScopedToUri) groups => groups.filter(group => !group.isMember && group.isScopedToUri)
); );
...@@ -187,12 +188,13 @@ const getCurrentlyViewingGroups = createSelector( ...@@ -187,12 +188,13 @@ const getCurrentlyViewingGroups = createSelector(
* @return {Group[]} * @return {Group[]}
*/ */
const getInScopeGroups = createSelector( const getInScopeGroups = createSelector(
state => state.groups, state => state.groups.groups,
groups => groups.filter(g => g.isScopedToUri) groups => groups.filter(g => g.isScopedToUri)
); );
module.exports = { module.exports = {
init, init,
namespace: 'groups',
update, update,
actions: { actions: {
focusGroup, focusGroup,
......
...@@ -3,11 +3,10 @@ ...@@ -3,11 +3,10 @@
const util = require('../util'); const util = require('../util');
function init() { function init() {
return {
/** /**
* Profile/session information for the active user. * Profile/session information for the active user.
*/ */
session: { return {
/** A map of features that are enabled for the current user. */ /** A map of features that are enabled for the current user. */
features: {}, features: {},
/** A map of preference names and values. */ /** A map of preference names and values. */
...@@ -16,14 +15,13 @@ function init() { ...@@ -16,14 +15,13 @@ function init() {
* The authenticated user ID or null if the user is not logged in. * The authenticated user ID or null if the user is not logged in.
*/ */
userid: null, userid: null,
},
}; };
} }
const update = { const update = {
UPDATE_SESSION: function(state, action) { UPDATE_SESSION: function(state, action) {
return { return {
session: action.session, ...action.session,
}; };
}, },
}; };
...@@ -71,6 +69,7 @@ function profile(state) { ...@@ -71,6 +69,7 @@ function profile(state) {
module.exports = { module.exports = {
init, init,
namespace: 'session',
update, update,
actions: { actions: {
......
...@@ -4,6 +4,7 @@ const immutable = require('seamless-immutable'); ...@@ -4,6 +4,7 @@ const immutable = require('seamless-immutable');
const createStore = require('../../create-store'); const createStore = require('../../create-store');
const groups = require('../groups'); const groups = require('../groups');
const session = require('../session');
describe('sidebar.store.modules.groups', () => { describe('sidebar.store.modules.groups', () => {
const publicGroup = immutable({ const publicGroup = immutable({
...@@ -73,7 +74,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -73,7 +74,7 @@ describe('sidebar.store.modules.groups', () => {
let store; let store;
beforeEach(() => { beforeEach(() => {
store = createStore([groups]); store = createStore([groups, session]);
}); });
describe('focusGroup', () => { describe('focusGroup', () => {
...@@ -90,7 +91,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -90,7 +91,7 @@ describe('sidebar.store.modules.groups', () => {
store.focusGroup(publicGroup.id); store.focusGroup(publicGroup.id);
assert.equal(store.getState().focusedGroupId, publicGroup.id); assert.equal(store.getRootState().groups.focusedGroupId, publicGroup.id);
assert.notCalled(console.error); assert.notCalled(console.error);
}); });
...@@ -99,7 +100,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -99,7 +100,7 @@ describe('sidebar.store.modules.groups', () => {
store.focusGroup(privateGroup.id); store.focusGroup(privateGroup.id);
assert.equal(store.getState().focusedGroupId, publicGroup.id); assert.equal(store.getRootState().groups.focusedGroupId, publicGroup.id);
assert.called(console.error); assert.called(console.error);
}); });
}); });
...@@ -107,7 +108,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -107,7 +108,7 @@ describe('sidebar.store.modules.groups', () => {
describe('loadGroups', () => { describe('loadGroups', () => {
it('updates the set of groups', () => { it('updates the set of groups', () => {
store.loadGroups([publicGroup]); store.loadGroups([publicGroup]);
assert.deepEqual(store.getState().groups, [publicGroup]); assert.deepEqual(store.getRootState().groups.groups, [publicGroup]);
}); });
it('resets the focused group if not in new set of groups', () => { it('resets the focused group if not in new set of groups', () => {
...@@ -115,7 +116,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -115,7 +116,7 @@ describe('sidebar.store.modules.groups', () => {
store.focusGroup(publicGroup.id); store.focusGroup(publicGroup.id);
store.loadGroups([]); store.loadGroups([]);
assert.equal(store.getState().focusedGroupId, null); assert.equal(store.getRootState().groups.focusedGroupId, null);
}); });
it('leaves focused group unchanged if in new set of groups', () => { it('leaves focused group unchanged if in new set of groups', () => {
...@@ -123,7 +124,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -123,7 +124,7 @@ describe('sidebar.store.modules.groups', () => {
store.focusGroup(publicGroup.id); store.focusGroup(publicGroup.id);
store.loadGroups([publicGroup, privateGroup]); store.loadGroups([publicGroup, privateGroup]);
assert.equal(store.getState().focusedGroupId, publicGroup.id); assert.equal(store.getRootState().groups.focusedGroupId, publicGroup.id);
}); });
}); });
...@@ -133,7 +134,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -133,7 +134,7 @@ describe('sidebar.store.modules.groups', () => {
store.clearGroups(); store.clearGroups();
assert.equal(store.getState().groups.length, 0); assert.equal(store.getRootState().groups.groups.length, 0);
}); });
it('clears the focused group id', () => { it('clears the focused group id', () => {
...@@ -142,7 +143,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -142,7 +143,7 @@ describe('sidebar.store.modules.groups', () => {
store.clearGroups(); store.clearGroups();
assert.equal(store.getState().focusedGroupId, null); assert.equal(store.getRootState().groups.focusedGroupId, null);
}); });
}); });
...@@ -231,11 +232,9 @@ describe('sidebar.store.modules.groups', () => { ...@@ -231,11 +232,9 @@ describe('sidebar.store.modules.groups', () => {
].forEach( ].forEach(
({ description, isLoggedIn, allGroups, expectedFeaturedGroups }) => { ({ description, isLoggedIn, allGroups, expectedFeaturedGroups }) => {
it(description, () => { it(description, () => {
store.getState().session = { userid: isLoggedIn ? '1234' : null }; store.updateSession({ userid: isLoggedIn ? '1234' : null });
store.loadGroups(allGroups); store.loadGroups(allGroups);
const featuredGroups = getListAssertNoDupes(store, 'featuredGroups'); const featuredGroups = getListAssertNoDupes(store, 'featuredGroups');
assert.deepEqual(featuredGroups, expectedFeaturedGroups); assert.deepEqual(featuredGroups, expectedFeaturedGroups);
}); });
} }
...@@ -273,7 +272,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -273,7 +272,7 @@ describe('sidebar.store.modules.groups', () => {
}, },
].forEach(({ description, isLoggedIn, allGroups, expectedMyGroups }) => { ].forEach(({ description, isLoggedIn, allGroups, expectedMyGroups }) => {
it(description, () => { it(description, () => {
store.getState().session = { userid: isLoggedIn ? '1234' : null }; store.updateSession({ userid: isLoggedIn ? '1234' : null });
store.loadGroups(allGroups); store.loadGroups(allGroups);
const myGroups = getListAssertNoDupes(store, 'myGroups'); const myGroups = getListAssertNoDupes(store, 'myGroups');
...@@ -305,7 +304,7 @@ describe('sidebar.store.modules.groups', () => { ...@@ -305,7 +304,7 @@ describe('sidebar.store.modules.groups', () => {
}, },
].forEach(({ description, isLoggedIn, allGroups }) => { ].forEach(({ description, isLoggedIn, allGroups }) => {
it(description, () => { it(description, () => {
store.getState().session = { userid: isLoggedIn ? '1234' : null }; store.updateSession({ userid: isLoggedIn ? '1234' : null });
store.loadGroups(allGroups); store.loadGroups(allGroups);
const currentlyViewing = getListAssertNoDupes( const currentlyViewing = getListAssertNoDupes(
......
'use strict'; 'use strict';
const createStore = require('../../create-store');
const session = require('../session'); const session = require('../session');
const util = require('../../util'); const { init } = session;
const { init, actions, selectors } = session;
const update = util.createReducer(session.update);
describe('sidebar.reducers.session', function() { describe('sidebar.reducers.session', function() {
let store;
beforeEach(() => {
store = createStore([session]);
});
describe('#updateSession', function() { describe('#updateSession', function() {
it('updates the session state', function() { it('updates the session state', function() {
const newSession = Object.assign(init(), { userid: 'john' }); const newSession = Object.assign(init(), { userid: 'john' });
const state = update(init(), actions.updateSession(newSession)); store.updateSession({ userid: 'john' });
assert.deepEqual(state.session, newSession); assert.deepEqual(store.getRootState().session, newSession);
}); });
}); });
...@@ -22,18 +26,20 @@ describe('sidebar.reducers.session', function() { ...@@ -22,18 +26,20 @@ describe('sidebar.reducers.session', function() {
{ userid: null, expectedIsLoggedIn: false }, { userid: null, expectedIsLoggedIn: false },
].forEach(({ userid, expectedIsLoggedIn }) => { ].forEach(({ userid, expectedIsLoggedIn }) => {
it('returns whether the user is logged in', () => { it('returns whether the user is logged in', () => {
const newSession = Object.assign(init(), { userid: userid }); store.updateSession({ userid: userid });
const state = update(init(), actions.updateSession(newSession)); assert.equal(store.isLoggedIn(), expectedIsLoggedIn);
assert.equal(selectors.isLoggedIn(state), expectedIsLoggedIn);
}); });
}); });
}); });
describe('#profile', () => { describe('#profile', () => {
it("returns the user's profile", () => { it("returns the user's profile", () => {
const newSession = Object.assign(init(), { userid: 'john' }); store.updateSession({ userid: 'john' });
const state = update(init(), actions.updateSession(newSession)); assert.deepEqual(store.profile(), {
assert.equal(selectors.profile(state), newSession); userid: 'john',
features: {},
preferences: {},
});
}); });
}); });
}); });
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