add comments behind a flag

This commit is contained in:
Sean Yesmunt 2020-07-23 10:22:57 -04:00
parent 631d7495c7
commit 90327a72ed
39 changed files with 559 additions and 76 deletions

View file

@ -8,6 +8,10 @@
node_modules/lbry-redux/flow-typed/ node_modules/lbry-redux/flow-typed/
node_modules/lbryinc/flow-typed/ node_modules/lbryinc/flow-typed/
[untyped]
.*/node_modules/lbry-redux
.*/node_modules/lbryinc
[lints] [lints]
[options] [options]

View file

@ -1,6 +1,6 @@
// On Web, this will find .env.defaults and optional .env in web/ // On Web, this will find .env.defaults and optional .env in web/
// On Desktop App, this will find .env.defaults and optional .env in root dir // On Desktop App, this will find .env.defaults and optional .env in root dir
require('dotenv-defaults').config({silent: false}); require('dotenv-defaults').config({ silent: false });
const config = { const config = {
MATOMO_URL: process.env.MATOMO_URL, MATOMO_URL: process.env.MATOMO_URL,
MATOMO_ID: process.env.MATOMO_ID, MATOMO_ID: process.env.MATOMO_ID,

31
flow-typed/notification.js vendored Normal file
View file

@ -0,0 +1,31 @@
// @flow
declare type WebNotification = {
active_at: string,
created_at: string,
id: number,
is_app_readable: boolean,
is_device_notified: boolean,
is_emailed: boolean,
is_read: boolean,
notification_parameters: {
device: {
analytics_label: string,
image_url: string,
is_data_only: boolean,
name: string,
placeholders: ?string,
target: string,
text: string,
title: string,
type: string,
},
dynamic: {
comment_author: string,
},
email: {},
},
notification_rule: string,
type: string,
updated_at: string,
user_id: number,
};

1
flow-typed/user.js vendored
View file

@ -27,4 +27,5 @@ declare type User = {
youtube_channels: ?Array<string>, youtube_channels: ?Array<string>,
device_types: Array<DeviceType>, device_types: Array<DeviceType>,
lbry_first_approved: boolean, lbry_first_approved: boolean,
experimental_ui: boolean,
}; };

View file

@ -149,7 +149,8 @@ const analytics: Analytics = {
} }
}, },
apiLogPublish: (claimResult: ChannelClaim | StreamClaim) => { apiLogPublish: (claimResult: ChannelClaim | StreamClaim) => {
if (internalAnalyticsEnabled && isProduction) { // Don't check if this is production so channels created on localhost are still linked to user
if (internalAnalyticsEnabled) {
const { permanent_url: uri, claim_id: claimId, txid, nout, signing_channel: signingChannel } = claimResult; const { permanent_url: uri, claim_id: claimId, txid, nout, signing_channel: signingChannel } = claimResult;
let channelClaimId; let channelClaimId;
if (signingChannel) { if (signingChannel) {

View file

@ -77,6 +77,7 @@ type Props = {
setReferrer: (string, boolean) => void, setReferrer: (string, boolean) => void,
analyticsTagSync: () => void, analyticsTagSync: () => void,
isAuthenticated: boolean, isAuthenticated: boolean,
socketConnect: () => void,
}; };
function App(props: Props) { function App(props: Props) {
@ -194,10 +195,11 @@ function App(props: Props) {
if (wrapperElement) { if (wrapperElement) {
ReactModal.setAppElement(wrapperElement); ReactModal.setAppElement(wrapperElement);
} }
fetchAccessToken(); fetchAccessToken();
// @if TARGET='app' // @if TARGET='app'
fetchChannelListMine(); // This needs to be done for web too... fetchChannelListMine(); // This is fetched after a user is signed in on web
// @endif // @endif
}, [appRef, fetchAccessToken, fetchChannelListMine]); }, [appRef, fetchAccessToken, fetchChannelListMine]);

View file

@ -1,12 +1,12 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectThumbnailForUri } from 'lbry-redux'; import { makeSelectThumbnailForUri, doResolveUri, makeSelectClaimForUri } from 'lbry-redux';
import ChannelThumbnail from './view'; import ChannelThumbnail from './view';
const select = (state, props) => ({ const select = (state, props) => ({
thumbnail: makeSelectThumbnailForUri(props.uri)(state), thumbnail: makeSelectThumbnailForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
}); });
export default connect( export default connect(select, {
select, doResolveUri,
null })(ChannelThumbnail);
)(ChannelThumbnail);

View file

@ -13,6 +13,8 @@ type Props = {
obscure?: boolean, obscure?: boolean,
small?: boolean, small?: boolean,
allowGifs?: boolean, allowGifs?: boolean,
claim: ?ChannelClaim,
doResolveUri: string => void,
}; };
function ChannelThumbnail(props: Props) { function ChannelThumbnail(props: Props) {
@ -24,8 +26,11 @@ function ChannelThumbnail(props: Props) {
obscure, obscure,
small = false, small = false,
allowGifs = false, allowGifs = false,
claim,
doResolveUri,
} = props; } = props;
const [thumbError, setThumbError] = React.useState(false); const [thumbError, setThumbError] = React.useState(false);
const shouldResolve = claim === undefined;
const thumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://'); const thumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
const thumbnailPreview = rawThumbnailPreview && rawThumbnailPreview.trim().replace(/^http:\/\//i, 'https://'); const thumbnailPreview = rawThumbnailPreview && rawThumbnailPreview.trim().replace(/^http:\/\//i, 'https://');
const channelThumbnail = thumbnail || thumbnailPreview; const channelThumbnail = thumbnail || thumbnailPreview;
@ -41,6 +46,12 @@ function ChannelThumbnail(props: Props) {
colorClassName = `channel-thumbnail__default--4`; colorClassName = `channel-thumbnail__default--4`;
} }
React.useEffect(() => {
if (shouldResolve && uri) {
doResolveUri(uri);
}
}, [doResolveUri, shouldResolve, uri]);
if (channelThumbnail && channelThumbnail.endsWith('gif') && !allowGifs) { if (channelThumbnail && channelThumbnail.endsWith('gif') && !allowGifs) {
return <FreezeframeWrapper src={channelThumbnail} className="channel-thumbnail" />; return <FreezeframeWrapper src={channelThumbnail} className="channel-thumbnail" />;
} }

View file

@ -701,4 +701,10 @@ export const icons = {
<line x1="6" y1="20" x2="6" y2="14" /> <line x1="6" y1="20" x2="6" y2="14" />
</g> </g>
), ),
[ICONS.NOTIFICATION]: buildIcon(
<g>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</g>
),
}; };

View file

@ -12,6 +12,7 @@ import Icon from 'component/common/icon';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import Tooltip from 'component/common/tooltip'; import Tooltip from 'component/common/tooltip';
import NavigationButton from 'component/navigationButton'; import NavigationButton from 'component/navigationButton';
import NotificationHeaderButton from 'component/notificationHeaderButton';
import { LOGO_TITLE } from 'config'; import { LOGO_TITLE } from 'config';
import useIsMobile from 'effects/use-is-mobile'; import useIsMobile from 'effects/use-is-mobile';
// @if TARGET='app' // @if TARGET='app'
@ -239,6 +240,8 @@ const Header = (props: Props) => {
</MenuList> </MenuList>
</Menu> </Menu>
<NotificationHeaderButton />
<Menu> <Menu>
<MenuButton <MenuButton
aria-label={__('Your account')} aria-label={__('Your account')}
@ -298,6 +301,7 @@ const Header = (props: Props) => {
</Menu> </Menu>
</Fragment> </Fragment>
)} )}
<Menu> <Menu>
<MenuButton <MenuButton
aria-label={__('Settings')} aria-label={__('Settings')}

View file

@ -0,0 +1,4 @@
import { connect } from 'react-redux';
import Notification from './view';
export default connect()(Notification);

View file

@ -0,0 +1,64 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Icon from 'component/common/icon';
import DateTime from 'component/dateTime';
import ChannelThumbnail from 'component/channelThumbnail';
import { MenuItem } from '@reach/menu-button';
import { formatLbryUrlForWeb } from 'util/url';
import { useHistory } from 'react-router';
type Props = {
notification: WebNotification,
menuButton: boolean,
children: any,
};
const NOTIFICATION_CREATOR_SUBSCRIBER = 'creator_subscriber';
const NOTIFICATION_COMMENT = 'comment';
export default function Notification(props: Props) {
const { notification, menuButton = false } = props;
const notificationTarget = notification && notification.notification_parameters.device.target;
const notificationLink = formatLbryUrlForWeb(notificationTarget);
const { push } = useHistory();
let icon;
switch (notification.notification_rule) {
case NOTIFICATION_CREATOR_SUBSCRIBER:
icon = <Icon icon={ICONS.SUBSCRIBE} sectionIcon className="notification__icon" />;
break;
case NOTIFICATION_COMMENT:
icon = <ChannelThumbnail uri={notification.notification_parameters.dynamic.comment_author} />;
break;
default:
icon = <Icon icon={ICONS.NOTIFICATION} sectionIcon className="notification__icon" />;
}
const Wrapper = menuButton
? (props: { children: any }) => (
<MenuItem className="menu__link--notification" onSelect={() => push(notificationLink)}>
{props.children}
</MenuItem>
)
: (props: { children: any }) => (
<a className="menu__link--notification" onClick={() => push(notificationLink)}>
{props.children}
</a>
);
return (
<Wrapper>
<div className="notification__wrapper">
<div className="notification__icon">{icon}</div>
<div className="notification__content">
<div className="notification__title">{notification.notification_parameters.device.title}</div>
<div className="notification__text">{notification.notification_parameters.device.text}</div>
<div className="notification__time">
<DateTime timeAgo date={notification.created_at} />
</div>
</div>
</div>
</Wrapper>
);
}

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import {
selectNotifications,
selectIsFetchingNotifications,
selectUnreadNotificationCount,
} from 'redux/selectors/notifications';
import { doReadNotifications } from 'redux/actions/notifications';
import { selectUser } from 'redux/selectors/user';
import NotificationHeaderButton from './view';
const select = state => ({
notifications: selectNotifications(state),
fetching: selectIsFetchingNotifications(state),
unreadCount: selectUnreadNotificationCount(state),
user: selectUser(state),
});
export default connect(select, {
doReadNotifications,
})(NotificationHeaderButton);

