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

Merge pull request #1420 from hypothesis/share-annotations-panel

Share Annotations Panel 3 & 4 of 4: Add `ShareAnnotationsPanel` and replace `ShareDialog` with it
parents 792e873e 7abcfaef
'use strict';
const scrollIntoView = require('scroll-into-view');
const events = require('../events');
const { parseAccountID } = require('../util/account-id');
const scopeTimeout = require('../util/scope-timeout');
const serviceConfig = require('../service-config');
const bridgeEvents = require('../../shared/bridge-events');
......@@ -60,7 +57,6 @@ function HypothesisAppController(
this.auth = { status: 'unknown' };
// App dialogs
this.shareDialog = { visible: false };
this.helpPanel = { visible: false };
// Check to see if we're in the sidebar, or on a standalone page such as
......@@ -79,19 +75,6 @@ function HypothesisAppController(
self.auth = authStateFromProfile(profile);
});
/** Scroll to the view to the element matching the given selector */
function scrollToView(selector) {
// Add a timeout so that if the element has just been shown (eg. via ngIf)
// it is added to the DOM before we try to locate and scroll to it.
scopeTimeout(
$scope,
function() {
scrollIntoView($document[0].querySelector(selector));
},
0
);
}
/**
* Start the login flow. This will present the user with the login dialog.
*
......@@ -127,12 +110,6 @@ function HypothesisAppController(
$window.open(serviceUrl('signup'));
};
// Display the dialog for sharing the current page
this.share = function() {
this.shareDialog.visible = true;
scrollToView('share-dialog');
};
this.showHelpPanel = function() {
const service = serviceConfig(settings) || {};
if (service.onHelpRequestProvided) {
......
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const useStore = require('../store/use-store');
const { copyText } = require('../util/copy-to-clipboard');
const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants');
const SidebarPanel = require('./sidebar-panel');
const SvgIcon = require('./svg-icon');
/**
* A panel for sharing the current group's annotations.
*
* Links withinin this component allow a user to share the set of annotations that
* are on the current page (as defined by the main frame's URI) and contained
* within the app's currently-focused group.
*/
function ShareAnnotationsPanel({ analytics, flash }) {
const mainFrame = useStore(store => store.mainFrame());
const focusedGroup = useStore(store => store.focusedGroup());
// We can render a basic frame for the panel at any time,
// but hold off rendering panel content if needed things aren't present.
// We need to know what page we're on and what group is focused.
const shouldRenderPanelContent = focusedGroup && mainFrame;
const groupName = (focusedGroup && focusedGroup.name) || '...';
const panelTitle = `Share Annotations in ${groupName}`;
// Generate bouncer sharing link for annotations in the current group.
// This is the URI format for the web-sharing link shown in the input
// and is available to be copied to clipboard
const shareURI = ((frame, group) => {
if (!shouldRenderPanelContent) {
return '';
}
return `https://hyp.is/go?url=${encodeURIComponent(mainFrame.uri)}&group=${
group.id
}`;
})(mainFrame, focusedGroup);
// This is the double-encoded format needed for other services (the entire
// URI needs to be encoded because it's used as the value of querystring params)
const encodedURI = encodeURIComponent(shareURI);
const trackShareClick = shareTarget => {
analytics.track(analytics.events.DOCUMENT_SHARED, shareTarget);
};
const copyShareLink = () => {
try {
copyText(shareURI);
flash.info('Copied share link to clipboard');
} catch (err) {
flash.error('Unable to copy link');
}
};
return (
<SidebarPanel
title={panelTitle}
panelName={uiConstants.PANEL_SHARE_ANNOTATIONS}
>
{shouldRenderPanelContent && (
<div className="share-annotations-panel">
<div className="share-annotations-panel__intro">
{focusedGroup.type === 'private' ? (
<p>
Use this link to share these annotations with other group
members:
</p>
) : (
<p>Use this link to share these annotations with anyone:</p>
)}
</div>
<div className="share-annotations-panel__input">
<input
aria-label="Use this URL to share these annotations"
className="form-input share-annotations-panel__form-input"
type="text"
value={shareURI}
readOnly
/>
<button
onClick={copyShareLink}
title="copy share link"
aria-label="Copy share link"
className="btn btn-clean share-annotations-panel__copy-btn"
>
<SvgIcon name="copy" />
</button>
</div>
<p>
{focusedGroup.type === 'private' ? (
<span>
Annotations in the private group <em>{focusedGroup.name}</em>{' '}
are only visible to group members.
</span>
) : (
<span>
Anyone using this link may view the annotations in the group{' '}
<em>{focusedGroup.name}</em>.
</span>
)}{' '}
<span>
Private (
<SvgIcon
name="lock"
inline
className="share-annotations-panel__icon--inline"
/>{' '}
<em>Only Me</em>) annotations are only visible to you.
</span>
</p>
<ul className="share-annotations-panel__links">
<li className="share-annotations-panel__link">
<a
href={`https://twitter.com/intent/tweet?url=${encodedURI}&hashtags=annotated`}
title="Tweet share link"
onClick={trackShareClick('twitter')}
>
<SvgIcon
name="twitter"
className="share-annotations-panel__icon"
/>
</a>
</li>
<li className="share-annotations-panel__link">
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedURI}`}
title="Share on Facebook"
onClick={trackShareClick('facebook')}
>
<SvgIcon
name="facebook"
className="share-annotations-panel__icon"
/>
</a>
</li>
<li className="share-annotations-panel__link">
<a
href={`mailto:?subject=${encodeURIComponent(
"Let's Annotate"
)}&body=${encodedURI}`}
title="Share via email"
onClick={trackShareClick('email')}
>
<SvgIcon
name="email"
className="share-annotations-panel__icon"
/>
</a>
</li>
</ul>
</div>
)}
</SidebarPanel>
);
}
ShareAnnotationsPanel.propTypes = {
// Injected services
analytics: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
};
ShareAnnotationsPanel.injectedProps = ['analytics', 'flash'];
module.exports = withServices(ShareAnnotationsPanel);
'use strict';
// @ngInject
function ShareDialogController($scope, $element, analytics, store) {
const self = this;
function updateSharePageLink(frames) {
if (!frames.length) {
self.sharePageLink = '';
return;
}
self.sharePageLink =
'https://hyp.is/go?url=' + encodeURIComponent(frames[0].uri);
}
const shareLinkInput = $element[0].querySelector('.js-share-link');
shareLinkInput.focus();
shareLinkInput.select();
$scope.$watch(function() {
return store.frames();
}, updateSharePageLink);
$scope.onShareClick = function(target) {
if (target) {
analytics.track(analytics.events.DOCUMENT_SHARED, target);
}
};
}
module.exports = {
controller: ShareDialogController,
controllerAs: 'vm',
bindings: {
onClose: '&',
},
template: require('../templates/share-dialog.html'),
};
......@@ -252,11 +252,6 @@ describe('sidebar.components.hypothesis-app', function() {
});
});
it('does not show the share dialog at start', function() {
const ctrl = createController();
assert.isFalse(ctrl.shareDialog.visible);
});
describe('#signUp', function() {
it('tracks sign up requests in analytics', function() {
const ctrl = createController();
......@@ -409,14 +404,6 @@ describe('sidebar.components.hypothesis-app', function() {
});
});
describe('#share()', function() {
it('shows the share dialog', function() {
const ctrl = createController();
ctrl.share();
assert.equal(ctrl.shareDialog.visible, true);
});
});
describe('#logout()', function() {
// Tests shared by both of the contexts below.
function doSharedTests() {
......
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const unroll = require('../../../shared/test/util').unroll;
const ShareAnnotationsPanel = require('../share-annotations-panel');
const SidebarPanel = require('../sidebar-panel');
describe('ShareAnnotationsPanel', () => {
let fakeStore;
let fakeAnalytics;
let fakeFlash;
let fakeCopyToClipboard;
const fakePrivateGroup = {
type: 'private',
name: 'Test Private Group',
id: 'testprivate',
};
const createShareAnnotationsPanel = props =>
shallow(
<ShareAnnotationsPanel
analytics={fakeAnalytics}
flash={fakeFlash}
{...props}
/>
).dive(); // Needed because of `withServices`
beforeEach(() => {
fakeAnalytics = {
events: {
DOCUMENT_SHARED: 'whatever',
},
track: sinon.stub(),
};
fakeCopyToClipboard = {
copyText: sinon.stub(),
};
fakeFlash = {
info: sinon.stub(),
error: sinon.stub(),
};
fakeStore = {
focusedGroup: sinon.stub().returns(fakePrivateGroup),
mainFrame: () => ({
uri: 'https://www.example.com',
}),
};
ShareAnnotationsPanel.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/copy-to-clipboard': fakeCopyToClipboard,
});
});
afterEach(() => {
ShareAnnotationsPanel.$imports.$restore();
});
describe('panel title', () => {
it("sets sidebar panel title to include group's name", () => {
const wrapper = createShareAnnotationsPanel();
assert.equal(
wrapper.find(SidebarPanel).prop('title'),
'Share Annotations in Test Private Group'
);
});
it('sets a temporary title if focused group not available', () => {
fakeStore.focusedGroup = sinon.stub().returns({});
const wrapper = createShareAnnotationsPanel();
assert.equal(
wrapper.find(SidebarPanel).prop('title'),
'Share Annotations in ...'
);
});
});
describe('panel content', () => {
it('renders panel content if needed info available', () => {
const wrapper = createShareAnnotationsPanel();
assert.isTrue(wrapper.exists('.share-annotations-panel'));
});
it('does not render panel content if needed info not available', () => {
fakeStore.focusedGroup.returns(undefined);
const wrapper = createShareAnnotationsPanel();
assert.isFalse(wrapper.exists('.share-annotations-panel'));
});
});
unroll(
'it displays appropriate help text depending on group type',
testCase => {
fakeStore.focusedGroup.returns({
type: testCase.groupType,
name: 'Test Group',
id: 'testid,',
});
const wrapper = createShareAnnotationsPanel();
assert.match(
wrapper.find('.share-annotations-panel__intro').text(),
testCase.introPattern
);
assert.match(
wrapper.find('.share-annotations-panel').text(),
testCase.visibilityPattern
);
},
[
{
groupType: 'private',
introPattern: /Use this link.*with other group members/,
visibilityPattern: /Annotations in the private group.*are only visible to group members/,
},
{
groupType: 'restricted',
introPattern: /Use this link to share these annotations with anyone/,
visibilityPattern: /Anyone using this link may view the annotations in the group/,
},
{
groupType: 'open',
introPattern: /Use this link to share these annotations with anyone/,
visibilityPattern: /Anyone using this link may view the annotations in the group/,
},
]
);
describe('web share link', () => {
it('displays web share link in readonly form input', () => {
const wrapper = createShareAnnotationsPanel();
const inputEl = wrapper.find('input');
assert.equal(
inputEl.prop('value'),
'https://hyp.is/go?url=https%3A%2F%2Fwww.example.com&group=testprivate'
);
assert.equal(inputEl.prop('readOnly'), true);
});
describe('copy link to clipboard', () => {
it('copies link to clipboard when copy button clicked', () => {
const wrapper = createShareAnnotationsPanel();
wrapper.find('button').simulate('click');
assert.calledWith(
fakeCopyToClipboard.copyText,
'https://hyp.is/go?url=https%3A%2F%2Fwww.example.com&group=testprivate'
);
});
it('confirms link copy when successful', () => {
const wrapper = createShareAnnotationsPanel();
wrapper.find('button').simulate('click');
assert.calledWith(fakeFlash.info, 'Copied share link to clipboard');
});
it('flashes an error if link copying unsuccessful', () => {
fakeCopyToClipboard.copyText.throws();
const wrapper = createShareAnnotationsPanel();
wrapper.find('button').simulate('click');
assert.calledWith(fakeFlash.error, 'Unable to copy link');
});
});
});
describe('other share links', () => {
const shareLink =
'https://hyp.is/go?url=https%3A%2F%2Fwww.example.com&group=testprivate';
const encodedLink = encodeURIComponent(shareLink);
const encodedSubject = encodeURIComponent("Let's Annotate");
[
{
service: 'facebook',
expectedURI: `https://www.facebook.com/sharer/sharer.php?u=${encodedLink}`,
title: 'Share on Facebook',
},
{
service: 'twitter',
expectedURI: `https://twitter.com/intent/tweet?url=${encodedLink}&hashtags=annotated`,
title: 'Tweet share link',
},
{
service: 'email',
expectedURI: `mailto:?subject=${encodedSubject}&body=${encodedLink}`,
title: 'Share via email',
},
].forEach(testCase => {
it(`creates a share link for ${testCase.service} and tracks clicks`, () => {
const wrapper = createShareAnnotationsPanel();
const link = wrapper.find(`a[title="${testCase.title}"]`);
link.simulate('click');
assert.equal(link.prop('href'), testCase.expectedURI);
assert.calledWith(
fakeAnalytics.track,
fakeAnalytics.events.DOCUMENT_SHARED,
testCase.service
);
});
});
});
});
'use strict';
const angular = require('angular');
const util = require('../../directive/test/util');
describe('shareDialog', function() {
let fakeAnalytics;
let fakeStore;
beforeEach(function() {
fakeAnalytics = {
track: sinon.stub(),
events: {},
};
fakeStore = { frames: sinon.stub().returns([]) };
angular
.module('h', [])
.component('shareDialog', require('../share-dialog'))
.value('analytics', fakeAnalytics)
.value('store', fakeStore)
.value('urlEncodeFilter', function(val) {
return val;
});
angular.mock.module('h');
});
it('generates new share link', function() {
const element = util.createDirective(document, 'shareDialog', {});
const uri = 'http://example.com';
fakeStore.frames.returns([{ uri }]);
element.scope.$digest();
assert.equal(
element.ctrl.sharePageLink,
'https://hyp.is/go?url=' + encodeURIComponent(uri)
);
});
it('tracks the target being shared', function() {
const element = util.createDirective(document, 'shareDialog');
const clickShareIcon = function(iconName) {
element.find('.' + iconName).click();
};
clickShareIcon('h-icon-twitter');
assert.equal(fakeAnalytics.track.args[0][1], 'twitter');
clickShareIcon('h-icon-facebook');
assert.equal(fakeAnalytics.track.args[1][1], 'facebook');
clickShareIcon('h-icon-mail');
assert.equal(fakeAnalytics.track.args[2][1], 'email');
});
});
......@@ -3,6 +3,8 @@
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const uiConstants = require('../../ui-constants');
const GroupList = require('../group-list');
const SearchInput = require('../search-input');
const StreamSearchInput = require('../stream-search-input');
......@@ -21,8 +23,14 @@ describe('TopBar', () => {
fakeStore = {
filterQuery: sinon.stub().returns(null),
getState: sinon.stub().returns({
sidebarPanels: {
activePanelName: null,
},
}),
pendingUpdateCount: sinon.stub().returns(0),
setFilterQuery: sinon.stub(),
toggleSidebarPanel: sinon.stub(),
};
fakeStreamer = {
......@@ -143,9 +151,9 @@ describe('TopBar', () => {
});
context('when using a first-party service', () => {
it('shows the share page button', () => {
it('shows the share annotations button', () => {
const wrapper = createTopBar();
assert.isTrue(wrapper.exists('[title="Share this page"]'));
assert.isTrue(wrapper.exists('[title="Share annotations on this page"]'));
});
});
......@@ -154,17 +162,31 @@ describe('TopBar', () => {
fakeIsThirdPartyService.returns(true);
});
it("doesn't show the share page button", () => {
it("doesn't show the share annotations button", () => {
const wrapper = createTopBar();
assert.isFalse(wrapper.exists('[title="Share this page"]'));
assert.isFalse(
wrapper.exists('[title="Share annotations on this page"]')
);
});
});
it('displays the share page when "Share this page" is clicked', () => {
const onSharePage = sinon.stub();
const wrapper = createTopBar({ onSharePage });
wrapper.find('[title="Share this page"]').simulate('click');
assert.called(onSharePage);
it('toggles the share annotations panel when "Share" is clicked', () => {
const wrapper = createTopBar();
wrapper.find('[title="Share annotations on this page"]').simulate('click');
assert.called(fakeStore.toggleSidebarPanel);
});
it('adds an active-state class to the "Share" icon when the panel is open', () => {
fakeStore.getState.returns({
sidebarPanels: {
activePanelName: uiConstants.PANEL_SHARE_ANNOTATIONS,
},
});
const wrapper = createTopBar();
const shareEl = wrapper.find('[title="Share annotations on this page"]');
assert.include(shareEl.prop('className'), 'top-bar__btn--active');
});
it('displays search input in the sidebar', () => {
......
......@@ -8,6 +8,7 @@ const useStore = require('../store/use-store');
const { applyTheme } = require('../util/theme');
const isThirdPartyService = require('../util/is-third-party-service');
const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants');
const GroupList = require('./group-list');
const SearchInput = require('./search-input');
......@@ -25,7 +26,6 @@ function TopBar({
isSidebar,
onLogin,
onLogout,
onSharePage,
onShowHelpPanel,
onSignUp,
settings,
......@@ -40,8 +40,17 @@ function TopBar({
const pendingUpdateCount = useStore(store => store.pendingUpdateCount());
const togglePanelFn = useStore(store => store.toggleSidebarPanel);
const currentActivePanel = useStore(
store => store.getState().sidebarPanels.activePanelName
);
const applyPendingUpdates = () => streamer.applyPendingUpdates();
const toggleSharePanel = () => {
togglePanelFn(uiConstants.PANEL_SHARE_ANNOTATIONS);
};
const loginControl = (
<Fragment>
{auth.status === 'unknown' && (
......@@ -104,12 +113,15 @@ function TopBar({
<SortMenu />
{showSharePageButton && (
<button
className="top-bar__btn"
onClick={onSharePage}
title="Share this page"
aria-label="Share this page"
className={classnames('top-bar__btn', {
'top-bar__btn--active':
currentActivePanel === uiConstants.PANEL_SHARE_ANNOTATIONS,
})}
onClick={toggleSharePanel}
title="Share annotations on this page"
aria-label="Share annotations on this page"
>
<i className="h-icon-annotation-share" />
<SvgIcon name="share" />
</button>
)}
<button
......@@ -158,9 +170,6 @@ TopBar.propTypes = {
/** Callback invoked when user clicks "Logout" action in account menu. */
onLogout: propTypes.func,
/** Callback invoked when user clicks "Share" toolbar action. */
onSharePage: propTypes.func,
/** Callback invoked when user clicks "Sign up" button. */
onSignUp: propTypes.func,
......
......@@ -183,8 +183,11 @@ function startAngularApp(config) {
'sidebarContentError',
wrapReactComponent(require('./components/sidebar-content-error'))
)
.component(
'shareAnnotationsPanel',
wrapReactComponent(require('./components/share-annotations-panel'))
)
.component('sidebarTutorial', require('./components/sidebar-tutorial'))
.component('shareDialog', require('./components/share-dialog'))
.component('streamContent', require('./components/stream-content'))
.component('svgIcon', wrapReactComponent(require('./components/svg-icon')))
.component('tagEditor', require('./components/tag-editor'))
......
......@@ -4,21 +4,17 @@
on-login="vm.login()"
on-sign-up="vm.signUp()"
on-logout="vm.logout()"
on-share-page="vm.share()"
on-show-help-panel="vm.showHelpPanel()"
is-sidebar="::vm.isSidebar">
</top-bar>
<div class="content">
<sidebar-tutorial ng-if="vm.isSidebar"></sidebar-tutorial>
<share-dialog
ng-if="vm.shareDialog.visible"
on-close="vm.shareDialog.visible = false">
</share-dialog>
<help-panel ng-if="vm.helpPanel.visible"
on-close="vm.helpPanel.visible = false"
auth="vm.auth">
</help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-view=""></main>
</div>
</div>
<div class="sheet">
<i class="close h-icon-close"
role="button"
title="Close"
ng-click="vm.onClose()"></i>
<div class="form-vertical">
<ul class="nav nav-tabs">
<li class="active"><a href="">Share</a></li>
</ul>
<div class="tab-content">
<p>Share the link below to show anyone these annotations and invite them to contribute their own.</p>
<p><input class="js-share-link form-input"
type="text"
ng-value="vm.sharePageLink"
readonly /></p>
<p class="share-link-icons">
<a href="https://twitter.com/intent/tweet?url={{vm.sharePageLink | urlEncode}}&hashtags=annotated"
target="_blank"
title="Tweet link"
class="share-link-icon h-icon-twitter"
ng-click="onShareClick('twitter')"></a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{vm.sharePageLink | urlEncode}}"
target="_blank"
title="Share on Facebook"
class="share-link-icon h-icon-facebook"
ng-click="onShareClick('facebook')"></a>
<a href="mailto:?subject=Let's%20Annotate&amp;body={{vm.sharePageLink}}"
target="_blank"
title="Share via email"
class="share-link-icon h-icon-mail"
ng-click="onShareClick('email')"></a>
</p>
</div>
</div>
</div>
......@@ -5,6 +5,7 @@
*/
module.exports = {
PANEL_SHARE_ANNOTATIONS: 'shareGroupAnnotations',
TAB_ANNOTATIONS: 'annotation',
TAB_NOTES: 'note',
TAB_ORPHANS: 'orphan',
......
.share-annotations-panel {
color: $grey-5;
&__intro {
color: $grey-7;
font-weight: 500;
}
&__input {
display: flex;
}
&__form-input {
border-radius: 0;
}
&__copy-btn {
@include outline-on-keyboard-focus;
padding: 10px;
color: $grey-4;
background: $grey-1;
border: 1px solid $grey-3;
border-radius: 0;
border-left: 0px;
&:hover {
background-color: $grey-2;
color: $grey-5;
}
}
&__links {
display: flex;
flex-direction: row;
justify-content: center;
padding-top: 8px;
border-top: 1px solid $grey-3;
}
&__icon {
display: flex;
width: 24px;
height: 24px;
margin: 0 8px;
color: $grey-5;
&:hover {
color: $grey-6;
}
}
&__icon--inline {
width: 1em;
height: 1em;
}
}
// form for sharing links
.share-link-container {
font-size: $body1-font-size;
line-height: $body1-line-height;
margin-top: 1px;
white-space: normal;
}
.share-link {
color: $gray-light;
}
.share-link:hover {
width: 100%;
color: $gray-dark;
}
.share-link-icons {
display: flex;
flex-direction: row;
justify-content: center;
}
.share-link-icon {
color: $color-dove-gray;
display: inline-block;
font-size: 24px;
text-decoration: none;
margin-left: 5px;
margin-right: 5px;
&:hover {
color: $brand-color;
}
}
.sidebar-panel {
position: relative;
background-color: $body-background;
border: solid 1px $grey-3;
border-radius: 2px;
font-family: $sans-font-family;
margin-bottom: 0.75em;
position: relative;
background-color: $body-background;
&__header {
display: flex;
flex-direction: row;
align-items: center;
border: 1px none $grey-3;
border-bottom-style: solid;
padding: 1em 0;
margin: 0 1em;
border: 1px none $grey-3;
border-bottom-style: solid;
}
&__title {
......@@ -23,6 +22,7 @@
}
&__close-btn {
@include outline-on-keyboard-focus;
display: flex;
align-items: center;
border-radius: 2px;
......
......@@ -77,6 +77,9 @@
&:hover {
color: $gray-dark;
}
&--active {
color: $gray-dark;
}
}
// Button which indicates that other users have made or edited annotations
......
......@@ -41,7 +41,7 @@ $base-line-height: 20px;
@import './components/primary-action-btn';
@import './components/search-status-bar';
@import './components/selection-tabs';
@import './components/share-link';
@import './components/share-annotations-panel';
@import './components/search-input';
@import './components/sidebar-panel';
@import './components/sidebar-tutorial';
......
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