add comments behind a flag
This commit is contained in:
parent
631d7495c7
commit
90327a72ed
39 changed files with 559 additions and 76 deletions
|
@ -8,6 +8,10 @@
|
|||
node_modules/lbry-redux/flow-typed/
|
||||
node_modules/lbryinc/flow-typed/
|
||||
|
||||
[untyped]
|
||||
.*/node_modules/lbry-redux
|
||||
.*/node_modules/lbryinc
|
||||
|
||||
[lints]
|
||||
|
||||
[options]
|
||||
|
|
31
flow-typed/notification.js
vendored
Normal file
31
flow-typed/notification.js
vendored
Normal 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
1
flow-typed/user.js
vendored
|
@ -27,4 +27,5 @@ declare type User = {
|
|||
youtube_channels: ?Array<string>,
|
||||
device_types: Array<DeviceType>,
|
||||
lbry_first_approved: boolean,
|
||||
experimental_ui: boolean,
|
||||
};
|
||||
|
|
|
@ -149,7 +149,8 @@ const analytics: Analytics = {
|
|||
}
|
||||
},
|
||||
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;
|
||||
let channelClaimId;
|
||||
if (signingChannel) {
|
||||
|
|
|
@ -77,6 +77,7 @@ type Props = {
|
|||
setReferrer: (string, boolean) => void,
|
||||
analyticsTagSync: () => void,
|
||||
isAuthenticated: boolean,
|
||||
socketConnect: () => void,
|
||||
};
|
||||
|
||||
function App(props: Props) {
|
||||
|
@ -194,10 +195,11 @@ function App(props: Props) {
|
|||
if (wrapperElement) {
|
||||
ReactModal.setAppElement(wrapperElement);
|
||||
}
|
||||
|
||||
fetchAccessToken();
|
||||
|
||||
// @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
|
||||
}, [appRef, fetchAccessToken, fetchChannelListMine]);
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectThumbnailForUri } from 'lbry-redux';
|
||||
import { makeSelectThumbnailForUri, doResolveUri, makeSelectClaimForUri } from 'lbry-redux';
|
||||
import ChannelThumbnail from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
select,
|
||||
null
|
||||
)(ChannelThumbnail);
|
||||
export default connect(select, {
|
||||
doResolveUri,
|
||||
})(ChannelThumbnail);
|
||||
|
|
|
@ -13,6 +13,8 @@ type Props = {
|
|||
obscure?: boolean,
|
||||
small?: boolean,
|
||||
allowGifs?: boolean,
|
||||
claim: ?ChannelClaim,
|
||||
doResolveUri: string => void,
|
||||
};
|
||||
|
||||
function ChannelThumbnail(props: Props) {
|
||||
|
@ -24,8 +26,11 @@ function ChannelThumbnail(props: Props) {
|
|||
obscure,
|
||||
small = false,
|
||||
allowGifs = false,
|
||||
claim,
|
||||
doResolveUri,
|
||||
} = props;
|
||||
const [thumbError, setThumbError] = React.useState(false);
|
||||
const shouldResolve = claim === undefined;
|
||||
const thumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
|
||||
const thumbnailPreview = rawThumbnailPreview && rawThumbnailPreview.trim().replace(/^http:\/\//i, 'https://');
|
||||
const channelThumbnail = thumbnail || thumbnailPreview;
|
||||
|
@ -41,6 +46,12 @@ function ChannelThumbnail(props: Props) {
|
|||
colorClassName = `channel-thumbnail__default--4`;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldResolve && uri) {
|
||||
doResolveUri(uri);
|
||||
}
|
||||
}, [doResolveUri, shouldResolve, uri]);
|
||||
|
||||
if (channelThumbnail && channelThumbnail.endsWith('gif') && !allowGifs) {
|
||||
return <FreezeframeWrapper src={channelThumbnail} className="channel-thumbnail" />;
|
||||
}
|
||||
|
|
|
@ -701,4 +701,10 @@ export const icons = {
|
|||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</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>
|
||||
),
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ import Icon from 'component/common/icon';
|
|||
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
|
||||
import Tooltip from 'component/common/tooltip';
|
||||
import NavigationButton from 'component/navigationButton';
|
||||
import NotificationHeaderButton from 'component/notificationHeaderButton';
|
||||
import { LOGO_TITLE } from 'config';
|
||||
import useIsMobile from 'effects/use-is-mobile';
|
||||
// @if TARGET='app'
|
||||
|
@ -239,6 +240,8 @@ const Header = (props: Props) => {
|
|||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
<NotificationHeaderButton />
|
||||
|
||||
<Menu>
|
||||
<MenuButton
|
||||
aria-label={__('Your account')}
|
||||
|
@ -298,6 +301,7 @@ const Header = (props: Props) => {
|
|||
</Menu>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<MenuButton
|
||||
aria-label={__('Settings')}
|
||||
|
|
4
ui/component/notification/index.js
Normal file
4
ui/component/notification/index.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Notification from './view';
|
||||
|
||||
export default connect()(Notification);
|
64
ui/component/notification/view.jsx
Normal file
64
ui/component/notification/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
20
ui/component/notificationHeaderButton/index.js
Normal file
20
ui/component/notificationHeaderButton/index.js
Normal 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);
|
91
ui/component/notificationHeaderButton/view.jsx
Normal file
91
ui/component/notificationHeaderButton/view.jsx
Normal 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>
|
||||
// );
|
||||
}
|
|
@ -43,6 +43,7 @@ import RewardsVerifyPage from 'page/rewardsVerify';
|
|||
import CheckoutPage from 'page/checkoutPage';
|
||||
import ChannelNew from 'page/channelNew';
|
||||
import BuyPage from 'page/buy';
|
||||
import NotificationsPage from 'page/notifications';
|
||||
|
||||
import { parseURI } from 'lbry-redux';
|
||||
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.CHECKOUT}`} exact component={CheckoutPage} />
|
||||
|
||||
<PrivateRoute
|
||||
{...props}
|
||||
path={`/$/${PAGES.SETTINGS_NOTIFICATIONS}`}
|
||||
exact
|
||||
component={SettingsNotificationsPage}
|
||||
/>
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} />
|
||||
<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.CHANNELS}`} component={ChannelsPage} />
|
||||
<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/:claimId`} exact component={EmbedWrapperPage} />
|
||||
|
|
|
@ -216,15 +216,17 @@ export const CLEAR_PUBLISH_ERROR = 'CLEAR_PUBLISH_ERROR';
|
|||
export const REMOVE_PENDING_PUBLISH = 'REMOVE_PENDING_PUBLISH';
|
||||
export const DO_PREPARE_EDIT = 'DO_PREPARE_EDIT';
|
||||
|
||||
// media
|
||||
// Media
|
||||
export const MEDIA_PLAY = 'MEDIA_PLAY';
|
||||
export const MEDIA_PAUSE = 'MEDIA_PAUSE';
|
||||
|
||||
// Notifications
|
||||
export const CREATE_NOTIFICATION = 'CREATE_NOTIFICATION';
|
||||
export const EDIT_NOTIFICATION = 'EDIT_NOTIFICATION';
|
||||
export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION';
|
||||
export const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION';
|
||||
export const NOTIFICATION_LIST_STARTED = 'NOTIFICATION_LIST_STARTED';
|
||||
export const NOTIFICATION_LIST_COMPLETED = 'NOTIFICATION_LIST_COMPLETED';
|
||||
export const NOTIFICATION_LIST_FAILED = 'NOTIFICATION_LIST_FAILED';
|
||||
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 DISMISS_TOAST = 'DISMISS_TOAST';
|
||||
export const CREATE_ERROR = 'CREATE_ERROR';
|
||||
|
@ -249,3 +251,7 @@ export const COMMENT_HIDE_FAILED = 'COMMENT_HIDE_FAILED';
|
|||
|
||||
// Blocked Channels
|
||||
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
|
||||
|
||||
// Notifications
|
||||
export const WS_CONNECT = 'WS_CONNECT';
|
||||
export const WS_DISCONNECT = 'WS_DISCONNECT';
|
||||
|
|
|
@ -110,3 +110,4 @@ export const CAMERA = 'Camera';
|
|||
export const OPEN_LOG = 'FilePlus';
|
||||
export const OPEN_LOG_FOLDER = 'Folder';
|
||||
export const LBRY_STATUS = 'BarChart';
|
||||
export const NOTIFICATION = 'Bell';
|
||||
|
|
|
@ -40,4 +40,5 @@ exports.CREATOR_DASHBOARD = 'dashboard';
|
|||
exports.CHECKOUT = 'checkout';
|
||||
exports.CODE_2257 = '2257';
|
||||
exports.BUY = 'buy';
|
||||
exports.CHANNEL_NEW = 'channelnew';
|
||||
exports.CHANNEL_NEW = 'channel/new';
|
||||
exports.NOTIFICATIONS = 'notifications';
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doHideModal } from 'redux/actions/app';
|
||||
import { doAbandonTxo, doAbandonClaim, selectTransactionItems } from 'lbry-redux';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import ModalRevokeClaim from './view';
|
||||
|
||||
const select = state => ({
|
||||
|
@ -8,6 +9,7 @@ const select = state => ({
|
|||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
toast: (message, isError) => dispatch(doToast({ message, isError })),
|
||||
closeModal: () => dispatch(doHideModal()),
|
||||
abandonTxo: (txo, cb) => dispatch(doAbandonTxo(txo, cb)),
|
||||
abandonClaim: (txid, nout, cb) => dispatch(doAbandonClaim(txid, nout, cb)),
|
||||
|
|
|
@ -6,9 +6,9 @@ import {
|
|||
selectFetchingMyClaimsPageError,
|
||||
doClearPublish,
|
||||
doFetchClaimListMine,
|
||||
doCheckPendingClaims,
|
||||
} from 'lbry-redux';
|
||||
import { selectUploadCount } from 'lbryinc';
|
||||
import { doCheckPendingPublishesApp } from 'redux/actions/publish';
|
||||
import FileListPublished from './view';
|
||||
import { withRouter } from 'react-router';
|
||||
import { MY_CLAIMS_PAGE_SIZE, PAGE_PARAM, PAGE_SIZE_PARAM } from 'constants/claim';
|
||||
|
@ -31,7 +31,7 @@ const select = (state, props) => {
|
|||
};
|
||||
|
||||
const perform = dispatch => ({
|
||||
checkPendingPublishes: () => dispatch(doCheckPendingPublishesApp()),
|
||||
checkPendingPublishes: () => dispatch(doCheckPendingClaims()),
|
||||
fetchClaimListMine: (page, pageSize) => dispatch(doFetchClaimListMine(page, pageSize)),
|
||||
clearPublish: () => dispatch(doClearPublish()),
|
||||
});
|
||||
|
|
|
@ -6,8 +6,8 @@ import Page from 'component/page';
|
|||
import Button from 'component/button';
|
||||
import ClaimTilesDiscover from 'component/claimTilesDiscover';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
|
||||
import getHomepage from 'homepage';
|
||||
|
||||
type Props = {
|
||||
authenticated: boolean,
|
||||
followedTags: Array<Tag>,
|
||||
|
|
15
ui/page/notifications/index.js
Normal file
15
ui/page/notifications/index.js
Normal 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);
|
40
ui/page/notifications/view.jsx
Normal file
40
ui/page/notifications/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -21,7 +21,7 @@ import {
|
|||
selectFollowedTagsList,
|
||||
// SHARED_PREFERENCES,
|
||||
} from 'lbry-redux';
|
||||
import { doToast, doError } from 'redux/actions/notifications';
|
||||
import { doToast, doError, doNotificationList } from 'redux/actions/notifications';
|
||||
import Native from 'native';
|
||||
import {
|
||||
doFetchDaemonSettings,
|
||||
|
@ -49,6 +49,7 @@ import { doAuthenticate } from 'redux/actions/user';
|
|||
import { lbrySettings as config, version as appVersion } from 'package.json';
|
||||
import analytics, { SHARE_INTERNAL } from 'analytics';
|
||||
import { doSignOutCleanup, deleteSavedPassword, getSavedPassword } from 'util/saved-passwords';
|
||||
import { doSocketConnect } from 'redux/actions/websocket';
|
||||
|
||||
// @if TARGET='app'
|
||||
const { autoUpdater } = remote.require('electron-updater');
|
||||
|
@ -489,13 +490,18 @@ export function doSignIn() {
|
|||
const state = getState();
|
||||
const user = selectUser(state);
|
||||
const userId = user.id;
|
||||
const notificationsEnabled = user.experimental_ui;
|
||||
|
||||
analytics.setUser(userId);
|
||||
|
||||
if (notificationsEnabled) {
|
||||
dispatch(doSocketConnect());
|
||||
dispatch(doNotificationList());
|
||||
}
|
||||
|
||||
// @if TARGET='web'
|
||||
dispatch(doBalanceSubscribe());
|
||||
dispatch(doFetchChannelListMine());
|
||||
|
||||
// @endif
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// @flow
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
import { Lbryio } from 'lbryinc';
|
||||
import uuid from 'uuid/v4';
|
||||
import { selectNotifications } from 'redux/selectors/notifications';
|
||||
|
||||
export function doToast(params: ToastParams) {
|
||||
if (!params) {
|
||||
|
@ -36,3 +38,39 @@ export function doDismissError() {
|
|||
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 } });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -11,10 +11,8 @@ import {
|
|||
ACTIONS as LBRY_REDUX_ACTIONS,
|
||||
} from 'lbry-redux';
|
||||
import { doError } from 'redux/actions/notifications';
|
||||
import { selectosNotificationsEnabled } from 'redux/selectors/settings';
|
||||
import { push } from 'connected-react-router';
|
||||
import analytics from 'analytics';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import { doOpenModal } from './app';
|
||||
|
||||
export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getState: () => {}) => {
|
||||
|
@ -61,7 +59,7 @@ export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getSt
|
|||
lbryFirstError,
|
||||
})
|
||||
);
|
||||
dispatch(doCheckPendingPublishesApp());
|
||||
dispatch(doCheckPendingClaims());
|
||||
// @if TARGET='app'
|
||||
dispatch(doCheckReflectingFiles());
|
||||
// @endif
|
||||
|
@ -85,22 +83,3 @@ export const doPublishDesktop = (filePath: string) => (dispatch: Dispatch, getSt
|
|||
|
||||
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));
|
||||
};
|
||||
|
|
42
ui/redux/actions/websocket.js
Normal file
42
ui/redux/actions/websocket.js
Normal 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,
|
||||
});
|
|
@ -4,6 +4,7 @@ import { handleActions } from 'util/redux-utils';
|
|||
|
||||
const defaultState: NotificationState = {
|
||||
notifications: [],
|
||||
fetchingNotifications: false,
|
||||
toasts: [],
|
||||
errors: [],
|
||||
};
|
||||
|
@ -32,40 +33,39 @@ export default handleActions(
|
|||
},
|
||||
|
||||
// Notifications
|
||||
[ACTIONS.CREATE_NOTIFICATION]: (state: NotificationState, action: DoNotification) => {
|
||||
const notification: Notification = action.data;
|
||||
const newNotifications: Array<Notification> = state.notifications.slice();
|
||||
newNotifications.push(notification);
|
||||
|
||||
[ACTIONS.NOTIFICATION_LIST_STARTED]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
notifications: newNotifications,
|
||||
fetchingNotifications: true,
|
||||
};
|
||||
},
|
||||
// Used to mark notifications as read/dismissed
|
||||
[ACTIONS.EDIT_NOTIFICATION]: (state: NotificationState, action: DoEditNotification) => {
|
||||
const { notification } = action.data;
|
||||
let notifications: Array<Notification> = state.notifications.slice();
|
||||
|
||||
notifications = notifications.map(pastNotification =>
|
||||
pastNotification.id === notification.id ? notification : pastNotification
|
||||
);
|
||||
|
||||
[ACTIONS.NOTIFICATION_LIST_COMPLETED]: (state, action) => {
|
||||
const { notifications } = action.data;
|
||||
return {
|
||||
...state,
|
||||
notifications,
|
||||
fetchingNotifications: false,
|
||||
};
|
||||
},
|
||||
[ACTIONS.DELETE_NOTIFICATION]: (state: NotificationState, action: DoDeleteNotification) => {
|
||||
const { id } = action.data;
|
||||
let newNotifications: Array<Notification> = state.notifications.slice();
|
||||
newNotifications = newNotifications.filter(notification => notification.id !== id);
|
||||
|
||||
[ACTIONS.NOTIFICATION_LIST_FAILED]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
fetchingNotifications: false,
|
||||
};
|
||||
},
|
||||
[ACTIONS.NOTIFICATION_READ_COMPLETED]: (state, action) => {
|
||||
const { notifications } = state;
|
||||
const newNotifications = notifications && notifications.map(notification => ({ ...notification, is_read: true }));
|
||||
return {
|
||||
...state,
|
||||
notifications: newNotifications,
|
||||
};
|
||||
},
|
||||
[ACTIONS.NOTIFICATION_READ_FAILED]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
},
|
||||
|
||||
// Errors
|
||||
[ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => {
|
||||
|
|
|
@ -2,6 +2,14 @@ import { createSelector } from 'reselect';
|
|||
|
||||
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 => {
|
||||
if (state.toasts.length) {
|
||||
const { id, params } = state.toasts[0];
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
@import 'component/modal';
|
||||
@import 'component/nag';
|
||||
@import 'component/navigation';
|
||||
@import 'component/notification';
|
||||
@import 'component/pagination';
|
||||
@import 'component/purchase';
|
||||
@import 'component/placeholder';
|
||||
|
|
|
@ -103,3 +103,9 @@
|
|||
.comment__menu-icon {
|
||||
stroke: var(--color-comment-menu);
|
||||
}
|
||||
|
||||
.comment__menu-list {
|
||||
box-shadow: var(--card-box-shadow);
|
||||
border-radius: var(--card-radius);
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
|
|
|
@ -97,6 +97,7 @@
|
|||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text);
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
stroke: var(--color-text);
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
padding: 1.5rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: 3.5rem;
|
||||
width: 3.5rem;
|
||||
border-radius: calc(3.5rem / 2);
|
||||
position: relative;
|
||||
|
|
37
ui/scss/component/_notification.scss
Normal file
37
ui/scss/component/_notification.scss
Normal 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;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
.snack-bar {
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
max-width: 20rem;
|
||||
background-color: var(--color-snack-bg);
|
||||
color: var(--color-snack);
|
||||
border-radius: 0.5rem;
|
||||
|
@ -25,6 +26,12 @@
|
|||
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 {
|
||||
display: inline-block;
|
||||
margin: var(--spacing-s) 0;
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
font-family: sans-serif;
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
z-index: 2;
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
|
@ -17,12 +17,13 @@
|
|||
white-space: nowrap;
|
||||
outline: none;
|
||||
background-color: var(--color-menu-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
[data-reach-menu-item] {
|
||||
display: block;
|
||||
z-index: 10000;
|
||||
z-index: 2;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
|
@ -35,23 +36,21 @@
|
|||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: initial;
|
||||
|
||||
.icon {
|
||||
stroke: var(--color-menu-icon);
|
||||
}
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
[data-reach-menu-item][data-selected] {
|
||||
background-color: var(--color-menu-background--active);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
stroke: var(--color-menu-icon-active);
|
||||
.menu__title {
|
||||
&[aria-expanded='true'] {
|
||||
background-color: var(--color-primary-alt);
|
||||
}
|
||||
}
|
||||
|
||||
.menu__list {
|
||||
margin-left: calc(var(--spacing-m) * -1);
|
||||
box-shadow: var(--card-box-shadow);
|
||||
animation: menu-animate-in var(--animation-duration) var(--animation-style);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
|
@ -60,6 +59,7 @@
|
|||
|
||||
.menu__list--header {
|
||||
@extend .menu__list;
|
||||
padding: var(--spacing-xs);
|
||||
margin-top: 19px;
|
||||
}
|
||||
|
||||
|
@ -72,8 +72,9 @@
|
|||
.menu__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-m);
|
||||
padding: var(--spacing-s);
|
||||
padding-right: var(--spacing-l);
|
||||
height: var(--button-height);
|
||||
|
||||
.icon {
|
||||
stroke: var(--color-menu-icon);
|
||||
|
@ -91,3 +92,27 @@
|
|||
font-size: var(--font-small);
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -301,3 +301,17 @@ textarea {
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
// Menu
|
||||
--color-menu-background: var(--color-header-background);
|
||||
--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-active: var(--color-navigation-link);
|
||||
|
||||
|
@ -75,9 +75,9 @@
|
|||
--color-editor-attr: #04b0f4;
|
||||
--color-editor-string: #ff7451;
|
||||
--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-preview: #8f9499;
|
||||
--color-editor-inline-code-bg-preview: #d0e8ff;
|
||||
--color-editor-selected: #add6ff;
|
||||
--color-editor-link: var(--color-link);
|
||||
--color-editor-url: var(--color-editor-string);
|
||||
|
|
|
@ -2,7 +2,13 @@
|
|||
const PAGES = require('../constants/pages');
|
||||
|
||||
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 => {
|
||||
|
|
Loading…
Reference in a new issue