View file

@ -0,0 +1,91 @@
// @flow
import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons';
import React from 'react';
import Icon from 'component/common/icon';
import Notification from 'component/notification';
import Button from 'component/button';
import { useHistory } from 'react-router';
// import { Menu, MenuList, MenuButton, MenuPopover, MenuItems, MenuItem } from '@reach/menu-button';
type Props = {
unreadCount: number,
fetching: boolean,
notifications: ?Array<Notification>,
doReadNotifications: () => void,
user: ?User,
};
export default function NotificationHeaderButton(props: Props) {
const {
unreadCount,
// notifications,
fetching,
doReadNotifications,
user,
} = props;
const notificationsEnabled = user && user.experimental_ui;
const { push } = useHistory();
function handleMenuClick() {
if (unreadCount > 0) {
doReadNotifications();
}
push(`/$/${PAGES.NOTIFICATIONS}`);
}
if (!notificationsEnabled) {
return null;
}
return (
<Button
onClick={handleMenuClick}
disabled={fetching}
aria-label={__('Notifications')}
title={__('Notifications')}
className="header__navigation-item menu__title header__navigation-item--icon"
>
<Icon size={18} icon={ICONS.NOTIFICATION} aria-hidden />
{unreadCount > 0 && <span className="notification__bubble">{unreadCount}</span>}
</Button>
);
// Below is disabled until scroll style issues are resolved
// return (
// <Menu>
// <MenuButton
// onClick={handleMenuClick}
// disabled={fetching}
// aria-label={__('Notifications')}
// title={__('Notifications')}
// className="header__navigation-item menu__title header__navigation-item--icon"
// >
// <Icon size={18} icon={ICONS.NOTIFICATION} aria-hidden />
// {unreadCount > 0 && <span className="notification__bubble">{unreadCount}</span>}
// </MenuButton>
// {notifications && notifications.length > 0 ? (
// <MenuList className="menu__list--header">
// {notifications.slice(0, 7).map((notification, index) => (
// <Notification menuButton key={notification.id} id={notification.id} notification={notification} />
// ))}
// <MenuItem className="menu__link" onSelect={() => push(`/$/${PAGES.NOTIFICATIONS}`)}>
// <Icon aria-hidden icon={ICONS.NOTIFICATION} />
// {__('View All')}
// </MenuItem>
// </MenuList>
// ) : (
// <MenuPopover>
// <div className="menu__list--header notifications__empty">No notifications yet.</div>
// {/* Below is needed because MenuPopover isn't meant to be used this way */}
// <MenuItems>
// <MenuItem disabled onSelect={() => {}} />
// </MenuItems>
// </MenuPopover>
// )}
// </Menu>
// );
}

View file

@ -43,6 +43,7 @@ import RewardsVerifyPage from 'page/rewardsVerify';
import CheckoutPage from 'page/checkoutPage'; import CheckoutPage from 'page/checkoutPage';
import ChannelNew from 'page/channelNew'; import ChannelNew from 'page/channelNew';
import BuyPage from 'page/buy'; import BuyPage from 'page/buy';
import NotificationsPage from 'page/notifications';
import { parseURI } from 'lbry-redux'; import { parseURI } from 'lbry-redux';
import { SITE_TITLE, WELCOME_VERSION } from 'config'; import { SITE_TITLE, WELCOME_VERSION } from 'config';
@ -190,6 +191,12 @@ function AppRouter(props: Props) {
<Route path={`/$/${PAGES.INVITE}/:referrer`} exact component={InvitedPage} /> <Route path={`/$/${PAGES.INVITE}/:referrer`} exact component={InvitedPage} />
<Route path={`/$/${PAGES.CHECKOUT}`} exact component={CheckoutPage} /> <Route path={`/$/${PAGES.CHECKOUT}`} exact component={CheckoutPage} />
<PrivateRoute
{...props}
path={`/$/${PAGES.SETTINGS_NOTIFICATIONS}`}
exact
component={SettingsNotificationsPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} /> <PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} /> <PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} />
<PrivateRoute {...props} path={`/$/${PAGES.PUBLISHED}`} component={FileListPublished} /> <PrivateRoute {...props} path={`/$/${PAGES.PUBLISHED}`} component={FileListPublished} />
@ -204,6 +211,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} /> <PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.BUY}`} component={BuyPage} /> <PrivateRoute {...props} path={`/$/${PAGES.BUY}`} component={BuyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} /> <Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} /> <Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />

View file

@ -216,15 +216,17 @@ export const CLEAR_PUBLISH_ERROR = 'CLEAR_PUBLISH_ERROR';
export const REMOVE_PENDING_PUBLISH = 'REMOVE_PENDING_PUBLISH'; export const REMOVE_PENDING_PUBLISH = 'REMOVE_PENDING_PUBLISH';
export const DO_PREPARE_EDIT = 'DO_PREPARE_EDIT'; export const DO_PREPARE_EDIT = 'DO_PREPARE_EDIT';
// media // Media
export const MEDIA_PLAY = 'MEDIA_PLAY'; export const MEDIA_PLAY = 'MEDIA_PLAY';
export const MEDIA_PAUSE = 'MEDIA_PAUSE'; export const MEDIA_PAUSE = 'MEDIA_PAUSE';
// Notifications // Notifications
export const CREATE_NOTIFICATION = 'CREATE_NOTIFICATION'; export const NOTIFICATION_LIST_STARTED = 'NOTIFICATION_LIST_STARTED';
export const EDIT_NOTIFICATION = 'EDIT_NOTIFICATION'; export const NOTIFICATION_LIST_COMPLETED = 'NOTIFICATION_LIST_COMPLETED';
export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION'; export const NOTIFICATION_LIST_FAILED = 'NOTIFICATION_LIST_FAILED';
export const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION'; export const NOTIFICATION_READ_STARTED = 'NOTIFICATION_READ_STARTED';
export const NOTIFICATION_READ_COMPLETED = 'NOTIFICATION_READ_COMPLETED';
export const NOTIFICATION_READ_FAILED = 'NOTIFICATION_READ_FAILED';
export const CREATE_TOAST = 'CREATE_TOAST'; export const CREATE_TOAST = 'CREATE_TOAST';
export const DISMISS_TOAST = 'DISMISS_TOAST'; export const DISMISS_TOAST = 'DISMISS_TOAST';
export const CREATE_ERROR = 'CREATE_ERROR'; export const CREATE_ERROR = 'CREATE_ERROR';
@ -249,3 +251,7 @@ export const COMMENT_HIDE_FAILED = 'COMMENT_HIDE_FAILED';
// Blocked Channels // Blocked Channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL'; export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
// Notifications
export const WS_CONNECT = 'WS_CONNECT';
export const WS_DISCONNECT = 'WS_DISCONNECT';

View file

@ -110,3 +110,4 @@ export const CAMERA = 'Camera';
export const OPEN_LOG = 'FilePlus'; export const OPEN_LOG = 'FilePlus';
export const OPEN_LOG_FOLDER = 'Folder'; export const OPEN_LOG_FOLDER = 'Folder';
export const LBRY_STATUS = 'BarChart'; export const LBRY_STATUS = 'BarChart';
export const NOTIFICATION = 'Bell';

View file

@ -40,4 +40,5 @@ exports.CREATOR_DASHBOARD = 'dashboard';
exports.CHECKOUT = 'checkout'; exports.CHECKOUT = 'checkout';
exports.CODE_2257 = '2257'; exports.CODE_2257 = '2257';
exports.BUY = 'buy'; exports.BUY = 'buy';
exports.CHANNEL_NEW = 'channelnew'; exports.CHANNEL_NEW = 'channel/new';
exports.NOTIFICATIONS = 'notifications';

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app'; import { doHideModal } from 'redux/actions/app';
import { doAbandonTxo, doAbandonClaim, selectTransactionItems } from 'lbry-redux'; import { doAbandonTxo, doAbandonClaim, selectTransactionItems } from 'lbry-redux';
import { doToast } from 'redux/actions/notifications';
import ModalRevokeClaim from './view'; import ModalRevokeClaim from './view';
const select = state => ({ const select = state => ({
@ -8,6 +9,7 @@ const select = state => ({
}); });
const perform = dispatch => ({ const perform = dispatch => ({
toast: (message, isError) => dispatch(doToast({ message, isError })),
closeModal: () => dispatch(doHideModal()), closeModal: () => dispatch(doHideModal()),
abandonTxo: (txo, cb) => dispatch(doAbandonTxo(txo, cb)), abandonTxo: (txo, cb) => dispatch(doAbandonTxo(txo, cb)),
abandonClaim: (txid, nout, cb) => dispatch(doAbandonClaim(txid, nout, cb)), abandonClaim: (txid, nout, cb) => dispatch(doAbandonClaim(txid, nout, cb)),

View file

@ -6,9 +6,9 @@ import {
selectFetchingMyClaimsPageError, selectFetchingMyClaimsPageError,
doClearPublish, doClearPublish,
doFetchClaimListMine, doFetchClaimListMine,
doCheckPendingClaims,
} from 'lbry-redux'; } from 'lbry-redux';
import { selectUploadCount } from 'lbryinc'; import { selectUploadCount } from 'lbryinc';
import { doCheckPendingPublishesApp } from 'redux/actions/publish';
import FileListPublished from './view'; import FileListPublished from './view';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { MY_CLAIMS_PAGE_SIZE, PAGE_PARAM, PAGE_SIZE_PARAM } from 'constants/claim'; import { MY_CLAIMS_PAGE_SIZE, PAGE_PARAM, PAGE_SIZE_PARAM } from 'constants/claim';
@ -31,7 +31,7 @@ const select = (state, props) => {
}; };
const perform = dispatch => ({ const perform = dispatch => ({
checkPendingPublishes: () => dispatch(doCheckPendingPublishesApp()), checkPendingPublishes: () => dispatch(doCheckPendingClaims()),
fetchClaimListMine: (page, pageSize) => dispatch(doFetchClaimListMine(page, pageSize)), fetchClaimListMine: (page, pageSize) => dispatch(doFetchClaimListMine(page, pageSize)),
clearPublish: () => dispatch(doClearPublish()), clearPublish: () => dispatch(doClearPublish()),
}); });

View file

@ -6,8 +6,8 @@ import Page from 'component/page';
import Button from 'component/button'; import Button from 'component/button';
import ClaimTilesDiscover from 'component/claimTilesDiscover'; import ClaimTilesDiscover from 'component/claimTilesDiscover';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import getHomepage from 'homepage'; import getHomepage from 'homepage';
type Props = { type Props = {
authenticated: boolean, authenticated: boolean,
followedTags: Array<Tag>, followedTags: Array<Tag>,

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import {
selectNotifications,
selectIsFetchingNotifications,
selectUnreadNotificationCount,
} from 'redux/selectors/notifications';
import NotificationsPage from './view';
const select = state => ({
notifications: selectNotifications(state),
fetching: selectIsFetchingNotifications(state),
unreadCount: selectUnreadNotificationCount(state),
});
export default connect(select)(NotificationsPage);

View file

@ -0,0 +1,40 @@
// @flow
import React from 'react';
import Page from 'component/page';
import Card from 'component/common/card';
import Spinner from 'component/spinner';
import Notification from 'component/notification';
type Props = {
notifications: ?Array<Notification>,
fetching: boolean,
};
export default function NotificationsPage(props: Props) {
const { notifications, fetching } = props;
return (
<Page>
{fetching && (
<div className="main--empty">
<Spinner delayed />
</div>
)}
{notifications && notifications.length > 0 ? (
<Card
isBodyList
title={__('Notifications')}
body={
<div className="notification_list">
{notifications.map((notification, index) => {
return <Notification key={notification.id} notification={notification} />;
})}
</div>
}
/>
) : (
<div>{__('No notifications')}</div>
)}
</Page>
);
}

View file

@ -21,7 +21,7 @@ import {
selectFollowedTagsList, selectFollowedTagsList,
// SHARED_PREFERENCES, // SHARED_PREFERENCES,
} from 'lbry-redux'; } from 'lbry-redux';
import { doToast, doError } from 'redux/actions/notifications'; import { doToast, doError, doNotificationList } from 'redux/actions/notifications';
import Native from 'native'; import Native from 'native';
import { import {
doFetchDaemonSettings, doFetchDaemonSettings,
@ -49,6 +49,7 @@ import { doAuthenticate } from 'redux/actions/user';
import { lbrySettings as config, version as appVersion } from 'package.json'; import { lbrySettings as config, version as appVersion } from 'package.json';
import analytics, { SHARE_INTERNAL } from 'analytics'; import analytics, { SHARE_INTERNAL } from 'analytics';
import { doSignOutCleanup, deleteSavedPassword, getSavedPassword } from 'util/saved-passwords'; import { doSignOutCleanup, deleteSavedPassword, getSavedPassword } from 'util/saved-passwords';
import { doSocketConnect } from 'redux/actions/websocket';
// @if TARGET='app' // @if TARGET='app'
const { autoUpdater } = remote.require('electron-updater'); const { autoUpdater } = remote.require('electron-updater');
@ -489,13 +490,18 @@ export function doSignIn() {
const state = getState(); const state = getState();
const user = selectUser(state); const user = selectUser(state);
const userId = user.id; const userId = user.id;
const notificationsEnabled = user.experimental_ui;
analytics.setUser(userId); analytics.setUser(userId);
if (notificationsEnabled) {
dispatch(doSocketConnect());
dispatch(doNotificationList());
}
// @if TARGET='web' // @if TARGET='web'
dispatch(doBalanceSubscribe()); dispatch(doBalanceSubscribe());
dispatch(doFetchChannelListMine()); dispatch(doFetchChannelListMine());
// @endif // @endif
}; };
} }

View file

@ -1,6 +1,8 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { Lbryio } from 'lbryinc';
import uuid from 'uuid/v4'; import uuid from 'uuid/v4';
import { selectNotifications } from 'redux/selectors/notifications';
export function doToast(params: ToastParams) { export function doToast(params: ToastParams) {
if (!params) { if (!params) {
@ -36,3 +38,39 @@ export function doDismissError() {
type: ACTIONS.DISMISS_ERROR, type: ACTIONS.DISMISS_ERROR,
}; };
} }
export function doNotificationList() {
return (dispatch: Dispatch) => {
dispatch({ type: ACTIONS.NOTIFICATION_LIST_STARTED });
return Lbryio.call('notification', 'list')
.then(response => {
const notifications = response || [];
dispatch({ type: ACTIONS.NOTIFICATION_LIST_COMPLETED, data: { notifications } });
})
.catch(error => {
dispatch({ type: ACTIONS.NOTIFICATION_LIST_FAILED, data: { error } });
});
};
}
export function doReadNotifications() {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const notifications = selectNotifications(state);
const unreadNotifications =
notifications &&
notifications
.filter(notification => !notification.is_read)
.map(notification => notification.id)
.join(',');
dispatch({ type: ACTIONS.NOTIFICATION_READ_STARTED });
return Lbryio.call('notification', 'edit', { notification_ids: unreadNotifications, is_read: true })
.then(() => {
dispatch({ type: ACTIONS.NOTIFICATION_READ_COMPLETED });
})
.catch(error => {
dispatch({ type: ACTIONS.NOTIFICATION_READ_FAILED, data: { error } });
});
};
}

View file

@ -11,10 +11,8 @@ import {
ACTIONS as LBRY_REDUX_ACTIONS, ACTIONS as LBRY_REDUX_ACTIONS,
} from 'lbry-redux'; } from 'lbry-redux';
import { doError } from 'redux/actions/notifications'; import { doError } from 'redux/actions/notifications';
import { selectosNotificationsEnabled } from 'redux/selectors/settings';
import { push } from 'connected-react-router'; import { push } from 'connected-react-router';
import analytics from 'analytics'; import analytics from 'analytics';
import { formatLbryUrlForWeb } from 'util/url';
import { doOpenModal } from './app'; import { doOpenModal } from './app';
export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getState: () => {}) => { export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getState: () => {}) => {
@ -61,7 +59,7 @@ export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getSt
lbryFirstError, lbryFirstError,
}) })
); );
dispatch(doCheckPendingPublishesApp()); dispatch(doCheckPendingClaims());
// @if TARGET='app' // @if TARGET='app'
dispatch(doCheckReflectingFiles()); dispatch(doCheckReflectingFiles());
// @endif // @endif
@ -85,22 +83,3 @@ export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getSt
dispatch(doPublish(publishSuccess, publishFail)); dispatch(doPublish(publishSuccess, publishFail));
}; };
// Calls claim_list_mine until any pending publishes are confirmed
export const doCheckPendingPublishesApp = () => (dispatch: Dispatch, getState: GetState) => {
const onConfirmed = claim => {
if (selectosNotificationsEnabled(getState())) {
const notif = new window.Notification('LBRY Publish Complete', {
body: __('%nameOrTitle% has been published to lbry://%name%. Click here to view it.', {
nameOrTitle: claim.value_type === 'channel' ? `@${claim.name}` : claim.value.title,
name: claim.name,
}),
silent: false,
});
notif.onclick = () => {
dispatch(push(formatLbryUrlForWeb(claim.permanent_url)));
};
}
};
return dispatch(doCheckPendingClaims(onConfirmed));
};

View file

@ -0,0 +1,42 @@
import * as ACTIONS from 'constants/action_types';
import { getAuthToken } from 'util/saved-passwords';
import { doNotificationList } from 'redux/actions/notifications';
let socket = null;
export const doSocketConnect = () => dispatch => {
const authToken = getAuthToken();
if (!authToken) {
console.error('Unable to connect to web socket because auth token is missing'); // eslint-disable-line
return;
}
if (socket !== null) {
socket.close();
}
socket = new WebSocket(`wss://api.lbry.com/subscribe?auth_token=${authToken}`);
socket.onmessage = e => {
const data = JSON.parse(e.data);
if (data.type === 'pending_notification') {
dispatch(doNotificationList());
}
};
socket.onerror = e => {
console.error('Error connecting to websocket', e); // eslint-disable-line
};
socket.onclose = e => {
// Reconnect?
};
socket.onopen = e => {
console.log('\nConnected to WS \n\n'); // eslint-disable-line
};
};
export const doSocketDisconnect = () => ({
type: ACTIONS.WS_DISCONNECT,
});

