Merge pull request #1335 from coralproject/keep-drafts

Keep comment drafts
This commit is contained in:
Wyatt Johnson
2018-02-06 14:30:27 -07:00
committed by GitHub
18 changed files with 291 additions and 111 deletions
+20 -16
View File
@@ -10,7 +10,7 @@ import jwtDecode from 'jwt-decode';
export const handleLogin = (email, password, recaptchaResponse) => (
dispatch,
_,
{ rest, client, storage }
{ rest, client, localStorage }
) => {
dispatch({ type: actions.LOGIN_REQUEST });
@@ -31,9 +31,9 @@ export const handleLogin = (email, password, recaptchaResponse) => (
return rest('/auth/local', params)
.then(({ user, token }) => {
if (!user) {
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
storage.removeItem('exp');
if (!bowser.safari && !bowser.ios && localStorage) {
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
return dispatch(checkLoginFailure('not logged in'));
}
@@ -122,14 +122,18 @@ const checkLoginFailure = error => ({
error,
});
export const checkLogin = () => (dispatch, _, { rest, client, storage }) => {
export const checkLogin = () => (
dispatch,
_,
{ rest, client, localStorage }
) => {
dispatch(checkLoginRequest());
return rest('/auth')
.then(({ user }) => {
if (!user) {
if (!bowser.safari && !bowser.ios && storage) {
storage.removeItem('token');
storage.removeItem('exp');
if (!bowser.safari && !bowser.ios && localStorage) {
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
return dispatch(checkLoginFailure('not logged in'));
}
@@ -150,11 +154,11 @@ export const checkLogin = () => (dispatch, _, { rest, client, storage }) => {
// LOGOUT
//==============================================================================
export const logout = () => (dispatch, _, { rest, client, storage }) => {
export const logout = () => (dispatch, _, { rest, client, localStorage }) => {
return rest('/auth', { method: 'DELETE' }).then(() => {
if (storage) {
storage.removeItem('token');
storage.removeItem('exp');
if (localStorage) {
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
// Reset the websocket.
@@ -168,10 +172,10 @@ export const logout = () => (dispatch, _, { rest, client, storage }) => {
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = token => (dispatch, _, { storage }) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
export const handleAuthToken = token => (dispatch, _, { localStorage }) => {
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
}
dispatch({ type: 'HANDLE_AUTH_TOKEN' });
};
+3 -3
View File
@@ -4,10 +4,10 @@ export const toggleModal = open => ({ type: actions.TOGGLE_MODAL, open });
export const singleView = () => ({ type: actions.SINGLE_VIEW });
// hide shortcuts note
export const hideShortcutsNote = () => (dispatch, _, { storage }) => {
export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => {
try {
if (storage) {
storage.setItem('coral:shortcutsNote', 'hide');
if (localStorage) {
localStorage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
+2 -2
View File
@@ -14,8 +14,8 @@ import { hideShortcutsNote } from './actions/moderation';
smoothscroll.polyfill();
function init({ store, storage }) {
if (storage && storage.getItem('coral:shortcutsNote') === 'hide') {
function init({ store, localStorage }) {
if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') {
store.dispatch(hideShortcutsNote());
}
}
+15 -15
View File
@@ -90,10 +90,10 @@ const signInFailure = error => ({
// AUTH TOKEN
//==============================================================================
export const handleAuthToken = token => (dispatch, _, { storage }) => {
if (storage) {
storage.setItem('exp', jwtDecode(token).exp);
storage.setItem('token', token);
export const handleAuthToken = token => (dispatch, _, { localStorage }) => {
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
}
dispatch({ type: 'HANDLE_AUTH_TOKEN' });
@@ -260,13 +260,13 @@ export const fetchForgotPassword = email => (dispatch, getState, { rest }) => {
export const logout = () => async (
dispatch,
_,
{ rest, client, pym, storage }
{ rest, client, pym, localStorage }
) => {
await rest('/auth', { method: 'DELETE' });
if (storage) {
storage.removeItem('token');
storage.removeItem('exp');
if (localStorage) {
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
// Reset the websocket.
@@ -297,15 +297,15 @@ const ErrNotLoggedIn = new Error('Not logged in');
export const checkLogin = () => (
dispatch,
_,
{ rest, client, pym, storage }
{ rest, client, pym, localStorage }
) => {
dispatch(checkLoginRequest());
rest('/auth')
.then(result => {
if (!result.user) {
if (storage) {
storage.removeItem('token');
storage.removeItem('exp');
if (localStorage) {
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
throw ErrNotLoggedIn;
}
@@ -328,10 +328,10 @@ export const checkLogin = () => (
if (error !== ErrNotLoggedIn) {
console.error(error);
}
if (error.status && error.status === 401 && storage) {
if (error.status && error.status === 401 && localStorage) {
// Unauthorized.
storage.removeItem('token');
storage.removeItem('exp');
localStorage.removeItem('token');
localStorage.removeItem('exp');
}
const errorMessage = error.translation_key
? t(`error.${error.translation_key}`)
+2 -10
View File
@@ -14,18 +14,10 @@ import reducers from './reducers';
import TalkProvider from 'coral-framework/components/TalkProvider';
import pluginsConfig from 'pluginsConfig';
function inIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
// TODO: move init code into `bootstrap` service after auth has been refactored.
function preInit({ store, pym }) {
function preInit({ store, pym, inIframe }) {
// TODO: This is popup specific code and needs to be refactored.
if (!inIframe()) {
if (!inIframe) {
store.dispatch(addExternalConfig({}));
store.dispatch(checkLogin());
return;
@@ -2,13 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'coral-ui';
import cn from 'classnames';
import Slot from 'coral-framework/components/Slot';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
import { name } from '../containers/CommentBox';
import styles from './CommentForm.css';
import t from 'coral-framework/services/i18n';
import DraftArea from '../containers/DraftArea';
/**
* Common UI for Creating or Editing a Comment
@@ -61,10 +61,6 @@ export class CommentForm extends React.Component {
};
}
onBodyChange = e => {
this.props.onBodyChange(e.target.value);
};
onClickSubmit = () => {
this.props.onSubmit();
};
@@ -107,37 +103,16 @@ export class CommentForm extends React.Component {
return (
<div>
<div className={`${name}-container`}>
<label
htmlFor={this.props.bodyInputId}
className="screen-reader-text"
aria-hidden={true}
>
{this.props.bodyLabel}
</label>
<textarea
className={`${name}-textarea`}
value={body}
placeholder={this.props.bodyPlaceholder}
id={this.props.bodyInputId}
onChange={this.onBodyChange}
rows={3}
disabled={disableTextArea}
/>
<Slot fill="commentInputArea" />
</div>
{this.props.charCountEnable && (
<div
className={`${name}-char-count ${
length > maxCharCount ? `${name}-char-max` : ''
}`}
>
{maxCharCount &&
`${maxCharCount - length} ${t(
'comment_box.characters_remaining'
)}`}
</div>
)}
<DraftArea
id={this.props.bodyInputId}
label={this.props.bodyLabel}
value={body}
placeholder={this.props.bodyPlaceholder}
onChange={this.props.onBodyChange}
disabled={disableTextArea}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
/>
<div className={`${name}-button-container`}>
{this.props.buttonContainerStart}
{typeof this.props.onCancel === 'function' && (
@@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import Slot from 'coral-framework/components/Slot';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
/**
* An enhanced textarea to make comment drafts.
*/
export default class DraftArea extends React.Component {
renderCharCount() {
const { value, maxCharCount } = this.props;
const className = cn('talk-plugin-commentbox-char-count', {
['talk-plugin-commentbox-char-max']: value.length > maxCharCount,
});
const remaining = maxCharCount - value.length;
return (
<div className={className}>
{remaining} {t('comment_box.characters_remaining')}
</div>
);
}
render() {
const {
value,
placeholder,
id,
disabled,
rows,
label,
charCountEnable,
maxCharCount,
onChange,
} = this.props;
return (
<div>
<div className={'talk-plugin-commentbox-container'}>
<label htmlFor={id} className="screen-reader-text" aria-hidden={true}>
{label}
</label>
<textarea
className={'talk-plugin-commentbox-textarea'}
value={value}
placeholder={placeholder}
id={id}
onChange={onChange}
rows={rows}
disabled={disabled}
/>
<Slot fill="commentInputArea" />
</div>
{charCountEnable && maxCharCount > 0 && this.renderCharCount()}
</div>
);
}
}
DraftArea.defaultProps = {
rows: 3,
};
DraftArea.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
label: PropTypes.string,
onChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
};
@@ -21,6 +21,7 @@ export class EditableCommentContent extends React.Component {
// comment that is being edited
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string,
editing: PropTypes.shape({
edited: PropTypes.bool,
@@ -120,10 +121,12 @@ export class EditableCommentContent extends React.Component {
};
render() {
const id = `edit-draft_${this.props.comment.id}`;
return (
<div className={styles.editCommentForm}>
<CommentForm
defaultValue={this.props.comment.body}
bodyInputId={id}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
submitEnabled={this.isSubmitEnabled}
@@ -7,7 +7,8 @@ const name = 'talk-plugin-replies';
class ReplyBox extends Component {
componentDidMount() {
document.getElementById('replyText').focus();
// TODO: (kiwi) This does not follow best practices, better to move this logic into the component.
document.getElementById(`comment-draft_${this.props.parentId}`).focus();
}
cancelReply = () => {
@@ -54,6 +55,8 @@ ReplyBox.propTypes = {
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired,
currentUser: PropTypes.object,
styles: PropTypes.object,
};
export default ReplyBox;
@@ -20,7 +20,6 @@ class CommentBox extends React.Component {
super(props);
this.state = {
username: '',
body: '',
loadingState: '',
@@ -129,7 +128,7 @@ class CommentBox extends React.Component {
};
render() {
const { isReply, maxCharCount } = this.props;
const { isReply, maxCharCount, assetId, parentId } = this.props;
let { onCancel } = this.props;
if (isReply && typeof onCancel !== 'function') {
@@ -139,6 +138,11 @@ class CommentBox extends React.Component {
onCancel = () => {};
}
// Generate id for the DraftArea.
const id = parentId
? `comment-draft_${parentId}`
: `comment-draft_${assetId}`;
return (
<div>
<CommentForm
@@ -147,7 +151,7 @@ class CommentBox extends React.Component {
maxCharCount={maxCharCount}
charCountEnable={this.props.charCountEnable}
bodyPlaceholder={t('comment.comment')}
bodyInputId={isReply ? 'replyText' : 'commentText'}
bodyInputId={id}
body={this.state.body}
buttonContainerStart={
<Slot
@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import DraftArea from '../components/DraftArea';
const STORAGE_PATH = 'DraftArea';
/**
* An enhanced textarea to make comment drafts.
*/
export default class DraftAreaContainer extends React.Component {
constructor(props, context) {
super(props, context);
this.initValue();
}
async initValue() {
const value = await this.context.pymSessionStorage.getItem(this.getPath());
if (value && this.props.onChange) {
this.props.onChange(value);
}
}
getPath = () => {
return `${STORAGE_PATH}_${this.props.id}`;
};
onChange = e => {
this.props.onChange && this.props.onChange(e.target.value);
};
componentWillReceiveProps(nextProps) {
if (this.props.value !== nextProps.value) {
if (nextProps.value) {
this.context.pymSessionStorage.setItem(this.getPath(), nextProps.value);
} else {
this.context.pymSessionStorage.removeItem(this.getPath());
}
}
}
render() {
return (
<DraftArea
value={this.props.value}
placeholder={this.props.placeholder}
id={this.props.id}
onChange={this.onChange}
rows={this.props.rows}
disabled={this.props.disabled}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
label={this.props.label}
/>
);
}
}
DraftAreaContainer.contextTypes = {
// We use pymSessionStorage instead to persist the data directly on the parent page,
// in order to mitigate strict cross domain security settings.
pymSessionStorage: PropTypes.object,
};
DraftAreaContainer.propTypes = {
charCountEnable: PropTypes.bool,
maxCharCount: PropTypes.number,
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
placeholder: PropTypes.string,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
rows: PropTypes.number,
label: PropTypes.string.isRequired,
};
+13 -2
View File
@@ -146,8 +146,19 @@ export default class Stream {
// If the user clicks outside the embed, then tell the embed.
document.addEventListener('click', this.handleClick.bind(this), true);
// Listens to storage requests on pym and relay it to local storage.
connectStorageToPym(createStorage(), this.pym);
// Listens to local storage requests on pym and relay it to local storage.
connectStorageToPym(
createStorage('localStorage'),
this.pym,
'localStorage'
);
// Listens to session storage requests on pym and relay it to session storage.
connectStorageToPym(
createStorage('sessionStorage'),
this.pym,
'sessionStorage'
);
}
login(token) {
@@ -11,9 +11,12 @@ class TalkProvider extends React.Component {
rest: this.props.rest,
graphql: this.props.graphql,
notification: this.props.notification,
storage: this.props.storage,
localStorage: this.props.localStorage,
sessionStorage: this.props.sessionStorage,
history: this.props.history,
store: this.props.store,
pymLocalStorage: this.props.pymLocalStorage,
pymSessionStorage: this.props.pymSessionStorage,
};
}
@@ -30,9 +33,14 @@ TalkProvider.childContextTypes = {
rest: PropTypes.func,
graphql: PropTypes.object,
notification: PropTypes.object,
storage: PropTypes.object,
localStorage: PropTypes.object,
sessionStorage: PropTypes.object,
pymLocalStorage: PropTypes.object,
pymSessionStorage: PropTypes.object,
history: PropTypes.object,
store: PropTypes.object,
};
TalkProvider.propTypes = TalkProvider.childContextTypes;
export default TalkProvider;
+23 -5
View File
@@ -43,6 +43,14 @@ const getAuthToken = (store, storage) => {
return null;
};
function areWeInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
/**
* createContext setups and returns Talk dependencies that should be
* passed to `TalkProvider`.
@@ -63,9 +71,16 @@ export async function createContext({
preInit,
init = noop,
} = {}) {
const inIframe = areWeInIframe();
const eventEmitter = new EventEmitter({ wildcard: true });
const storage = createStorage();
const pymStorage = createPymStorage(pym);
const localStorage = createStorage('localStorage');
const sessionStorage = createStorage('sessionStorage');
const pymLocalStorage = inIframe
? createPymStorage(pym, 'localStorage')
: localStorage;
const pymSessionStorage = inIframe
? createPymStorage(pym, 'sessionStorage')
: sessionStorage;
const history = createHistory(BASE_PATH);
const introspection = createIntrospection(introspectionData);
let store = null;
@@ -75,7 +90,7 @@ export async function createContext({
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return getAuthToken(store, storage);
return getAuthToken(store, localStorage);
};
const rest = createRestClient({
@@ -116,10 +131,13 @@ export async function createContext({
rest,
graphql,
notification,
storage,
localStorage,
sessionStorage,
history,
introspection,
pymStorage,
pymLocalStorage,
pymSessionStorage,
inIframe,
};
// Load framework fragments.
+1 -1
View File
@@ -68,7 +68,7 @@ function getLocale(storage) {
export function setupTranslations() {
// Setup the translation framework with the storage.
const storage = createStorage();
const storage = createStorage('localStorage');
const locale = getLocale(storage);
setLocale(storage, locale);
+21 -10
View File
@@ -34,8 +34,8 @@ function getStorage(type) {
* createStorage returns a localStorage wrapper if available
* @return {Object} localStorage wrapper
*/
export function createStorage() {
return getStorage('localStorage');
export function createStorage(type = 'localStorage') {
return getStorage(type);
}
/**
@@ -44,7 +44,7 @@ export function createStorage() {
* @param {string} pym pym
* @return {Object} storage
*/
export function createPymStorage(pym) {
export function createPymStorage(pym, type = 'localStorage') {
// A Map of requestID => {resolve, reject}
const requests = {};
@@ -54,21 +54,21 @@ export function createPymStorage(pym) {
return new Promise((resolve, reject) => {
requests[id] = { resolve, reject };
pym.sendMessage(
'pymStorage.request',
`pymStorage.${type}.request`,
JSON.stringify({ id, method, parameters })
);
});
};
// Receive successful responses.
pym.onMessage('pymStorage.response', msg => {
pym.onMessage(`pymStorage.${type}.response`, msg => {
const { id, result } = JSON.parse(msg);
requests[id].resolve(result);
delete requests[id];
});
// Receive error responses.
pym.onMessage('pymStorage.error', msg => {
pym.onMessage(`pymStorage.${type}.error`, msg => {
const { id, error } = JSON.parse(msg);
requests[id].reject(error);
delete requests[id];
@@ -88,8 +88,13 @@ export function createPymStorage(pym) {
* @param {Object} pym pym to listen to storage requests
* @param {string} prefix namespace requests by prepending a prefix to the keys
*/
export function connectStorageToPym(storage, pym, prefix = 'talkPymStorage:') {
pym.onMessage('pymStorage.request', msg => {
export function connectStorageToPym(
storage,
pym,
type = 'localStorage',
prefix = 'talkPymStorage:'
) {
pym.onMessage(`pymStorage.${type}.request`, msg => {
const { id, method, parameters } = JSON.parse(msg);
const { key, value } = parameters;
const prefixedKey = `${prefix}${key}`;
@@ -99,7 +104,10 @@ export function connectStorageToPym(storage, pym, prefix = 'talkPymStorage:') {
const sendError = error => {
console.error(error);
pym.sendMessage('pymStorage.error', JSON.stringify({ id, error }));
pym.sendMessage(
`pymStorage.${type}.error`,
JSON.stringify({ id, error })
);
};
try {
@@ -122,6 +130,9 @@ export function connectStorageToPym(storage, pym, prefix = 'talkPymStorage:') {
return;
}
pym.sendMessage('pymStorage.response', JSON.stringify({ id, result }));
pym.sendMessage(
`pymStorage.${type}.response`,
JSON.stringify({ id, result })
);
});
}
@@ -7,7 +7,7 @@ import {
const STORAGE_PATH = 'talkPluginRememberSort';
export default {
init: async ({ store, pymStorage, introspection }) => {
init: async ({ store, pymLocalStorage, introspection }) => {
// TODO: workaround as this plugin is included in any target and
// embeds (e.g. admin), but should only be included inside the stream.
@@ -16,10 +16,10 @@ export default {
return;
}
// We use pymStorage instead to persist the data directly on the parent page,
// We use pymLocalStorage instead to persist the data directly on the parent page,
// in order to mitigate strict cross domain security settings.
let sort = JSON.parse(await pymStorage.getItem(STORAGE_PATH));
let sort = JSON.parse(await pymLocalStorage.getItem(STORAGE_PATH));
if (
sort &&
introspection.isValidEnumValue('SORT_ORDER', sort.sortOrder) &&
@@ -35,7 +35,7 @@ export default {
// Save sorting choice to storage if it has changed.
if (!sort || sort.sortOrder !== sortOrder || sort.sortBy !== sortBy) {
sort = { sortOrder, sortBy };
pymStorage.setItem(STORAGE_PATH, JSON.stringify(sort));
pymLocalStorage.setItem(STORAGE_PATH, JSON.stringify(sort));
}
});
},
+1 -1
View File
@@ -97,7 +97,7 @@ module.exports = {
elements: {
logoutButton: '.talk-stream-userbox-logout',
signInButton: '#coralSignInButton',
commentBoxTextarea: '#commentText',
commentBoxTextarea: '.talk-plugin-commentbox-textarea',
commentBoxPostButton: '.talk-plugin-commentbox-button',
firstComment: '.talk-stream-comment.talk-stream-comment-level-0',
firstCommentContent: