Merge pull request #93 from lbryio/notifications

Notifications v2 (part 1)
This commit is contained in:
Sean Yesmunt 2018-11-20 11:01:36 -05:00 committed by GitHub
commit df8e8c6b44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1038 additions and 774 deletions

1366
dist/bundle.js vendored

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,8 @@
},
"dependencies": {
"proxy-polyfill": "0.1.6",
"reselect": "^3.0.0"
"reselect": "^3.0.0",
"uuid": "^3.3.2"
},
"devDependencies": {
"babel-core": "^6.26.0",

View file

@ -211,4 +211,9 @@ export const DO_PREPARE_EDIT = 'DO_PREPARE_EDIT';
// Notifications
export const CREATE_NOTIFICATION = 'CREATE_NOTIFICATION';
export const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION';
export const EDIT_NOTIFICATION = 'EDIT_NOTIFICATION';
export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION';
export const CREATE_TOAST = 'CREATE_TOAST';
export const DISMISS_TOAST = 'DISMISS_TOAST';
export const CREATE_ERROR = 'CREATE_ERROR';
export const DISMISS_ERROR = 'DISMISS_ERROR';

View file

@ -1,30 +0,0 @@
export const CONFIRM_FILE_REMOVE = 'confirm_file_remove';
export const CONFIRM_EXTERNAL_LINK = 'confirm_external_link';
export const INCOMPATIBLE_DAEMON = 'incompatible_daemon';
export const FILE_TIMEOUT = 'file_timeout';
export const DOWNLOADING = 'downloading';
export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded';
export const AUTO_UPDATE_CONFIRM = 'auto_update_confirm';
export const ERROR = 'error';
export const INSUFFICIENT_CREDITS = 'insufficient_credits';
export const UPGRADE = 'upgrade';
export const WELCOME = 'welcome';
export const EMAIL_COLLECTION = 'email_collection';
export const PHONE_COLLECTION = 'phone_collection';
export const FIRST_REWARD = 'first_reward';
export const AUTHENTICATION_FAILURE = 'auth_failure';
export const TRANSACTION_FAILED = 'transaction_failed';
export const REWARD_APPROVAL_REQUIRED = 'reward_approval_required';
export const REWARD_GENERATED_CODE = 'reward_generated_code';
export const AFFIRM_PURCHASE = 'affirm_purchase';
export const CONFIRM_CLAIM_REVOKE = 'confirm_claim_revoke';
export const FIRST_SUBSCRIPTION = 'firstSubscription';
export const SEND_TIP = 'send_tip';
export const SOCIAL_SHARE = 'social_share';
export const PUBLISH = 'publish';
export const SEARCH = 'search';
export const CONFIRM_TRANSACTION = 'confirm_transaction';
export const CONFIRM_THUMBNAIL_UPLOAD = 'confirm_thumbnail_upload';
export const WALLET_ENCRYPT = 'wallet_encrypt';
export const WALLET_DECRYPT = 'wallet_decrypt';
export const WALLET_UNLOCK = 'wallet_unlock';

View file

@ -1,5 +1,4 @@
import * as ACTIONS from 'constants/action_types';
import * as MODALS from 'constants/modal_types';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import * as SEARCH_TYPES from 'constants/search';
import * as SETTINGS from 'constants/settings';
@ -12,19 +11,10 @@ import Lbryapi from 'lbryapi';
import { selectState as selectSearchState } from 'redux/selectors/search';
// types
export { Notification } from 'types/Notification';
export { Toast } from 'types/Notification';
// constants
export {
ACTIONS,
MODALS,
THUMBNAIL_STATUSES,
SEARCH_TYPES,
SETTINGS,
TRANSACTIONS,
SORT_OPTIONS,
PAGES,
};
export { ACTIONS, THUMBNAIL_STATUSES, SEARCH_TYPES, SETTINGS, TRANSACTIONS, SORT_OPTIONS, PAGES };
// common
export { Lbry, Lbryapi };
@ -41,7 +31,7 @@ export {
} from 'lbryURI';
// actions
export { doNotify, doHideNotification } from 'redux/actions/notifications';
export { doToast, doDismissToast, doError, doDismissError } from 'redux/actions/notifications';
export {
doFetchClaimsByChannel,
@ -108,11 +98,7 @@ export { blacklistReducer } from 'redux/reducers/blacklist';
// selectors
export { selectBlackListedOutpoints } from 'redux/selectors/blacklist';
export {
selectNotification,
selectNotificationProps,
selectSnack,
} from 'redux/selectors/notifications';
export { selectToast, selectError } from 'redux/selectors/notifications';
export {
makeSelectClaimForUri,

View file

@ -2,7 +2,7 @@ import * as ACTIONS from 'constants/action_types';
import Lbry from 'lbry';
import Lbryapi from 'lbryapi';
import { normalizeURI } from 'lbryURI';
import { doNotify } from 'redux/actions/notifications';
import { doToast } from 'redux/actions/notifications';
import { selectMyClaimsRaw, selectResolvingUris } from 'redux/selectors/claims';
import { batchActions } from 'util/batchActions';
import { doFetchTransactions } from 'redux/actions/wallet';
@ -86,11 +86,9 @@ export function doAbandonClaim(txid, nout) {
const errorCallback = () => {
dispatch(
doNotify({
title: 'Transaction failed',
message: 'Error abandoning claim',
type: 'error',
displayType: ['snackbar', 'toast'],
doToast({
message: 'Transaction failed',
isError: true,
})
);
};
@ -104,10 +102,8 @@ export function doAbandonClaim(txid, nout) {
},
});
dispatch(
doNotify({
title: 'Transaction successful',
doToast({
message: 'Successfully abandoned your claim',
displayType: ['snackbar', 'toast'],
})
);
@ -117,11 +113,9 @@ export function doAbandonClaim(txid, nout) {
dispatch(doFetchTransactions());
} else {
dispatch(
doNotify({
title: 'Transaction failed',
doToast({
message: 'Error abandoning claim',
type: 'error',
displayType: ['snackbar', 'toast'],
isError: true,
})
);
}

View file

@ -1,20 +1,39 @@
// @flow
import type { ToastParams } from 'types/Notification';
import * as ACTIONS from 'constants/action_types';
import type { Notification, NotificationProps } from 'types/Notification';
import uuid from 'uuid/v4';
export function doToast(params: ToastParams) {
if (!params) {
throw Error("'params' object is required to create a toast notification");
}
export function doNotify(notification: Notification, notificationProps: NotificationProps) {
return {
type: ACTIONS.CREATE_NOTIFICATION,
type: ACTIONS.CREATE_TOAST,
data: {
notification,
// using this syntax to create an object if notificationProps is undefined
notificationProps: { ...notificationProps },
id: uuid(),
params,
},
};
}
export function doHideNotification() {
export function doDismissToast() {
return {
type: ACTIONS.DISMISS_NOTIFICATION,
type: ACTIONS.DISMISS_TOAST,
};
}
export function doError(error: string | {}) {
return {
type: ACTIONS.CREATE_ERROR,
data: {
error,
},
};
}
export function doDismissError() {
return {
type: ACTIONS.DISMISS_ERROR,
};
}

View file

@ -1,6 +1,6 @@
import * as ACTIONS from 'constants/action_types';
import Lbry from 'lbry';
import { doNotify } from 'redux/actions/notifications';
import { doToast } from 'redux/actions/notifications';
import { selectBalance } from 'redux/selectors/wallet';
import { creditsToString } from 'util/formatCredits';
@ -96,11 +96,9 @@ export function doSendDraftTransaction(address, amount) {
if (balance - amount <= 0) {
dispatch(
doNotify({
doToast({
title: 'Insufficient credits',
message: 'Insufficient credits',
type: 'error',
displayType: ['modal', 'toast'],
})
);
return;
@ -116,11 +114,8 @@ export function doSendDraftTransaction(address, amount) {
type: ACTIONS.SEND_TRANSACTION_COMPLETED,
});
dispatch(
doNotify({
title: 'Credits sent',
doToast({
message: `You sent ${amount} LBC`,
type: 'error',
displayType: ['snackbar', 'toast'],
linkText: 'History',
linkTarget: '/wallet',
})
@ -131,11 +126,9 @@ export function doSendDraftTransaction(address, amount) {
data: { error: response },
});
dispatch(
doNotify({
title: 'Transaction failed',
doToast({
message: 'Transaction failed',
type: 'error',
displayType: ['snackbar', 'toast'],
isError: true,
})
);
}
@ -147,11 +140,9 @@ export function doSendDraftTransaction(address, amount) {
data: { error: error.message },
});
dispatch(
doNotify({
title: 'Transaction failed',
doToast({
message: 'Transaction failed',
type: 'error',
displayType: ['snackbar', 'toast'],
isError: true,
})
);
};
@ -184,11 +175,9 @@ export function doSendTip(amount, claimId, uri, successCallback, errorCallback)
if (balance - amount <= 0) {
dispatch(
doNotify({
title: 'Insufficient credits',
doToast({
message: 'Insufficient credits',
type: 'error',
displayType: ['modal', 'toast'],
isError: true,
})
);
return;
@ -196,11 +185,10 @@ export function doSendTip(amount, claimId, uri, successCallback, errorCallback)
const success = () => {
dispatch(
doNotify({
doToast({
message: __(`You sent ${amount} LBC as a tip, Mahalo!`),
linkText: __('History'),
linkTarget: __('/wallet'),
displayType: ['snackbar'],
})
);
@ -215,9 +203,9 @@ export function doSendTip(amount, claimId, uri, successCallback, errorCallback)
const error = (err) => {
dispatch(
doNotify({
doToast({
message: __(`There was an error sending support funds.`),
displayType: ['snackbar'],
isError: true,
})
);

View file

@ -1,55 +1,102 @@
// @flow
import type {
NotificationState,
DoToast,
DoNotification,
DoEditNotification,
DoDeleteNotification,
} from 'types/Notification';
import * as ACTIONS from 'constants/action_types';
import * as MODALS from 'constants/modal_types';
import { handleActions } from 'util/redux-utils';
const reducers = {};
const defaultState = {
// First-in, first-out
queue: [],
const defaultState: NotificationState = {
notifications: [],
toasts: [],
errors: [],
};
reducers[ACTIONS.CREATE_NOTIFICATION] = (state, action) => {
const { notification, notificationProps } = action.data;
const { title, message, type, error, displayType, id } = notification;
const notificationsReducer = handleActions(
{
// Toasts
[ACTIONS.CREATE_TOAST]: (state: NotificationState, action: DoToast) => {
const toast = action.data;
const newToasts = state.toasts.slice();
newToasts.push(toast);
const queue = Object.assign([], state.queue);
queue.push({
notification: {
id,
title,
message,
type,
error,
displayType,
return {
...state,
toasts: newToasts,
};
},
notificationProps,
});
[ACTIONS.DISMISS_TOAST]: (state: NotificationState) => {
const newToasts = state.toasts.slice();
newToasts.shift();
return Object.assign({}, state, {
queue,
});
};
return {
...state,
toasts: newToasts,
};
},
reducers[ACTIONS.DISMISS_NOTIFICATION] = state => {
const queue = Object.assign([], state.queue);
queue.shift();
// Notifications
[ACTIONS.CREATE_NOTIFICATION]: (state: NotificationState, action: DoNotification) => {
const notification = action.data;
const newNotifications = state.notifications.slice();
newNotifications.push(notification);
return Object.assign({}, state, {
queue,
});
};
return {
...state,
notifications: newNotifications,
};
},
// Used to mark notifications as read/dismissed
[ACTIONS.EDIT_NOTIFICATION]: (state: NotificationState, action: DoEditNotification) => {
const { notification } = action.data;
let notifications = state.notifications.slice();
reducers[ACTIONS.HISTORY_NAVIGATE] = state => {
const queue = Object.assign([], state.queue);
if (queue[0] && queue[0].notification.id === MODALS.SEARCH) {
queue.shift();
return Object.assign({}, state, { queue });
}
return state;
};
notifications = notifications.map(
(pastNotification) =>
pastNotification.id === notification.id ? notification : pastNotification
);
export function notificationsReducer(state = defaultState, action) {
const handler = reducers[action.type];
if (handler) return handler(state, action);
return state;
}
return {
...state,
notifications,
};
},
[ACTIONS.DELETE_NOTIFICATION]: (state: NotificationState, action: DoDeleteNotification) => {
const { id } = action.data;
let newNotifications = state.notifications.slice();
newNotifications = newNotifications.filter((notification) => notification.id !== id);
return {
...state,
notifications: newNotifications,
};
},
// Errors
[ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoToast) => {
const error = action.data;
const newErrors = state.errors.slice();
newErrors.push(error);
return {
...state,
errors: newErrors,
};
},
[ACTIONS.DISMISS_ERROR]: (state: NotificationState) => {
const newErrors = state.errors.slice();
newErrors.shift();
return {
...state,
errors: newErrors,
};
},
},
defaultState
);
export { notificationsReducer };

View file

@ -1,30 +1,26 @@
import { createSelector } from 'reselect';
export const selectState = state => state.notifications || {};
export const selectState = (state) => state.notifications || {};
export const selectNotificationData = createSelector(
selectState,
state => (state.queue.length > 0 ? state.queue[0] : {})
);
export const selectNotification = createSelector(
selectNotificationData,
notificationData => notificationData.notification
);
export const selectNotificationProps = createSelector(
selectNotificationData,
notificationData => notificationData.notificationProps
);
export const selectSnack = createSelector(
// No props for snackbar
selectNotification,
notification => {
if (notification && notification.displayType) {
return notification.displayType.indexOf('snackbar') > -1 ? notification : undefined;
}
return undefined;
export const selectToast = createSelector(selectState, (state) => {
if (state.toasts.length) {
const { id, params } = state.toasts[0];
return {
id,
...params,
};
}
);
return null;
});
export const selectError = createSelector(selectState, (state) => {
if (state.errors.length) {
const { error } = state.errors[0];
return {
error,
};
}
return null;
});

View file

@ -1,19 +1,97 @@
// @flow
export type Notification = {
id: ?string,
title: ?string,
import * as ACTIONS from 'constants/action_types';
/*
Toasts:
- First-in, first-out queue
- Simple messages that are shown in response to user interactions
- Never saved
- If they are the result of errors, use the isError flag when creating
- For errors that should interrupt user behavior, use Error
*/
export type ToastParams = {
message: string,
type: string,
error: ?string,
displayType: mixed,
// additional properties for SnackBar
linkText: ?string,
linkTarget: ?string,
title?: string,
linkText?: string,
linkTarget?: string,
isError?: boolean,
};
// Used for retreiving data from redux store
export type NotificationProps = {
uri: ?string,
path: ?string,
export type Toast = {
id: string,
params: ToastParams,
};
export type DoToast = {
type: ACTIONS.CREATE_TOAST,
data: Toast,
};
/*
Notifications:
- List of notifications based on user interactions/app notifications
- Always saved, but can be manually deleted
- Can happen in the background, or because of user interaction (ex: publish confirmed)
*/
export type Notification = {
id: string, // Unique id
dateCreated: number,
isRead: boolean, // Used to display "new" notifications that a user hasn't seen yet
source?: string, // The type/area an notification is from. Used for sorting (ex: publishes, transactions)
// We may want to use priority/isDismissed in the future to specify how urgent a notification is
// and if the user should see it immediately
// isDissmied: boolean,
// priority?: number
};
export type DoNotification = {
type: ACTIONS.CREATE_NOTIFICATION,
data: Notification,
};
export type DoEditNotification = {
type: ACTIONS.EDIT_NOTIFICATION,
data: {
id: string,
isRead: boolean,
// In the future we can add `isDismissed` if we decide to show notifications as they come in
// Similar to Facebook's notifications in the corner of the screen
// isDismissed: boolean,
},
};
export type DoDeleteNotification = {
type: ACTIONS.DELETE_NOTIFICATION,
data: {
id: string, // The id to delete
},
};
/*
Errors:
- First-in, first-out queue
- Errors that should interupt user behavior
- For errors that can be shown without interrupting a user, use Toast with the isError flag
*/
export type Error = {
title: string,
text: string,
};
export type DoError = {
type: ACTIONS.CREATE_ERROR,
data: Error,
};
export type DoDismissError = {
type: ACTIONS.DISMISS_ERROR,
};
/*
NotificationState
*/
export type NotificationState = {
notifications: Array<Notification>,
errors: Array<Error>,
toasts: Array<Toast>,
};

View file

@ -5977,6 +5977,10 @@ uuid@^3.1.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14"
uuid@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
v8-compile-cache@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-1.1.2.tgz#8d32e4f16974654657e676e0e467a348e89b0dc4"