View file

@ -4,6 +4,7 @@ import { handleActions } from 'util/redux-utils';
const defaultState: NotificationState = { const defaultState: NotificationState = {
notifications: [], notifications: [],
fetchingNotifications: false,
toasts: [], toasts: [],
errors: [], errors: [],
}; };
@ -32,40 +33,39 @@ export default handleActions(
}, },
// Notifications // Notifications
[ACTIONS.CREATE_NOTIFICATION]: (state: NotificationState, action: DoNotification) => { [ACTIONS.NOTIFICATION_LIST_STARTED]: (state, action) => {
const notification: Notification = action.data;
const newNotifications: Array<Notification> = state.notifications.slice();
newNotifications.push(notification);
return { return {
...state, ...state,
notifications: newNotifications, fetchingNotifications: true,
}; };
}, },
// Used to mark notifications as read/dismissed [ACTIONS.NOTIFICATION_LIST_COMPLETED]: (state, action) => {
[ACTIONS.EDIT_NOTIFICATION]: (state: NotificationState, action: DoEditNotification) => { const { notifications } = action.data;
const { notification } = action.data;
let notifications: Array<Notification> = state.notifications.slice();
notifications = notifications.map(pastNotification =>
pastNotification.id === notification.id ? notification : pastNotification
);
return { return {
...state, ...state,
notifications, notifications,
fetchingNotifications: false,
}; };
}, },
[ACTIONS.DELETE_NOTIFICATION]: (state: NotificationState, action: DoDeleteNotification) => { [ACTIONS.NOTIFICATION_LIST_FAILED]: (state, action) => {
const { id } = action.data; return {
let newNotifications: Array<Notification> = state.notifications.slice(); ...state,
newNotifications = newNotifications.filter(notification => notification.id !== id); fetchingNotifications: false,
};
},
[ACTIONS.NOTIFICATION_READ_COMPLETED]: (state, action) => {
const { notifications } = state;
const newNotifications = notifications && notifications.map(notification => ({ ...notification, is_read: true }));
return { return {
...state, ...state,
notifications: newNotifications, notifications: newNotifications,
}; };
}, },
[ACTIONS.NOTIFICATION_READ_FAILED]: (state, action) => {
return {
...state,
};
},
// Errors // Errors
[ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => { [ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => {

View file

@ -2,6 +2,14 @@ import { createSelector } from 'reselect';
export const selectState = state => state.notifications || {}; export const selectState = state => state.notifications || {};
export const selectNotifications = createSelector(selectState, state => state.notifications);
export const selectIsFetchingNotifications = createSelector(selectState, state => state.fetchingNotifications);
export const selectUnreadNotificationCount = createSelector(selectNotifications, notifications => {
return notifications ? notifications.filter(notification => !notification.is_read).length : 0;
});
export const selectToast = createSelector(selectState, state => { export const selectToast = createSelector(selectState, state => {
if (state.toasts.length) { if (state.toasts.length) {
const { id, params } = state.toasts[0]; const { id, params } = state.toasts[0];

View file

@ -34,6 +34,7 @@
@import 'component/modal'; @import 'component/modal';
@import 'component/nag'; @import 'component/nag';
@import 'component/navigation'; @import 'component/navigation';
@import 'component/notification';
@import 'component/pagination'; @import 'component/pagination';
@import 'component/purchase'; @import 'component/purchase';
@import 'component/placeholder'; @import 'component/placeholder';

View file

@ -103,3 +103,9 @@
.comment__menu-icon { .comment__menu-icon {
stroke: var(--color-comment-menu); stroke: var(--color-comment-menu);
} }
.comment__menu-list {
box-shadow: var(--card-box-shadow);
border-radius: var(--card-radius);
padding: var(--spacing-s);
}

View file

@ -97,6 +97,7 @@
align-items: center; align-items: center;
border-radius: var(--border-radius); border-radius: var(--border-radius);
color: var(--color-text); color: var(--color-text);
position: relative;
svg { svg {
stroke: var(--color-text); stroke: var(--color-text);

View file

@ -6,6 +6,7 @@
padding: 1.5rem; padding: 1.5rem;
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
height: 3.5rem;
width: 3.5rem; width: 3.5rem;
border-radius: calc(3.5rem / 2); border-radius: calc(3.5rem / 2);
position: relative; position: relative;

View file

@ -0,0 +1,37 @@
.notifications__empty {
background-color: var(--color-card-background);
padding: var(--spacing-l);
}
.notification_list {
> * {
border-bottom: 1px solid var(--color-border);
&:last-of-type {
border-bottom: none;
}
}
}
.notification__wrapper {
width: 100%;
display: flex;
}
.notification__content {
margin-left: var(--spacing-m);
}
.notification__title {
font-size: var(--font-large);
}
.notification__text {
font-size: var(--font-body);
margin-top: var(--spacing-s);
}
.notification__time {
@extend .help;
margin-top: 0;
}

View file

@ -1,6 +1,7 @@
.snack-bar { .snack-bar {
bottom: 2rem; bottom: 2rem;
left: 2rem; right: 2rem;
max-width: 20rem;
background-color: var(--color-snack-bg); background-color: var(--color-snack-bg);
color: var(--color-snack); color: var(--color-snack);
border-radius: 0.5rem; border-radius: 0.5rem;
@ -25,6 +26,12 @@
background-color: var(--color-snack-bg-error); background-color: var(--color-snack-bg-error);
} }
.snack-bar--notification {
@extend .card;
background-color: var(--color-card-background);
color: var(--color-text);
}
.snack-bar__action { .snack-bar__action {
display: inline-block; display: inline-block;
margin: var(--spacing-s) 0; margin: var(--spacing-s) 0;

View file

@ -8,7 +8,7 @@
font-family: sans-serif; font-family: sans-serif;
display: block; display: block;
position: absolute; position: absolute;
z-index: 10000; z-index: 2;
font-size: var(--font-body); font-size: var(--font-body);
} }
@ -17,12 +17,13 @@
white-space: nowrap; white-space: nowrap;
outline: none; outline: none;
background-color: var(--color-menu-background); background-color: var(--color-menu-background);
border: 1px solid var(--border-color);
border-top: none; border-top: none;
} }
[data-reach-menu-item] { [data-reach-menu-item] {
display: block; display: block;
z-index: 10000; z-index: 2;
&:focus { &:focus {
box-shadow: none; box-shadow: none;
@ -35,23 +36,21 @@
color: inherit; color: inherit;
font: inherit; font: inherit;
text-decoration: initial; text-decoration: initial;
border-radius: var(--border-radius);
.icon {
stroke: var(--color-menu-icon);
}
} }
[data-reach-menu-item][data-selected] { [data-reach-menu-item][data-selected] {
background-color: var(--color-menu-background--active); background-color: var(--color-menu-background--active);
box-shadow: none; box-shadow: none;
}
.icon { .menu__title {
stroke: var(--color-menu-icon-active); &[aria-expanded='true'] {
background-color: var(--color-primary-alt);
} }
} }
.menu__list { .menu__list {
margin-left: calc(var(--spacing-m) * -1);
box-shadow: var(--card-box-shadow); box-shadow: var(--card-box-shadow);
animation: menu-animate-in var(--animation-duration) var(--animation-style); animation: menu-animate-in var(--animation-duration) var(--animation-style);
border-bottom-left-radius: var(--border-radius); border-bottom-left-radius: var(--border-radius);
@ -60,6 +59,7 @@
.menu__list--header { .menu__list--header {
@extend .menu__list; @extend .menu__list;
padding: var(--spacing-xs);
margin-top: 19px; margin-top: 19px;
} }
@ -72,8 +72,9 @@
.menu__link { .menu__link {
display: flex; display: flex;
align-items: center; align-items: center;
padding: var(--spacing-m); padding: var(--spacing-s);
padding-right: var(--spacing-l); padding-right: var(--spacing-l);
height: var(--button-height);
.icon { .icon {
stroke: var(--color-menu-icon); stroke: var(--color-menu-icon);
@ -91,3 +92,27 @@
font-size: var(--font-small); font-size: var(--font-small);
padding-top: 0; padding-top: 0;
} }
.menu__link--notification {
display: flex;
align-items: flex-start;
padding: var(--spacing-s);
.icon__wrapper {
height: 2.5rem;
width: 2.5rem;
}
&:hover {
cursor: pointer;
}
}
.menu__link--all-notifications {
@extend .button--alt;
width: auto;
align-self: flex-start;
margin-right: auto;
font-weight: var(--font-weight-bold);
margin-top: var(--spacing-m);
}

View file

@ -301,3 +301,17 @@ textarea {
margin-bottom: 0; margin-bottom: 0;
} }
} }
.notification__bubble {
height: 1.5rem;
width: 1.5rem;
border-radius: 50%;
background-color: #f02849;
position: absolute;
top: -0.5rem;
right: -0.5rem;
color: white;
font-size: var(--font-small);
font-weight: bold;
line-height: 1;
}

View file

@ -54,7 +54,7 @@
// Menu // Menu
--color-menu-background: var(--color-header-background); --color-menu-background: var(--color-header-background);
--color-menu-background--selected: var(--color-secondary-alt); --color-menu-background--selected: var(--color-secondary-alt);
--color-menu-background--active: var(--color-secondary-alt); --color-menu-background--active: #f0f7ff;
--color-menu-icon: var(--color-navigation-link); --color-menu-icon: var(--color-navigation-link);
--color-menu-icon-active: var(--color-navigation-link); --color-menu-icon-active: var(--color-navigation-link);
@ -75,9 +75,9 @@
--color-editor-attr: #04b0f4; --color-editor-attr: #04b0f4;
--color-editor-string: #ff7451; --color-editor-string: #ff7451;
--color-editor-inline-code-fg: var(--color-text); --color-editor-inline-code-fg: var(--color-text);
--color-editor-inline-code-fg-preview: #eeeeee; --color-editor-inline-code-fg-preview: #2e3439;
--color-editor-inline-code-bg: rgba(157, 161, 165, 0.3); --color-editor-inline-code-bg: rgba(157, 161, 165, 0.3);
--color-editor-inline-code-bg-preview: #8f9499; --color-editor-inline-code-bg-preview: #d0e8ff;
--color-editor-selected: #add6ff; --color-editor-selected: #add6ff;
--color-editor-link: var(--color-link); --color-editor-link: var(--color-link);
--color-editor-url: var(--color-editor-string); --color-editor-url: var(--color-editor-string);

View file

@ -2,7 +2,13 @@
const PAGES = require('../constants/pages'); const PAGES = require('../constants/pages');
exports.formatLbryUrlForWeb = uri => { exports.formatLbryUrlForWeb = uri => {
return uri.replace('lbry://', '/').replace(/#/g, ':'); let newUrl = uri.replace('lbry://', '/').replace(/#/g, ':');
if (newUrl.startsWith('/?')) {
// This is a lbry link to an internal page ex: lbry://?rewards
newUrl = newUrl.replace('/?', '/$/');
}
return newUrl;
}; };
exports.formatFileSystemPath = path => { exports.formatFileSystemPath = path => {