From 7d492ae1fcc3e4448e2b3f2c50a9967d4d463bbf Mon Sep 17 00:00:00 2001 From: Sean Yesmunt Date: Thu, 4 Jan 2018 00:05:20 -0500 Subject: [PATCH] Redesign groundwork (home/search) --- .flowconfig | 1 + flow-typed/react-modal.js | 3 + npm-debug.log.2899857694 | 25 + package.json | 1 + src/renderer/component/app/view.jsx | 61 +- src/renderer/component/cardMedia/view.jsx | 44 +- src/renderer/component/common.js | 4 + .../component/common/category-list.jsx | 255 ++++++++ src/renderer/component/common/icon.jsx | 43 ++ src/renderer/component/common/tooltip.jsx | 57 ++ src/renderer/component/fileCard/view.jsx | 119 ++-- src/renderer/component/fileTile/view.jsx | 4 +- src/renderer/component/header/view.jsx | 120 ++-- src/renderer/component/icon/index.js | 5 - src/renderer/component/icon/view.jsx | 50 -- src/renderer/component/link/view.jsx | 111 ++-- src/renderer/component/page/index.js | 9 + src/renderer/component/page/view.jsx | 26 + src/renderer/component/tooltip.js | 54 -- src/renderer/component/uriIndicator/view.jsx | 54 +- src/renderer/component/wunderbar/index.js | 10 +- .../wunderbar/internal/autocomplete.jsx | 601 ++++++++++++++++++ src/renderer/component/wunderbar/view.jsx | 234 +++---- src/renderer/constants/action_types.js | 4 + src/renderer/index.js | 6 +- src/renderer/page/channel/view.jsx | 7 +- src/renderer/page/discover/view.jsx | 250 +------- src/renderer/page/file/view.jsx | 85 +-- src/renderer/page/search/view.jsx | 14 +- src/renderer/page/show/view.jsx | 16 +- src/renderer/page/subscriptions/view.jsx | 6 +- src/renderer/redux/actions/search.js | 157 +++-- src/renderer/redux/reducers/search.js | 91 ++- src/renderer/redux/selectors/navigation.js | 35 +- src/renderer/redux/selectors/search.js | 55 +- src/renderer/scss/_gui.scss | 203 +++--- src/renderer/scss/_icons.scss | 10 + src/renderer/scss/_vars.scss | 31 +- src/renderer/scss/component/_button.scss | 153 ++--- src/renderer/scss/component/_card.scss | 484 ++++++-------- .../scss/component/_channel-indicator.scss | 6 - src/renderer/scss/component/_header.scss | 78 ++- src/renderer/scss/component/_tooltip.scss | 10 +- .../metropolis/Metropolis-BlackItalic.woff2 | Bin 0 -> 17292 bytes static/font/metropolis/Metropolis-Bold.woff2 | Bin 0 -> 16728 bytes .../metropolis/Metropolis-BoldItalic.woff2 | Bin 0 -> 17268 bytes .../metropolis/Metropolis-ExtraBold.woff2 | Bin 0 -> 16452 bytes .../Metropolis-ExtraBoldItalic.woff2 | Bin 0 -> 17424 bytes .../metropolis/Metropolis-ExtraLight.woff2 | Bin 0 -> 16400 bytes .../Metropolis-ExtraLightItalic.woff2 | Bin 0 -> 17392 bytes static/font/metropolis/Metropolis-Light.woff2 | Bin 0 -> 16564 bytes .../metropolis/Metropolis-LightItalic.woff2 | Bin 0 -> 17380 bytes .../font/metropolis/Metropolis-Medium.woff2 | Bin 0 -> 16496 bytes .../metropolis/Metropolis-MediumItalic.woff2 | Bin 0 -> 17536 bytes .../font/metropolis/Metropolis-Regular.woff2 | Bin 0 -> 16388 bytes .../metropolis/Metropolis-RegularItalic.woff2 | Bin 0 -> 17332 bytes .../font/metropolis/Metropolis-SemiBold.woff2 | Bin 0 -> 16576 bytes .../Metropolis-SemiBoldItalic.woff2 | Bin 0 -> 17372 bytes static/font/metropolis/Metropolis-Thin.woff2 | Bin 0 -> 16080 bytes .../metropolis/Metropolis-ThinItalic.woff2 | Bin 0 -> 17144 bytes yarn.lock | 4 + 61 files changed, 2151 insertions(+), 1445 deletions(-) create mode 100644 flow-typed/react-modal.js create mode 100644 npm-debug.log.2899857694 create mode 100644 src/renderer/component/common/category-list.jsx create mode 100644 src/renderer/component/common/icon.jsx create mode 100644 src/renderer/component/common/tooltip.jsx delete mode 100644 src/renderer/component/icon/index.js delete mode 100644 src/renderer/component/icon/view.jsx create mode 100644 src/renderer/component/page/index.js create mode 100644 src/renderer/component/page/view.jsx delete mode 100644 src/renderer/component/tooltip.js create mode 100644 src/renderer/component/wunderbar/internal/autocomplete.jsx create mode 100755 static/font/metropolis/Metropolis-BlackItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-Bold.woff2 create mode 100755 static/font/metropolis/Metropolis-BoldItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-ExtraBold.woff2 create mode 100755 static/font/metropolis/Metropolis-ExtraBoldItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-ExtraLight.woff2 create mode 100755 static/font/metropolis/Metropolis-ExtraLightItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-Light.woff2 create mode 100755 static/font/metropolis/Metropolis-LightItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-Medium.woff2 create mode 100755 static/font/metropolis/Metropolis-MediumItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-Regular.woff2 create mode 100755 static/font/metropolis/Metropolis-RegularItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-SemiBold.woff2 create mode 100755 static/font/metropolis/Metropolis-SemiBoldItalic.woff2 create mode 100755 static/font/metropolis/Metropolis-Thin.woff2 create mode 100755 static/font/metropolis/Metropolis-ThinItalic.woff2 diff --git a/.flowconfig b/.flowconfig index 899a096e6..5f5dfd123 100644 --- a/.flowconfig +++ b/.flowconfig @@ -18,5 +18,6 @@ module.name_mapper='^types\(.*\)$' -> '/src/renderer/types\1' module.name_mapper='^component\(.*\)$' -> '/src/renderer/component\1' module.name_mapper='^page\(.*\)$' -> '/src/renderer/page\1' module.name_mapper='^lbry\(.*\)$' -> '/src/renderer/lbry\1' +module.name_mapper='^modal\(.*\)$' -> '/src/renderer/modal\1' [strict] diff --git a/flow-typed/react-modal.js b/flow-typed/react-modal.js new file mode 100644 index 000000000..17766e9f0 --- /dev/null +++ b/flow-typed/react-modal.js @@ -0,0 +1,3 @@ +declare module 'react-modal' { + declare module.exports: any; +} diff --git a/npm-debug.log.2899857694 b/npm-debug.log.2899857694 new file mode 100644 index 000000000..b276ec83b --- /dev/null +++ b/npm-debug.log.2899857694 @@ -0,0 +1,25 @@ +0 info it worked if it ends with ok +1 verbose cli [ '/Users/seanyesmunt/.nvm/versions/node/v6.12.0/bin/node', +1 verbose cli '/Users/seanyesmunt/.nvm/versions/node/v6.12.0/bin/npm', +1 verbose cli 'config', +1 verbose cli '--loglevel=warn', +1 verbose cli 'get', +1 verbose cli 'prefix' ] +2 info using npm@3.10.10 +3 info using node@v6.12.0 +4 verbose exit [ 0, true ] +5 verbose stack Error: write EPIPE +5 verbose stack at exports._errnoException (util.js:1020:11) +5 verbose stack at WriteWrap.afterWrite (net.js:800:14) +6 verbose cwd /Users/seanyesmunt/Workspace/lbry/lbry-app +7 error Darwin 17.2.0 +8 error argv "/Users/seanyesmunt/.nvm/versions/node/v6.12.0/bin/node" "/Users/seanyesmunt/.nvm/versions/node/v6.12.0/bin/npm" "config" "--loglevel=warn" "get" "prefix" +9 error node v6.12.0 +10 error npm v3.10.10 +11 error code EPIPE +12 error errno EPIPE +13 error syscall write +14 error write EPIPE +15 error If you need help, you may report this error at: +15 error +16 verbose exit [ 1, true ] diff --git a/package.json b/package.json index 2e61b99cc..07d4e84fd 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "amplitude-js": "^4.0.0", "bluebird": "^3.5.1", "classnames": "^2.2.5", + "dom-scroll-into-view": "^1.2.1", "electron-dl": "^1.6.0", "formik": "^0.10.4", "from2": "^2.3.0", diff --git a/src/renderer/component/app/view.jsx b/src/renderer/component/app/view.jsx index fb43b6574..240db13e2 100644 --- a/src/renderer/component/app/view.jsx +++ b/src/renderer/component/app/view.jsx @@ -1,3 +1,4 @@ +// @flow import React from 'react'; import Router from 'component/router/index'; import Header from 'component/header'; @@ -6,61 +7,83 @@ import ModalRouter from 'modal/modalRouter'; import ReactModal from 'react-modal'; import throttle from 'util/throttle'; -class App extends React.PureComponent { +type Props = { + alertError: (string | {}) => void, + recordScroll: number => void, + currentStackIndex: number, + currentPageAttributes: { path: string, scrollY: number }, + pageTitle: ?string, +}; + +class App extends React.PureComponent { constructor() { super(); this.mainContent = undefined; + (this: any).scrollListener = this.scrollListener.bind(this); } componentWillMount() { const { alertError } = this.props; - document.addEventListener('unhandledError', event => { + // TODO: create type for this object + // it lives in jsonrpc.js + document.addEventListener('unhandledError', (event: any) => { alertError(event.detail); }); } componentDidMount() { - const { recordScroll } = this.props; const mainContent = document.getElementById('main-content'); this.mainContent = mainContent; - const scrollListener = () => recordScroll(this.mainContent.scrollTop); - - this.mainContent.addEventListener('scroll', throttle(scrollListener, 750)); + if (this.mainContent) { + this.mainContent.addEventListener('scroll', throttle(this.scrollListener, 750)); + } ReactModal.setAppElement('#window'); // fuck this } - componentWillUnmount() { - this.mainContent.removeEventListener('scroll', this.scrollListener); + componentWillReceiveProps(props: Props) { + const { pageTitle } = props; + this.setTitleFromProps(pageTitle); } - componentWillReceiveProps(props) { - this.setTitleFromProps(props); - } - - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { const { currentStackIndex: prevStackIndex } = prevProps; const { currentStackIndex, currentPageAttributes } = this.props; - if (currentStackIndex !== prevStackIndex) { + if (this.mainContent && currentStackIndex !== prevStackIndex) { this.mainContent.scrollTop = currentPageAttributes.scrollY || 0; } } - setTitleFromProps(props) { - window.document.title = props.pageTitle || 'LBRY'; + componentWillUnmount() { + if (this.mainContent) { + // having issues with this + // $FlowFixMe + this.mainContent.removeEventListener('scroll'); + } } + setTitleFromProps = (title: ?string) => { + window.document.title = title || 'LBRY'; + }; + + scrollListener() { + const { recordScroll } = this.props; + if (this.mainContent) { + recordScroll(this.mainContent.scrollTop); + } + } + + mainContent: ?HTMLElement; + render() { return (
-
- -
+
); diff --git a/src/renderer/component/cardMedia/view.jsx b/src/renderer/component/cardMedia/view.jsx index 06f80f1d7..34feddccb 100644 --- a/src/renderer/component/cardMedia/view.jsx +++ b/src/renderer/component/cardMedia/view.jsx @@ -1,48 +1,18 @@ +// @flow import React from 'react'; -class CardMedia extends React.PureComponent { - static AUTO_THUMB_CLASSES = [ - 'purple', - 'red', - 'pink', - 'indigo', - 'blue', - 'light-blue', - 'cyan', - 'teal', - 'green', - 'yellow', - 'orange', - ]; - - componentWillMount() { - this.setState({ - autoThumbClass: - CardMedia.AUTO_THUMB_CLASSES[ - Math.floor(Math.random() * CardMedia.AUTO_THUMB_CLASSES.length) - ], - }); - } +type Props = { + thumbnail: ?string, // externally sourced image +}; +class CardMedia extends React.PureComponent { render() { - const { title, thumbnail } = this.props; - const atClass = this.state.autoThumbClass; - + const { thumbnail } = this.props; if (thumbnail) { return
; } - return ( -
-
- {title && - title - .replace(/\s+/g, '') - .substring(0, Math.min(title.replace(' ', '').length, 5)) - .toUpperCase()} -
-
- ); + return
LBRY
; } } diff --git a/src/renderer/component/common.js b/src/renderer/component/common.js index 629d09f57..687c6e9da 100644 --- a/src/renderer/component/common.js +++ b/src/renderer/component/common.js @@ -1,3 +1,6 @@ +// just disabling the linter because this file shouldn't even exist +// will continue to move components over to /components/common/{comp} - sean +/* eslint-disable */ import React from 'react'; import PropTypes from 'prop-types'; import { formatCredits, formatFullPrice } from 'util/formatCredits'; @@ -170,3 +173,4 @@ export class Thumbnail extends React.PureComponent { ); } } +/* eslint-enable */ diff --git a/src/renderer/component/common/category-list.jsx b/src/renderer/component/common/category-list.jsx new file mode 100644 index 000000000..1f01ea921 --- /dev/null +++ b/src/renderer/component/common/category-list.jsx @@ -0,0 +1,255 @@ +// @flow +import React from 'react'; +import lbryuri from 'lbryuri'; +import ToolTip from 'component/common/tooltip'; +import FileCard from 'component/fileCard'; +import Button from 'component/link'; + +type Props = { + category: string, + names: Array, + categoryLink?: string, +}; + +type State = { + canScrollNext: boolean, + canScrollPrevious: boolean, +}; + +class CategoryList extends React.PureComponent { + constructor() { + super(); + + this.state = { + canScrollPrevious: false, + canScrollNext: false, + }; + + (this: any).handleScrollNext = this.handleScrollNext.bind(this); + (this: any).handleScrollPrevious = this.handleScrollPrevious.bind(this); + this.rowItems = undefined; + } + + componentDidMount() { + const cardRow = this.rowItems; + if (cardRow) { + const cards = cardRow.getElementsByTagName('section'); + const lastCard = cards[cards.length - 1]; + const isCompletelyVisible = this.isCardVisible(lastCard); + + if (!isCompletelyVisible) { + // not sure how we can avoid doing this + /* eslint-disable react/no-did-mount-set-state */ + this.setState({ + canScrollNext: true, + }); + /* eslint-enable react/no-did-mount-set-state */ + } + } + } + + rowItems: ?HTMLDivElement; + + handleScroll(cardRow: HTMLDivElement, scrollTarget: number) { + const cards = cardRow.getElementsByTagName('section'); + const animationCallback = () => { + const firstCard = cards[0]; + const lastCard = cards[cards.length - 1]; + const firstCardVisible = this.isCardVisible(firstCard); + const lastCardVisible = this.isCardVisible(lastCard); + this.setState({ + canScrollNext: !lastCardVisible, + canScrollPrevious: !firstCardVisible, + }); + }; + + const currentScrollLeft = cardRow.scrollLeft; + const direction = currentScrollLeft > scrollTarget ? 'left' : 'right'; + this.scrollCardsAnimated(cardRow, scrollTarget, direction, animationCallback); + } + + scrollCardsAnimated = ( + cardRow: HTMLDivElement, + scrollTarget: number, + direction: string, + callback: () => any + ) => { + let start; + const step = timestamp => { + if (!start) start = timestamp; + + const currentLeftVal = cardRow.scrollLeft; + + let newTarget; + let shouldContinue; + let progress = currentLeftVal; + + if (direction === 'right') { + progress += timestamp - start; + newTarget = Math.min(progress, scrollTarget); + shouldContinue = newTarget < scrollTarget; + } else { + progress -= timestamp - start; + newTarget = Math.max(progress, scrollTarget); + shouldContinue = newTarget > scrollTarget; + } + + cardRow.scrollLeft = newTarget; // eslint-disable-line no-param-reassign + + if (shouldContinue) { + window.requestAnimationFrame(step); + } else { + callback(); + } + }; + + window.requestAnimationFrame(step); + }; + + // check if a card is fully visible horizontally + isCardVisible = (section: HTMLElement) => { + const rect = section.getBoundingClientRect(); + const isVisible = rect.left >= 0 && rect.right <= window.innerWidth; + return isVisible; + }; + + handleScrollNext() { + const cardRow = this.rowItems; + if (cardRow) { + const cards = cardRow.getElementsByTagName('section'); + + // loop over items until we find one that is on the screen + // continue searching until a card isn't fully visible, this is the new target + let firstFullVisibleCard; + let firstSemiVisibleCard; + + for (let i = 0; i < cards.length; i += 1) { + const currentCardVisible = this.isCardVisible(cards[i]); + + if (firstFullVisibleCard && !currentCardVisible) { + firstSemiVisibleCard = cards[i]; + break; + } else if (currentCardVisible) { + [firstFullVisibleCard] = cards; + } + } + + if (firstFullVisibleCard && firstSemiVisibleCard) { + const scrollTarget = firstSemiVisibleCard.offsetLeft - firstFullVisibleCard.offsetLeft; + this.handleScroll(cardRow, scrollTarget); + } + } + } + + handleScrollPrevious() { + const cardRow = this.rowItems; + if (cardRow) { + const cards = cardRow.getElementsByTagName('section'); + + let hasFoundCard; + let numberOfCardsThatCanFit = 0; + + // loop starting at the end until we find a visible card + // then count to find how many cards can fit on the screen + for (let i = cards.length - 1; i >= 0; i -= 1) { + const currentCard = cards[i]; + const isCurrentCardVisible = this.isCardVisible(currentCard); + + if (isCurrentCardVisible) { + if (!hasFoundCard) { + hasFoundCard = true; + } + + numberOfCardsThatCanFit += 1; + } else if (hasFoundCard) { + // this card is off the screen to the left + // we know how many cards can fit on a screen + // find the new target and scroll + const firstCardOffsetLeft = cards[0].offsetLeft; + const cardIndexToScrollTo = i + 1 - numberOfCardsThatCanFit; + const newFirstCard = cards[cardIndexToScrollTo]; + + let scrollTarget; + if (newFirstCard) { + scrollTarget = newFirstCard.offsetLeft; + } else { + // more cards can fit on the screen than are currently hidden + // just scroll to the first card + scrollTarget = cards[0].offsetLeft; + } + + scrollTarget -= firstCardOffsetLeft; // to play nice with the margins + + this.handleScroll(cardRow, scrollTarget); + break; + } + } + } + } + + render() { + const { category, names, categoryLink } = this.props; + const { canScrollNext, canScrollPrevious } = this.state; + + // The lint was throwing an error saying we should use
+
+
+ +
{ + this.rowItems = ref; + }} + className="card-row__scrollhouse" + > + {names && + names.map(name => ( + + ))} +
+ + ); + } +} + +export default CategoryList; diff --git a/src/renderer/component/common/icon.jsx b/src/renderer/component/common/icon.jsx new file mode 100644 index 000000000..3e261d21b --- /dev/null +++ b/src/renderer/component/common/icon.jsx @@ -0,0 +1,43 @@ +// @flow +import React from 'react'; +import classnames from 'classnames'; +import * as icons from 'constants/icons'; + +type Props = { + icon: string, + fixed?: boolean, + padded?: boolean, +}; + +class Icon extends React.PureComponent { + getIconTitle() { + const { icon } = this.props; + + switch (icon) { + case icons.FEATURED: + return __('Watch this and earn rewards.'); + case icons.LOCAL: + return __('You have a copy of this file.'); + default: + return ''; + } + } + + render() { + const { icon, fixed, padded } = this.props; + const iconClassName = icon.startsWith('icon-') ? icon : `icon-${icon}`; + const title = this.getIconTitle(); + + const spanClassName = classnames( + { + 'icon--fixed-width': fixed, + 'icon--padded': padded, + }, + iconClassName + ); + + return ; + } +} + +export default Icon; diff --git a/src/renderer/component/common/tooltip.jsx b/src/renderer/component/common/tooltip.jsx new file mode 100644 index 000000000..942ac73be --- /dev/null +++ b/src/renderer/component/common/tooltip.jsx @@ -0,0 +1,57 @@ +// @flow +import React from 'react'; +import classnames from 'classnames'; +import Icon from 'component/common/icon'; +import Button from 'component/link'; + +type Props = { + body: string, + label: string, +}; + +type State = { + showTooltip: boolean, +}; + +class ToolTip extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + showTooltip: false, + }; + + (this: any).handleClick = this.handleClick.bind(this); + } + + handleClick() { + const { showTooltip } = this.state; + + if (!showTooltip) { + document.addEventListener('click', this.handleClick); + } else { + document.removeEventListener('click', this.handleClick); + } + + this.setState({ + showTooltip: !showTooltip, + }); + } + + render() { + const { label, body } = this.props; + const { showTooltip } = this.state; + + return ( + + +
{body}
+
+ ); + } +} + +export default ToolTip; diff --git a/src/renderer/component/fileCard/view.jsx b/src/renderer/component/fileCard/view.jsx index e38bf06c2..42631ba8f 100644 --- a/src/renderer/component/fileCard/view.jsx +++ b/src/renderer/component/fileCard/view.jsx @@ -1,111 +1,100 @@ +// @flow import React from 'react'; -import lbryuri from 'lbryuri.js'; +import lbryuri from 'lbryuri'; import CardMedia from 'component/cardMedia'; -import Link from 'component/link'; import { TruncatedText } from 'component/common'; -import Icon from 'component/icon'; +import Icon from 'component/common/icon'; import FilePrice from 'component/filePrice'; import UriIndicator from 'component/uriIndicator'; import NsfwOverlay from 'component/nsfwOverlay'; -import TruncatedMarkdown from 'component/truncatedMarkdown'; import * as icons from 'constants/icons'; +import classnames from 'classnames'; -class FileCard extends React.PureComponent { - constructor(props) { - super(props); - - this.state = { - hovered: false, - }; - } +type Props = { + isResolvingUri: boolean, + resolveUri: string => void, + uri: string, + claim: ?{ claim_id: string }, + fileInfo: ?{}, + metadata: ?{ nsfw: boolean, thumbnail: ?string }, + navigate: (string, ?{}) => void, + rewardedContentClaimIds: Array, + obscureNsfw: boolean, +}; +class FileCard extends React.PureComponent { componentWillMount() { this.resolve(this.props); } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { this.resolve(nextProps); } - resolve(props) { + resolve = (props: Props) => { const { isResolvingUri, resolveUri, claim, uri } = props; if (!isResolvingUri && claim === undefined && uri) { resolveUri(uri); } - } - - handleMouseOver() { - this.setState({ - hovered: true, - }); - } - - handleMouseOut() { - this.setState({ - hovered: false, - }); - } + }; render() { const { claim, fileInfo, metadata, - isResolvingUri, navigate, rewardedContentClaimIds, + obscureNsfw, } = this.props; - const uri = lbryuri.normalize(this.props.uri); const title = metadata && metadata.title ? metadata.title : uri; const thumbnail = metadata && metadata.thumbnail ? metadata.thumbnail : null; - const obscureNsfw = this.props.obscureNsfw && metadata && metadata.nsfw; + const shouldObscureNsfw = obscureNsfw && metadata && metadata.nsfw; const isRewardContent = claim && rewardedContentClaimIds.includes(claim.claim_id); - let description = ''; - if (isResolvingUri && !claim) { - description = __('Loading...'); - } else if (metadata && metadata.description) { - description = metadata.description; - } else if (claim === null) { - description = __('This address contains no content.'); - } + // Come back to this on other pages + // let description = ''; + // if (isResolvingUri && !claim) { + // description = __('Loading...'); + // } else if (metadata && metadata.description) { + // description = metadata.description; + // } else if (claim === null) { + // description = __('This address contains no content.'); + // } + // We don't want to allow a click handler unless it's in focus + // I'll come back to this when I work on site-wide keyboard navigation + /* eslint-disable jsx-a11y/click-events-have-key-events */ return (
navigate('/show', { uri })} + className={classnames('card card--small card__link', { + 'card--obscured': shouldObscureNsfw, + })} > -
- navigate('/show', { uri })} className="card__link"> - -
-
- {title} -
-
- - {' '} - {isRewardContent && }{' '} - {fileInfo && } - - - - -
+ + +
+
+ {title} +
+ +
+ +
+ {isRewardContent && } + {fileInfo && }
- - {/* Test for nizuka's design: should we remove description? -
- {description} -
- */} +
- {obscureNsfw && this.state.hovered && } + {obscureNsfw && }
); + /* eslint-enable jsx-a11y/click-events-have-key-events */ } } diff --git a/src/renderer/component/fileTile/view.jsx b/src/renderer/component/fileTile/view.jsx index 231cfc293..ae1901485 100644 --- a/src/renderer/component/fileTile/view.jsx +++ b/src/renderer/component/fileTile/view.jsx @@ -1,3 +1,4 @@ +/* eslint-disable */ import React from 'react'; import * as icons from 'constants/icons'; import lbryuri from 'lbryuri.js'; @@ -5,7 +6,7 @@ import CardMedia from 'component/cardMedia'; import { TruncatedText } from 'component/common.js'; import FilePrice from 'component/filePrice'; import NsfwOverlay from 'component/nsfwOverlay'; -import Icon from 'component/icon'; +import Icon from 'component/common/icon'; class FileTile extends React.PureComponent { static SHOW_EMPTY_PUBLISH = 'publish'; @@ -133,3 +134,4 @@ class FileTile extends React.PureComponent { } export default FileTile; +/* eslint-enable */ diff --git a/src/renderer/component/header/view.jsx b/src/renderer/component/header/view.jsx index c1289c889..0795380dc 100644 --- a/src/renderer/component/header/view.jsx +++ b/src/renderer/component/header/view.jsx @@ -1,8 +1,20 @@ +// @flow import React from 'react'; -import Link from 'component/link'; +import Button from 'component/link'; import WunderBar from 'component/wunderbar'; -export const Header = props => { +type Props = { + balance: string, + back: any => void, + forward: any => void, + isBackDisabled: boolean, + isForwardDisabled: boolean, + isUpgradeAvailable: boolean, + navigate: any => void, + downloadUpgrade: any => void, +}; + +export const Header = (props: Props) => { const { balance, back, @@ -15,85 +27,57 @@ export const Header = props => { } = props; return ( ); }; diff --git a/src/renderer/component/icon/index.js b/src/renderer/component/icon/index.js deleted file mode 100644 index 81d61e58b..000000000 --- a/src/renderer/component/icon/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import Icon from './view'; - -export default connect(null, null)(Icon); diff --git a/src/renderer/component/icon/view.jsx b/src/renderer/component/icon/view.jsx deleted file mode 100644 index 795a1b241..000000000 --- a/src/renderer/component/icon/view.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import * as icons from 'constants/icons'; -import classnames from 'classnames'; - -export default class Icon extends React.PureComponent { - static propTypes = { - icon: PropTypes.string.isRequired, - fixed: PropTypes.bool, - }; - - static defaultProps = { - fixed: false, - }; - - getIconClass() { - const { icon } = this.props; - - return icon.startsWith('icon-') ? icon : `icon-${icon}`; - } - - getIconTitle() { - switch (this.props.icon) { - case icons.FEATURED: - return __('Watch this and earn rewards.'); - case icons.LOCAL: - return __('You have a copy of this file.'); - default: - return ''; - } - } - - render() { - const { icon, fixed, className, leftPad } = this.props; - const iconClass = this.getIconClass(); - const title = this.getIconTitle(); - - const spanClassName = classnames( - 'icon', - iconClass, - { - 'icon-fixed-width': fixed, - 'icon--left-pad': leftPad, - }, - className - ); - - return ; - } -} diff --git a/src/renderer/component/link/view.jsx b/src/renderer/component/link/view.jsx index 92451daff..fdd716dc1 100644 --- a/src/renderer/component/link/view.jsx +++ b/src/renderer/component/link/view.jsx @@ -1,60 +1,99 @@ -import React from 'react'; -import Icon from 'component/icon'; +// @flow +import * as React from 'react'; +import Icon from 'component/common/icon'; +import classnames from 'classnames'; -const Link = props => { +type Props = { + onClick: ?(any) => any, + href: ?string, + title: ?string, + label: ?string, + icon: ?string, + iconRight: ?string, + disabled: ?boolean, + children: ?React.Node, + navigate: ?string, + // TODO: these (nav) should be a reusable type + doNavigate: (string, ?any) => void, + navigateParams: any, + className: ?string, + inverse: ?boolean, + circle: ?boolean, + alt: ?boolean, + flat: ?boolean, + fakeLink: ?boolean, + description: ?string, +}; + +const Button = (props: Props) => { const { + onClick, href, title, - style, label, icon, iconRight, - button, disabled, children, navigate, navigateParams, doNavigate, className, - span, + inverse, + alt, + circle, + flat, + fakeLink, + description, + ...otherProps } = props; - const combinedClassName = - (className || '') + - (!className && !button ? 'button-text' : '') + // Non-button links get the same look as text buttons - (button ? ` button-block button-${button} button-set-item` : '') + - (disabled ? ' disabled' : ''); + const combinedClassName = classnames( + { + btn: !fakeLink, + 'btn--link': fakeLink, + 'btn--primary': !fakeLink && !alt, + 'btn--alt': alt, + 'btn--inverse': inverse, + 'btn--disabled': disabled, + 'btn--circle': circle, + 'btn--flat': flat, + }, + className + ); - const onClick = - !props.onClick && navigate + const extendedOnClick = + !onClick && navigate ? event => { event.stopPropagation(); doNavigate(navigate, navigateParams || {}); } - : props.onClick; + : onClick; - let content; - if (children) { - content = children; - } else { - content = ( - - {icon ? : null} - {label ? {label} : null} - {iconRight ? : null} - - ); - } + const content = ( + + {icon && } + {label && {label}} + {children && children} + {iconRight && } + + ); - const linkProps = { - className: combinedClassName, - href: href || 'javascript:;', - title, - onClick, - style, - }; - - return span ? {content} :
{content}; + return href ? ( + + {content} + + ) : ( + + ); }; -export default Link; +export default Button; diff --git a/src/renderer/component/page/index.js b/src/renderer/component/page/index.js new file mode 100644 index 000000000..dbe9cc315 --- /dev/null +++ b/src/renderer/component/page/index.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import { selectPageTitle } from 'redux/selectors/navigation'; +import Page from './view'; + +const select = state => ({ + title: selectPageTitle(state), +}); + +export default connect(select, null)(Page); diff --git a/src/renderer/component/page/view.jsx b/src/renderer/component/page/view.jsx new file mode 100644 index 000000000..9d16b3e8f --- /dev/null +++ b/src/renderer/component/page/view.jsx @@ -0,0 +1,26 @@ +// @flow +import * as React from 'react'; +import classnames from 'classnames'; +import { BusyMessage } from 'component/common'; + +type Props = { + children: React.Node, + title: ?string, + noPadding: ?boolean, + isLoading: ?boolean, +}; + +const Page = (props: Props) => { + const { children, title, noPadding, isLoading } = props; + return ( +
+
+ {title &&

{title}

} + {isLoading && } +
+
{children}
+
+ ); +}; + +export default Page; diff --git a/src/renderer/component/tooltip.js b/src/renderer/component/tooltip.js deleted file mode 100644 index c81775614..000000000 --- a/src/renderer/component/tooltip.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export class ToolTip extends React.PureComponent { - static propTypes = { - body: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - showTooltip: false, - }; - } - - handleClick() { - this.setState({ - showTooltip: !this.state.showTooltip, - }); - } - - handleTooltipMouseOut() { - this.setState({ - showTooltip: false, - }); - } - - render() { - return ( - - { - this.handleClick(); - }} - > - {this.props.label} - -
{ - this.handleTooltipMouseOut(); - }} - > - {this.props.body} -
-
- ); - } -} - -export default ToolTip; diff --git a/src/renderer/component/uriIndicator/view.jsx b/src/renderer/component/uriIndicator/view.jsx index 704769250..04a8c78a3 100644 --- a/src/renderer/component/uriIndicator/view.jsx +++ b/src/renderer/component/uriIndicator/view.jsx @@ -1,35 +1,46 @@ +// @flow import React from 'react'; -import Icon from 'component/icon'; -import Link from 'component/link'; +import { Icon } from 'component/common'; +import Button from 'component/link'; import lbryuri from 'lbryuri'; import classnames from 'classnames'; -class UriIndicator extends React.PureComponent { +type Props = { + isResolvingUri: boolean, + resolveUri: string => void, + claim: { + channel_name: string, + has_signature: boolean, + signature_is_valid: boolean, + value: { + publisherSignature: { certificateId: string }, + }, + }, + uri: string, + link: ?boolean, +}; + +class UriIndicator extends React.PureComponent { componentWillMount() { this.resolve(this.props); } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { this.resolve(nextProps); } - resolve(props) { + resolve = (props: Props) => { const { isResolvingUri, resolveUri, claim, uri } = props; if (!isResolvingUri && claim === undefined && uri) { resolveUri(uri); } - } + }; render() { - const { claim, link, uri, isResolvingUri, smallCard, span } = this.props; - - if (isResolvingUri && !claim) { - return Validating...; - } - + const { claim, link, isResolvingUri } = this.props; if (!claim) { - return Unused; + return {isResolvingUri ? 'Validating...' : 'Unused'}; } const { @@ -38,14 +49,17 @@ class UriIndicator extends React.PureComponent { signature_is_valid: signatureIsValid, value, } = claim; + const channelClaimId = value && value.publisherSignature && value.publisherSignature.certificateId; if (!hasSignature || !channelName) { - return Anonymous; + return Anonymous; } - let icon, channelLink, modifier; + let icon; + let channelLink; + let modifier; if (signatureIsValid) { modifier = 'valid'; @@ -59,7 +73,6 @@ class UriIndicator extends React.PureComponent { @@ -81,14 +94,9 @@ class UriIndicator extends React.PureComponent { } return ( - + ); } } diff --git a/src/renderer/component/wunderbar/index.js b/src/renderer/component/wunderbar/index.js index 826ab4225..575676232 100644 --- a/src/renderer/component/wunderbar/index.js +++ b/src/renderer/component/wunderbar/index.js @@ -1,19 +1,21 @@ -import React from 'react'; import { connect } from 'react-redux'; -import lbryuri from 'lbryuri.js'; -import { selectWunderBarAddress, selectWunderBarIcon } from 'redux/selectors/search'; +import lbryuri from 'lbryuri'; +import { selectState as selectSearch, selectWunderBarAddress } from 'redux/selectors/search'; import { doNavigate } from 'redux/actions/navigation'; +import { updateSearchQuery, getSearchSuggestions } from 'redux/actions/search'; import Wunderbar from './view'; const select = state => ({ + ...selectSearch(state), address: selectWunderBarAddress(state), - icon: selectWunderBarIcon(state), }); const perform = dispatch => ({ onSearch: query => dispatch(doNavigate('/search', { query })), onSubmit: (query, extraParams) => dispatch(doNavigate('/show', { uri: lbryuri.normalize(query), ...extraParams })), + updateSearchQuery: query => dispatch(updateSearchQuery(query)), + getSearchSuggestions: query => dispatch(getSearchSuggestions(query)), }); export default connect(select, perform)(Wunderbar); diff --git a/src/renderer/component/wunderbar/internal/autocomplete.jsx b/src/renderer/component/wunderbar/internal/autocomplete.jsx new file mode 100644 index 000000000..5c70c8f06 --- /dev/null +++ b/src/renderer/component/wunderbar/internal/autocomplete.jsx @@ -0,0 +1,601 @@ +/* +This is taken from https://github.com/reactjs/react-autocomplete + +We aren't using that component because (for now) there is no way to autohightlight +the first item if it isn't an exact match from what is in the search bar. + +Our use case is: +value in search bar: "hello" +first suggestion: "lbry://hello" + +I changed the function maybeAutoCompleteText to check if the suggestion contains +the search query anywhere, instead of the suggestion starting with it + +https://github.com/reactjs/react-autocomplete/issues/239 +*/ +/* eslint-disable */ + +const React = require('react'); +const PropTypes = require('prop-types'); +const { findDOMNode } = require('react-dom'); +const scrollIntoView = require('dom-scroll-into-view'); + +const IMPERATIVE_API = [ + 'blur', + 'checkValidity', + 'click', + 'focus', + 'select', + 'setCustomValidity', + 'setSelectionRange', + 'setRangeText', +]; + +function getScrollOffset() { + return { + x: + window.pageXOffset !== undefined + ? window.pageXOffset + : (document.documentElement || document.body.parentNode || document.body).scrollLeft, + y: + window.pageYOffset !== undefined + ? window.pageYOffset + : (document.documentElement || document.body.parentNode || document.body).scrollTop, + }; +} + +export default class Autocomplete extends React.Component { + static propTypes = { + /** + * The items to display in the dropdown menu + */ + items: PropTypes.array.isRequired, + /** + * The value to display in the input field + */ + value: PropTypes.any, + /** + * Arguments: `event: Event, value: String` + * + * Invoked every time the user changes the input's value. + */ + onChange: PropTypes.func, + /** + * Arguments: `value: String, item: Any` + * + * Invoked when the user selects an item from the dropdown menu. + */ + onSelect: PropTypes.func, + /** + * Arguments: `item: Any, value: String` + * + * Invoked for each entry in `items` and its return value is used to + * determine whether or not it should be displayed in the dropdown menu. + * By default all items are always rendered. + */ + shouldItemRender: PropTypes.func, + /** + * Arguments: `itemA: Any, itemB: Any, value: String` + * + * The function which is used to sort `items` before display. + */ + sortItems: PropTypes.func, + /** + * Arguments: `item: Any` + * + * Used to read the display value from each entry in `items`. + */ + getItemValue: PropTypes.func.isRequired, + /** + * Arguments: `item: Any, isHighlighted: Boolean, styles: Object` + * + * Invoked for each entry in `items` that also passes `shouldItemRender` to + * generate the render tree for each item in the dropdown menu. `styles` is + * an optional set of styles that can be applied to improve the look/feel + * of the items in the dropdown menu. + */ + renderItem: PropTypes.func.isRequired, + /** + * Arguments: `items: Array, value: String, styles: Object` + * + * Invoked to generate the render tree for the dropdown menu. Ensure the + * returned tree includes every entry in `items` or else the highlight order + * and keyboard navigation logic will break. `styles` will contain + * { top, left, minWidth } which are the coordinates of the top-left corner + * and the width of the dropdown menu. + */ + renderMenu: PropTypes.func, + /** + * Styles that are applied to the dropdown menu in the default `renderMenu` + * implementation. If you override `renderMenu` and you want to use + * `menuStyle` you must manually apply them (`this.props.menuStyle`). + */ + menuStyle: PropTypes.object, + /** + * Arguments: `props: Object` + * + * Invoked to generate the input element. The `props` argument is the result + * of merging `props.inputProps` with a selection of props that are required + * both for functionality and accessibility. At the very least you need to + * apply `props.ref` and all `props.on` event handlers. Failing to do + * this will cause `Autocomplete` to behave unexpectedly. + */ + renderInput: PropTypes.func, + /** + * Props passed to `props.renderInput`. By default these props will be + * applied to the `` element rendered by `Autocomplete`, unless you + * have specified a custom value for `props.renderInput`. Any properties + * supported by `HTMLInputElement` can be specified, apart from the + * following which are set by `Autocomplete`: value, autoComplete, role, + * aria-autocomplete. `inputProps` is commonly used for (but not limited to) + * placeholder, event handlers (onFocus, onBlur, etc.), autoFocus, etc.. + */ + inputProps: PropTypes.object, + /** + * Props that are applied to the element which wraps the `` and + * dropdown menu elements rendered by `Autocomplete`. + */ + wrapperProps: PropTypes.object, + /** + * This is a shorthand for `wrapperProps={{ style: }}`. + * Note that `wrapperStyle` is applied before `wrapperProps`, so the latter + * will win if it contains a `style` entry. + */ + wrapperStyle: PropTypes.object, + /** + * Whether or not to automatically highlight the top match in the dropdown + * menu. + */ + autoHighlight: PropTypes.bool, + /** + * Whether or not to automatically select the highlighted item when the + * `` loses focus. + */ + selectOnBlur: PropTypes.bool, + /** + * Arguments: `isOpen: Boolean` + * + * Invoked every time the dropdown menu's visibility changes (i.e. every + * time it is displayed/hidden). + */ + onMenuVisibilityChange: PropTypes.func, + /** + * Used to override the internal logic which displays/hides the dropdown + * menu. This is useful if you want to force a certain state based on your + * UX/business logic. Use it together with `onMenuVisibilityChange` for + * fine-grained control over the dropdown menu dynamics. + */ + open: PropTypes.bool, + debug: PropTypes.bool, + }; + + static defaultProps = { + value: '', + wrapperProps: {}, + wrapperStyle: { + display: 'inline-block', + }, + inputProps: {}, + renderInput(props) { + return ; + }, + onChange() {}, + onSelect() {}, + renderMenu(items, value, style) { + return
; + }, + menuStyle: { + borderRadius: '3px', + boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)', + background: 'rgba(255, 255, 255, 0.9)', + padding: '2px 0', + fontSize: '90%', + position: 'fixed', + overflow: 'auto', + maxHeight: '50%', // TODO: don't cheat, let it flow to the bottom, + }, + autoHighlight: true, + selectOnBlur: false, + onMenuVisibilityChange() {}, + }; + + constructor(props) { + super(props); + this.state = { + isOpen: false, + highlightedIndex: null, + }; + this._debugStates = []; + this.ensureHighlightedIndex = this.ensureHighlightedIndex.bind(this); + this.exposeAPI = this.exposeAPI.bind(this); + this.handleInputFocus = this.handleInputFocus.bind(this); + this.handleInputBlur = this.handleInputBlur.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleInputClick = this.handleInputClick.bind(this); + this.maybeAutoCompleteText = this.maybeAutoCompleteText.bind(this); + } + + componentWillMount() { + // this.refs is frozen, so we need to assign a new object to it + this.refs = {}; + this._ignoreBlur = false; + this._ignoreFocus = false; + this._scrollOffset = null; + this._scrollTimer = null; + } + + componentWillUnmount() { + clearTimeout(this._scrollTimer); + this._scrollTimer = null; + } + + componentWillReceiveProps(nextProps) { + if (this.state.highlightedIndex !== null) { + this.setState(this.ensureHighlightedIndex); + } + if ( + nextProps.autoHighlight && + (this.props.value !== nextProps.value || this.state.highlightedIndex === null) + ) { + this.setState(this.maybeAutoCompleteText); + } + } + + componentDidMount() { + if (this.isOpen()) { + this.setMenuPositions(); + } + } + + componentDidUpdate(prevProps, prevState) { + if ( + (this.state.isOpen && !prevState.isOpen) || + ('open' in this.props && this.props.open && !prevProps.open) + ) + this.setMenuPositions(); + + this.maybeScrollItemIntoView(); + if (prevState.isOpen !== this.state.isOpen) { + this.props.onMenuVisibilityChange(this.state.isOpen); + } + } + + exposeAPI(el) { + this.refs.input = el; + IMPERATIVE_API.forEach(ev => (this[ev] = el && el[ev] && el[ev].bind(el))); + } + + maybeScrollItemIntoView() { + if (this.isOpen() && this.state.highlightedIndex !== null) { + const itemNode = this.refs[`item-${this.state.highlightedIndex}`]; + const menuNode = this.refs.menu; + scrollIntoView(findDOMNode(itemNode), findDOMNode(menuNode), { + onlyScrollIfNeeded: true, + }); + } + } + + handleKeyDown(event) { + if (Autocomplete.keyDownHandlers[event.key]) + Autocomplete.keyDownHandlers[event.key].call(this, event); + else if (!this.isOpen()) { + this.setState({ + isOpen: true, + }); + } + } + + handleChange(event) { + this.props.onChange(event, event.target.value); + } + + static keyDownHandlers = { + ArrowDown(event) { + event.preventDefault(); + const itemsLength = this.getFilteredItems(this.props).length; + if (!itemsLength) return; + const { highlightedIndex } = this.state; + const index = + highlightedIndex === null || highlightedIndex === itemsLength - 1 + ? 0 + : highlightedIndex + 1; + this.setState({ + highlightedIndex: index, + isOpen: true, + }); + }, + + ArrowUp(event) { + event.preventDefault(); + const itemsLength = this.getFilteredItems(this.props).length; + if (!itemsLength) return; + const { highlightedIndex } = this.state; + const index = + highlightedIndex === 0 || highlightedIndex === null + ? itemsLength - 1 + : highlightedIndex - 1; + this.setState({ + highlightedIndex: index, + isOpen: true, + }); + }, + + Enter(event) { + // Key code 229 is used for selecting items from character selectors (Pinyin, Kana, etc) + if (event.keyCode !== 13) return; + if (!this.isOpen()) { + // menu is closed so there is no selection to accept -> do nothing + } else if (this.state.highlightedIndex == null) { + // input has focus but no menu item is selected + enter is hit -> close the menu, highlight whatever's in input + this.setState( + { + isOpen: false, + }, + () => { + this.refs.input.select(); + } + ); + } else { + // text entered + menu item has been highlighted + enter is hit -> update value to that of selected menu item, close the menu + event.preventDefault(); + const item = this.getFilteredItems(this.props)[this.state.highlightedIndex]; + const value = this.props.getItemValue(item); + this.setState( + { + isOpen: false, + highlightedIndex: null, + }, + () => { + // this.refs.input.focus() // TODO: file issue + this.refs.input.setSelectionRange(value.length, value.length); + this.props.onSelect(value, item); + } + ); + } + }, + + Escape() { + // In case the user is currently hovering over the menu + this.setIgnoreBlur(false); + this.setState({ + highlightedIndex: null, + isOpen: false, + }); + }, + + Tab() { + // In case the user is currently hovering over the menu + this.setIgnoreBlur(false); + }, + }; + + getFilteredItems(props) { + let items = props.items; + + if (props.shouldItemRender) { + items = items.filter(item => props.shouldItemRender(item, props.value)); + } + + if (props.sortItems) { + items.sort((a, b) => props.sortItems(a, b, props.value)); + } + + return items; + } + + maybeAutoCompleteText(state, props) { + const { highlightedIndex } = state; + const { value, getItemValue } = props; + const index = highlightedIndex === null ? 0 : highlightedIndex; + const matchedItem = this.getFilteredItems(props)[index]; + if (value !== '' && matchedItem) { + const itemValue = getItemValue(matchedItem); + const itemValueDoesMatch = + itemValue.toLowerCase().indexOf( + value.toLowerCase() + // below line is the the only thing that is changed from the real component + ) !== -1; + if (itemValueDoesMatch) { + return { highlightedIndex: index }; + } + } + return { highlightedIndex: null }; + } + + ensureHighlightedIndex(state, props) { + if (state.highlightedIndex >= this.getFilteredItems(props).length) { + return { highlightedIndex: null }; + } + } + + setMenuPositions() { + const node = this.refs.input; + const rect = node.getBoundingClientRect(); + const computedStyle = global.window.getComputedStyle(node); + const marginBottom = parseInt(computedStyle.marginBottom, 10) || 0; + const marginLeft = parseInt(computedStyle.marginLeft, 10) || 0; + const marginRight = parseInt(computedStyle.marginRight, 10) || 0; + this.setState({ + menuTop: rect.bottom + marginBottom, + menuLeft: rect.left + marginLeft, + menuWidth: rect.width + marginLeft + marginRight, + }); + } + + highlightItemFromMouse(index) { + this.setState({ highlightedIndex: index }); + } + + selectItemFromMouse(item) { + const value = this.props.getItemValue(item); + // The menu will de-render before a mouseLeave event + // happens. Clear the flag to release control over focus + this.setIgnoreBlur(false); + this.setState( + { + isOpen: false, + highlightedIndex: null, + }, + () => { + this.props.onSelect(value, item); + } + ); + } + + setIgnoreBlur(ignore) { + this._ignoreBlur = ignore; + } + + renderMenu() { + const items = this.getFilteredItems(this.props).map((item, index) => { + const element = this.props.renderItem(item, this.state.highlightedIndex === index, { + cursor: 'default', + }); + return React.cloneElement(element, { + onMouseEnter: () => this.highlightItemFromMouse(index), + onClick: () => this.selectItemFromMouse(item), + ref: e => (this.refs[`item-${index}`] = e), + }); + }); + const style = { + left: this.state.menuLeft, + top: this.state.menuTop, + minWidth: this.state.menuWidth, + }; + const menu = this.props.renderMenu(items, this.props.value, style); + return React.cloneElement(menu, { + ref: e => (this.refs.menu = e), + // Ignore blur to prevent menu from de-rendering before we can process click + onMouseEnter: () => this.setIgnoreBlur(true), + onMouseLeave: () => this.setIgnoreBlur(false), + }); + } + + handleInputBlur(event) { + if (this._ignoreBlur) { + this._ignoreFocus = true; + this._scrollOffset = getScrollOffset(); + this.refs.input.focus(); + return; + } + let setStateCallback; + const { highlightedIndex } = this.state; + if (this.props.selectOnBlur && highlightedIndex !== null) { + const items = this.getFilteredItems(this.props); + const item = items[highlightedIndex]; + const value = this.props.getItemValue(item); + setStateCallback = () => this.props.onSelect(value, item); + } + this.setState( + { + isOpen: false, + highlightedIndex: null, + }, + setStateCallback + ); + const { onBlur } = this.props.inputProps; + if (onBlur) { + onBlur(event); + } + } + + handleInputFocus(event) { + if (this._ignoreFocus) { + this._ignoreFocus = false; + const { x, y } = this._scrollOffset; + this._scrollOffset = null; + // Focus will cause the browser to scroll the into view. + // This can cause the mouse coords to change, which in turn + // could cause a new highlight to happen, cancelling the click + // event (when selecting with the mouse) + window.scrollTo(x, y); + // Some browsers wait until all focus event handlers have been + // processed before scrolling the into view, so let's + // scroll again on the next tick to ensure we're back to where + // the user was before focus was lost. We could do the deferred + // scroll only, but that causes a jarring split second jump in + // some browsers that scroll before the focus event handlers + // are triggered. + clearTimeout(this._scrollTimer); + this._scrollTimer = setTimeout(() => { + this._scrollTimer = null; + window.scrollTo(x, y); + }, 0); + return; + } + this.setState({ isOpen: true }); + const { onFocus } = this.props.inputProps; + if (onFocus) { + onFocus(event); + } + } + + isInputFocused() { + const el = this.refs.input; + return el.ownerDocument && el === el.ownerDocument.activeElement; + } + + handleInputClick() { + // Input will not be focused if it's disabled + if (this.isInputFocused() && !this.isOpen()) this.setState({ isOpen: true }); + } + + composeEventHandlers(internal, external) { + return external + ? e => { + internal(e); + external(e); + } + : internal; + } + + isOpen() { + return 'open' in this.props ? this.props.open : this.state.isOpen; + } + + render() { + if (this.props.debug) { + // you don't like it, you love it + this._debugStates.push({ + id: this._debugStates.length, + state: this.state, + }); + } + + const { inputProps, items } = this.props; + + const open = this.isOpen(); + return ( +
+ {this.props.renderInput({ + ...inputProps, + role: 'combobox', + 'aria-autocomplete': 'list', + 'aria-expanded': open, + autoComplete: 'off', + ref: this.exposeAPI, + onFocus: this.handleInputFocus, + onBlur: this.handleInputBlur, + onChange: this.handleChange, + onKeyDown: this.composeEventHandlers(this.handleKeyDown, inputProps.onKeyDown), + onClick: this.composeEventHandlers(this.handleInputClick, inputProps.onClick), + value: this.props.value, + })} + {open && !!items.length && this.renderMenu()} + {this.props.debug && ( +
+            {JSON.stringify(
+              this._debugStates.slice(
+                Math.max(0, this._debugStates.length - 5),
+                this._debugStates.length
+              ),
+              null,
+              2
+            )}
+          
+ )} +
+ ); + } +} diff --git a/src/renderer/component/wunderbar/view.jsx b/src/renderer/component/wunderbar/view.jsx index f332a11ec..27fdd5e17 100644 --- a/src/renderer/component/wunderbar/view.jsx +++ b/src/renderer/component/wunderbar/view.jsx @@ -1,166 +1,116 @@ +// @flow import React from 'react'; -import PropTypes from 'prop-types'; -import lbryuri from 'lbryuri.js'; -import Icon from 'component/icon'; -import { parseQueryParams } from 'util/query_params'; +import lbryuri from 'lbryuri'; +import classnames from 'classnames'; +import Autocomplete from './internal/autocomplete'; -class WunderBar extends React.PureComponent { - static TYPING_TIMEOUT = 800; +type Props = { + updateSearchQuery: string => void, + getSearchSuggestions: string => void, + onSearch: string => void, + onSubmit: string => void, + searchQuery: ?string, + isActive: boolean, + address: ?string, + suggestions: Array, +}; - static propTypes = { - onSearch: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - this._userTypingTimer = null; - this._isSearchDispatchPending = false; - this._input = null; - this._stateBeforeSearch = null; - this._resetOnNextBlur = true; - this.onChange = this.onChange.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); - this.onReceiveRef = this.onReceiveRef.bind(this); - this.state = { - address: this.props.address, - icon: this.props.icon, - }; +class WunderBar extends React.PureComponent { + constructor() { + super(); + (this: any).handleSubmit = this.handleSubmit.bind(this); + (this: any).handleChange = this.handleChange.bind(this); + (this: any).focus = this.focus.bind(this); + this.input = undefined; } - componentWillUnmount() { - if (this.userTypingTimer) { - clearTimeout(this._userTypingTimer); + input: ?HTMLInputElement; + + handleChange(e: SyntheticInputEvent<*>) { + const { updateSearchQuery, getSearchSuggestions } = this.props; + const { value } = e.target; + + updateSearchQuery(value); + getSearchSuggestions(value); + } + + focus() { + const { input } = this; + if (input) { + input.focus(); } } - onChange(event) { - if (this._userTypingTimer) { - clearTimeout(this._userTypingTimer); + handleSubmit(value: string) { + if (!value) { + return; } - this.setState({ address: event.target.value }); + const { onSubmit, onSearch } = this.props; - this._isSearchDispatchPending = true; + // if they choose the "search for {value}" in the suggestions + // it will contain the {query}?search + const choseDoSuggestedSearch = value.endsWith('?search'); - const searchQuery = event.target.value; - - this._userTypingTimer = setTimeout(() => { - const hasQuery = searchQuery.length === 0; - this._resetOnNextBlur = hasQuery; - this._isSearchDispatchPending = false; - if (searchQuery) { - this.props.onSearch(searchQuery.trim()); - } - }, WunderBar.TYPING_TIMEOUT); // 800ms delay, tweak for faster/slower - } - - componentWillReceiveProps(nextProps) { - if ( - nextProps.viewingPage !== this.props.viewingPage || - nextProps.address != this.props.address - ) { - this.setState({ address: nextProps.address, icon: nextProps.icon }); + let searchValue = value; + if (choseDoSuggestedSearch) { + searchValue = value.slice(0, -7); // trim off ?search } - } - onFocus() { - this._stateBeforeSearch = this.state; - const newState = { - icon: 'icon-search', - isActive: true, - }; - - this._focusPending = true; - // below is hacking, improved when we have proper routing - if (!this.state.address.startsWith('lbry://') && this.state.icon !== 'icon-search') { - // onFocus, if they are not on an exact URL or a search page, clear the bar - newState.address = ''; + if (this.input) { + this.input.blur(); } - this.setState(newState); - } - onBlur() { - if (this._isSearchDispatchPending) { - setTimeout(() => { - this.onBlur(); - }, WunderBar.TYPING_TIMEOUT + 1); - } else { - const commonState = { isActive: false }; - if (this._resetOnNextBlur) { - this.setState(Object.assign({}, this._stateBeforeSearch, commonState)); - this._input.value = this.state.address; - } else { - this._resetOnNextBlur = true; - this._stateBeforeSearch = this.state; - this.setState(commonState); - } + try { + const uri = lbryuri.normalize(value); + onSubmit(uri); + } catch (e) { + // search query isn't a valid uri + onSearch(searchValue); } } - componentDidUpdate() { - if (this._input) { - const start = this._input.selectionStart, - end = this._input.selectionEnd; - - this._input.value = this.state.address; // this causes cursor to go to end of input - - this._input.setSelectionRange(start, end); - - if (this._focusPending) { - this._input.select(); - this._focusPending = false; - } - } - } - - onKeyPress(event) { - if (event.charCode == 13 && this._input.value) { - let uri = null, - method = 'onSubmit', - extraParams = {}; - - this._resetOnNextBlur = false; - clearTimeout(this._userTypingTimer); - - const parts = this._input.value.trim().split('?'); - const value = parts.shift(); - if (parts.length > 0) extraParams = parseQueryParams(parts.join('')); - - try { - uri = lbryuri.normalize(value); - this.setState({ value: uri }); - } catch (error) { - // then it's not a valid URL, so let's search - uri = value; - method = 'onSearch'; - } - - this.props[method](uri, extraParams); - this._input.blur(); - } - } - - onReceiveRef(ref) { - this._input = ref; - } - render() { + const { searchQuery, isActive, address, suggestions } = this.props; + + // if we are on the file/channel page + // use the address in the history stack + const wunderbarValue = isActive ? searchQuery : searchQuery || address; + return ( -
- {this.state.icon ? : ''} - + { + this.input = ref; + }} + wrapperStyle={{ flex: 1, minHeight: 0 }} + value={wunderbarValue} + items={suggestions} + getItemValue={item => item.value} + onChange={this.handleChange} + onSelect={this.handleSubmit} + renderInput={props => ( + + )} + renderItem={(item, isHighlighted) => ( +
+ {item.label} +
+ )} />
); diff --git a/src/renderer/constants/action_types.js b/src/renderer/constants/action_types.js index 3c828f5cb..c1e6fc525 100644 --- a/src/renderer/constants/action_types.js +++ b/src/renderer/constants/action_types.js @@ -91,6 +91,10 @@ export const FILE_DELETE = 'FILE_DELETE'; export const SEARCH_STARTED = 'SEARCH_STARTED'; export const SEARCH_COMPLETED = 'SEARCH_COMPLETED'; export const SEARCH_CANCELLED = 'SEARCH_CANCELLED'; +export const UPDATE_SEARCH_QUERY = 'UPDATE_SEARCH_QUERY'; +export const GET_SEARCH_SUGGESTIONS_START = 'GET_SEARCH_SUGGESTIONS_START'; +export const GET_SEARCH_SUGGESTIONS_SUCCESS = 'GET_SEARCH_SUGGESTIONS_SUCCESS'; +export const GET_SEARCH_SUGGESTIONS_FAIL = 'GET_SEARCH_SUGGESTIONS_FAIL'; // Settings export const DAEMON_SETTINGS_RECEIVED = 'DAEMON_SETTINGS_RECEIVED'; diff --git a/src/renderer/index.js b/src/renderer/index.js index fd6dcc1f7..ad2db3927 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -31,7 +31,7 @@ ipcRenderer.on('open-uri-requested', (event, uri, newSession) => { try { verification = JSON.parse(atob(uri.substring(15))); } catch (error) { - console.log(error); + console.log(error); // eslint-disable-line no-console } if (verification.token && verification.recaptcha) { app.store.dispatch(doConditionalAuthNavigate(newSession)); @@ -112,10 +112,10 @@ const init = () => { ReactDOM.render( -
+ -
+
, document.getElementById('app') ); diff --git a/src/renderer/page/channel/view.jsx b/src/renderer/page/channel/view.jsx index f32a9f938..7ce61b182 100644 --- a/src/renderer/page/channel/view.jsx +++ b/src/renderer/page/channel/view.jsx @@ -1,3 +1,4 @@ +/* eslint-disable */ import React from 'react'; import lbryuri from 'lbryuri'; import { BusyMessage } from 'component/common'; @@ -5,6 +6,7 @@ import FileTile from 'component/fileTile'; import ReactPaginate from 'react-paginate'; import Link from 'component/link'; import SubscribeButton from 'component/subscribeButton'; +import Page from 'component/page'; class ChannelPage extends React.PureComponent { componentDidMount() { @@ -70,7 +72,7 @@ class ChannelPage extends React.PureComponent { } return ( -
+
@@ -107,9 +109,10 @@ class ChannelPage extends React.PureComponent { containerClassName="pagination" /> )} -
+ ); } } export default ChannelPage; +/* eslint-enable */ diff --git a/src/renderer/page/discover/view.jsx b/src/renderer/page/discover/view.jsx index 52b841bab..28adf330c 100644 --- a/src/renderer/page/discover/view.jsx +++ b/src/renderer/page/discover/view.jsx @@ -1,259 +1,37 @@ +// @flow import React from 'react'; -import ReactDOM from 'react-dom'; -import lbryuri from 'lbryuri'; -import FileCard from 'component/fileCard'; -import { BusyMessage } from 'component/common.js'; -import Icon from 'component/icon'; -import ToolTip from 'component/tooltip.js'; -import SubHeader from 'component/subHeader'; -import classnames from 'classnames'; -import Link from 'component/link'; +import Page from 'component/page'; +import CategoryList from 'component/common/category-list'; -// This should be in a separate file -export class FeaturedCategory extends React.PureComponent { - constructor() { - super(); +type Props = { + fetchFeaturedUris: () => void, + fetchingFeaturedUris: boolean, + featuredUris: {}, +}; - this.state = { - numItems: undefined, - canScrollPrevious: false, - canScrollNext: false, - }; - } - - componentWillMount() { - this.setState({ - numItems: this.props.names.length, - }); - } - - componentDidMount() { - const cardRow = ReactDOM.findDOMNode(this.refs.rowitems); - const cards = cardRow.getElementsByTagName('section'); - - // check if the last card is visible - const lastCard = cards[cards.length - 1]; - const isCompletelyVisible = this.isCardVisible(lastCard, cardRow, false); - - if (!isCompletelyVisible) { - this.setState({ - canScrollNext: true, - }); - } - } - - handleScrollPrevious() { - const cardRow = ReactDOM.findDOMNode(this.refs.rowitems); - if (cardRow.scrollLeft > 0) { - // check the visible cards - const cards = cardRow.getElementsByTagName('section'); - let firstVisibleCard = null; - let firstVisibleIdx = -1; - for (let i = 0; i < cards.length; i++) { - if (this.isCardVisible(cards[i], cardRow, false)) { - firstVisibleCard = cards[i]; - firstVisibleIdx = i; - break; - } - } - - const numDisplayed = this.numDisplayedCards(cardRow); - const scrollToIdx = firstVisibleIdx - numDisplayed; - const animationCallback = () => { - this.setState({ - canScrollPrevious: cardRow.scrollLeft !== 0, - canScrollNext: true, - }); - }; - this.scrollCardItemsLeftAnimated( - cardRow, - scrollToIdx < 0 ? 0 : cards[scrollToIdx].offsetLeft, - 100, - animationCallback - ); - } - } - - handleScrollNext() { - const cardRow = ReactDOM.findDOMNode(this.refs.rowitems); - - // check the visible cards - const cards = cardRow.getElementsByTagName('section'); - let lastVisibleCard = null; - let lastVisibleIdx = -1; - for (let i = 0; i < cards.length; i++) { - if (this.isCardVisible(cards[i], cardRow, true)) { - lastVisibleCard = cards[i]; - lastVisibleIdx = i; - } - } - - if (lastVisibleCard) { - const numDisplayed = this.numDisplayedCards(cardRow); - const animationCallback = () => { - // update last visible index after scroll - for (let i = 0; i < cards.length; i++) { - if (this.isCardVisible(cards[i], cardRow, true)) { - lastVisibleIdx = i; - } - } - - this.setState({ canScrollPrevious: true }); - if (lastVisibleIdx === cards.length - 1) { - this.setState({ canScrollNext: false }); - } - }; - - this.scrollCardItemsLeftAnimated( - cardRow, - Math.min(lastVisibleCard.offsetLeft, cardRow.scrollWidth - cardRow.clientWidth), - 100, - animationCallback - ); - } - } - - scrollCardItemsLeftAnimated(cardRow, target, duration, callback) { - if (!duration || duration <= diff) { - cardRow.scrollLeft = target; - if (callback) { - callback(); - } - return; - } - - const component = this; - const diff = target - cardRow.scrollLeft; - const tick = diff / duration * 10; - setTimeout(() => { - cardRow.scrollLeft += tick; - if (cardRow.scrollLeft === target) { - if (callback) { - callback(); - } - return; - } - component.scrollCardItemsLeftAnimated(cardRow, target, duration - 10, callback); - }, 10); - } - - isCardVisible(section, cardRow, partialVisibility) { - // check if a card is fully or partialy visible in its parent - const cardRowWidth = cardRow.offsetWidth; - const cardRowLeft = cardRow.scrollLeft; - const cardRowEnd = cardRowLeft + cardRow.offsetWidth; - const sectionLeft = section.offsetLeft - cardRowLeft; - const sectionEnd = sectionLeft + section.offsetWidth; - - return ( - (sectionLeft >= 0 && sectionEnd <= cardRowWidth) || - (((sectionLeft < 0 && sectionEnd > 0) || (sectionLeft > 0 && sectionLeft <= cardRowWidth)) && - partialVisibility) - ); - } - - numDisplayedCards(cardRow) { - const cards = cardRow.getElementsByTagName('section'); - const cardRowWidth = cardRow.offsetWidth; - // get the width of the first card and then calculate - const cardWidth = cards.length > 0 ? cards[0].offsetWidth : 0; - - if (cardWidth > 0) { - return Math.ceil(cardRowWidth / cardWidth); - } - - // return a default value of 1 card displayed if the card width couldn't be determined - return 1; - } - - render() { - const { category, names, categoryLink } = this.props; - - return ( -
-

- {categoryLink ? ( - - ) : ( - category - )} - - {category && - category.match(/^community/i) && ( - - )} -

-
- {this.state.canScrollPrevious && ( -
- - - -
- )} - {this.state.canScrollNext && ( -
- - - -
- )} -
- {names && - names.map(name => ( - - ))} -
-
-
- ); - } -} - -class DiscoverPage extends React.PureComponent { +class DiscoverPage extends React.PureComponent { componentWillMount() { this.props.fetchFeaturedUris(); } render() { const { featuredUris, fetchingFeaturedUris } = this.props; - const hasContent = typeof featuredUris === 'object' && Object.keys(featuredUris).length, - failedToLoad = !fetchingFeaturedUris && !hasContent; + const hasContent = typeof featuredUris === 'object' && Object.keys(featuredUris).length; + const failedToLoad = !fetchingFeaturedUris && !hasContent; return ( -
- - {!hasContent && fetchingFeaturedUris && } + {hasContent && Object.keys(featuredUris).map( category => featuredUris[category].length ? ( - + ) : ( '' ) )} {failedToLoad &&
{__('Failed to load landing content.')}
} -
+ ); } } diff --git a/src/renderer/page/file/view.jsx b/src/renderer/page/file/view.jsx index fb8f741c8..d9a224c84 100644 --- a/src/renderer/page/file/view.jsx +++ b/src/renderer/page/file/view.jsx @@ -1,3 +1,4 @@ +/* eslint-disable */ import React from 'react'; import lbry from 'lbry'; import lbryuri from 'lbryuri'; @@ -6,12 +7,13 @@ import { Thumbnail } from 'component/common'; import FilePrice from 'component/filePrice'; import FileDetails from 'component/fileDetails'; import UriIndicator from 'component/uriIndicator'; -import Icon from 'component/icon'; +import Icon from 'component/common/icon'; import WalletSendTip from 'component/walletSendTip'; import DateTime from 'component/dateTime'; import * as icons from 'constants/icons'; import Link from 'component/link'; import SubscribeButton from 'component/subscribeButton'; +import Page from 'component/page'; class FilePage extends React.PureComponent { componentDidMount() { @@ -69,49 +71,52 @@ class FilePage extends React.PureComponent { } return ( -
-
- {isPlayable ? ( -
-
- {(!tab || tab === 'details') && ( -
- {' '} -
- {!fileInfo || fileInfo.written_bytes <= 0 ? ( - - - {isRewardContent && ( - - {' '} - - - )} - - ) : null} -

{title}

-
- - - Published on - + +
+
+ {isPlayable ? ( +
+
+ {(!tab || tab === 'details') && ( +
+ {' '} +
+ {!fileInfo || fileInfo.written_bytes <= 0 ? ( + + + {isRewardContent && ( + + {' '} + + + )} + + ) : null} +

{title}

+
+ + + Published on + +
+ +
- - -
- )} - {tab === 'tip' && } -
-
+ )} + {tab === 'tip' && } +
+
+
); } } export default FilePage; +/* eslint-enable */ diff --git a/src/renderer/page/search/view.jsx b/src/renderer/page/search/view.jsx index 48aed6fce..c3cd0cc7e 100644 --- a/src/renderer/page/search/view.jsx +++ b/src/renderer/page/search/view.jsx @@ -1,15 +1,21 @@ +// @flow import React from 'react'; import lbryuri from 'lbryuri'; import FileTile from 'component/fileTile'; import FileListSearch from 'component/fileListSearch'; -import { ToolTip } from 'component/tooltip.js'; +import ToolTip from 'component/common/tooltip'; +import Page from 'component/page'; -class SearchPage extends React.PureComponent { +type Props = { + query: ?string, +}; + +class SearchPage extends React.PureComponent { render() { const { query } = this.props; return ( -
+ {lbryuri.isValid(query) ? (

@@ -36,7 +42,7 @@ class SearchPage extends React.PureComponent {

-
+ ); } } diff --git a/src/renderer/page/show/view.jsx b/src/renderer/page/show/view.jsx index ebb45da30..60a1fd06f 100644 --- a/src/renderer/page/show/view.jsx +++ b/src/renderer/page/show/view.jsx @@ -1,17 +1,24 @@ +/* eslint-disable */ import React from 'react'; -import lbryuri from 'lbryuri'; import { BusyMessage } from 'component/common'; import ChannelPage from 'page/channel'; import FilePage from 'page/file'; -class ShowPage extends React.PureComponent { +type Props = { + isResolvingUri: boolean, + resolveUri: string => void, + uri: string, + claim: { name: string }, +}; + +class ShowPage extends React.PureComponent { componentWillMount() { const { isResolvingUri, resolveUri, uri } = this.props; if (!isResolvingUri) resolveUri(uri); } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { const { isResolvingUri, resolveUri, claim, uri } = nextProps; if (!isResolvingUri && claim === undefined && uri) { @@ -47,8 +54,9 @@ class ShowPage extends React.PureComponent { innerContent = ; } - return
{innerContent}
; + return innerContent; } } export default ShowPage; +/* eslint-enable */ diff --git a/src/renderer/page/subscriptions/view.jsx b/src/renderer/page/subscriptions/view.jsx index 3e09748a5..3c4e6e61a 100644 --- a/src/renderer/page/subscriptions/view.jsx +++ b/src/renderer/page/subscriptions/view.jsx @@ -1,8 +1,8 @@ // @flow import React from 'react'; import SubHeader from 'component/subHeader'; -import { BusyMessage } from 'component/common.js'; -import { FeaturedCategory } from 'page/discover/view'; +import { BusyMessage } from 'component/common'; +import CategoryList from 'component/common/category-list'; import type { Subscription } from 'redux/reducers/subscriptions'; type SavedSubscriptions = Array; @@ -83,7 +83,7 @@ export default class extends React.PureComponent { } return ( - + response.status === 200 + ? Promise.resolve(response.json()) + : Promise.reject(new Error(response.statusText)); + // eslint-disable-next-line import/prefer-default-export -export function doSearch(rawQuery) { - return (dispatch, getState) => { - const state = getState(); - const page = selectCurrentPage(state); +export const doSearch = rawQuery => (dispatch, getState) => { + const state = getState(); + const page = selectCurrentPage(state); - const query = rawQuery.replace(/^lbry:\/\//i, ''); - - if (!query) { - dispatch({ - type: ACTIONS.SEARCH_CANCELLED, - }); - return; - } + const query = rawQuery.replace(/^lbry:\/\//i, ''); + if (!query) { dispatch({ - type: ACTIONS.SEARCH_STARTED, - data: { query }, + type: ACTIONS.SEARCH_CANCELLED, }); + return; + } - if (page !== 'search') { - dispatch(doNavigate('search', { query })); - } else { - fetch(`https://lighthouse.lbry.io/search?s=${query}`) - .then( - response => - response.status === 200 - ? Promise.resolve(response.json()) - : Promise.reject(new Error(response.statusText)) - ) - .then(data => { - const uris = []; - const actions = []; + dispatch({ + type: ACTIONS.SEARCH_STARTED, + data: { query }, + }); - data.forEach(result => { - const uri = Lbryuri.build({ - name: result.name, - claimId: result.claimId, - }); - actions.push(doResolveUri(uri)); - uris.push(uri); - }); + if (page !== 'search') { + dispatch(doNavigate('search', { query })); + } else { + fetch(`https://lighthouse.lbry.io/search?s=${query}`) + .then(handleResponse) + .then(data => { + const uris = []; + const actions = []; - actions.push({ - type: ACTIONS.SEARCH_COMPLETED, - data: { - query, - uris, - }, - }); - dispatch(batchActions(...actions)); - }) - .catch(() => { - dispatch({ - type: ACTIONS.SEARCH_CANCELLED, + data.forEach(result => { + const uri = Lbryuri.build({ + name: result.name, + claimId: result.claimId, }); + actions.push(doResolveUri(uri)); + uris.push(uri); }); - } - }; -} + + actions.push({ + type: ACTIONS.SEARCH_COMPLETED, + data: { + query, + uris, + }, + }); + dispatch(batchActions(...actions)); + }) + .catch(() => { + dispatch({ + type: ACTIONS.SEARCH_CANCELLED, + }); + }); + } +}; + +export const updateSearchQuery = searchQuery => ({ + type: ACTIONS.UPDATE_SEARCH_QUERY, + data: { searchQuery }, +}); + +export const getSearchSuggestions = value => dispatch => { + dispatch({ type: ACTIONS.GET_SEARCH_SUGGESTIONS_START }); + if (!value) { + dispatch({ + type: ACTIONS.GET_SEARCH_SUGGESTIONS_SUCCESS, + data: [], + }); + return; + } + + // This should probably be more robust + let searchValue = value; + if (searchValue.startsWith('lbry://')) { + searchValue = searchValue.slice(7); + } + + // need to handle spaces in the query? + fetch(`https://lighthouse.lbry.io/autocomplete?s=${searchValue}`) + .then(handleResponse) + .then(suggestions => { + const formattedSuggestions = suggestions.slice(0, 5).map(suggestion => ({ + label: suggestion, + value: suggestion, + })); + + // Should we add lbry://{query} as the first result? + // If it's not a valid uri, then add a "search for {query}" result + const searchLabel = `Search for "${value}"`; + try { + const uri = Lbryuri.normalize(value); + formattedSuggestions.unshift( + { label: uri, value: uri }, + { label: searchLabel, value: `${value}?search` } + ); + } catch (e) { + if (value) { + formattedSuggestions.unshift({ label: searchLabel, value }); + } + } + + return dispatch({ + type: ACTIONS.GET_SEARCH_SUGGESTIONS_SUCCESS, + data: formattedSuggestions, + }); + }) + .catch(err => + dispatch({ + type: ACTIONS.GET_SEARCH_SUGGESTIONS_FAIL, + data: err, + }) + ); +}; diff --git a/src/renderer/redux/reducers/search.js b/src/renderer/redux/reducers/search.js index b3a0754e4..b6474bfe0 100644 --- a/src/renderer/redux/reducers/search.js +++ b/src/renderer/redux/reducers/search.js @@ -1,32 +1,75 @@ +// @flow import * as ACTIONS from 'constants/action_types'; +import { handleActions } from 'util/redux-utils'; -const reducers = {}; -const defaultState = { +type SearchState = { + isActive: boolean, + searchQuery: string, + searchingForSuggestions: boolean, + suggestions: Array, urisByQuery: {}, - searching: false, }; -reducers[ACTIONS.SEARCH_STARTED] = state => - Object.assign({}, state, { - searching: true, - }); - -reducers[ACTIONS.SEARCH_COMPLETED] = (state, action) => { - const { query, uris } = action.data; - - return Object.assign({}, state, { - searching: false, - urisByQuery: Object.assign({}, state.urisByQuery, { [query]: uris }), - }); +const defaultState = { + isActive: false, + searchQuery: '', // needs to be an empty string for input focusing + searchingForSuggestions: false, + suggestions: [], + urisByQuery: {}, }; -reducers[ACTIONS.SEARCH_CANCELLED] = state => - Object.assign({}, state, { - searching: false, - }); +export default handleActions( + { + [ACTIONS.SEARCH_STARTED]: (state: SearchState): SearchState => ({ + ...state, + searching: true, + }), + [ACTIONS.SEARCH_COMPLETED]: (state: SearchState, action): SearchState => { + const { query, uris } = action.data; -export default function reducer(state = defaultState, action) { - const handler = reducers[action.type]; - if (handler) return handler(state, action); - return state; -} + return { + ...state, + searching: false, + urisByQuery: Object.assign({}, state.urisByQuery, { [query]: uris }), + }; + }, + + [ACTIONS.SEARCH_CANCELLED]: (state: SearchState): SearchState => ({ + ...state, + searching: false, + }), + + [ACTIONS.UPDATE_SEARCH_QUERY]: (state: SearchState, action): SearchState => ({ + ...state, + searchQuery: action.data.searchQuery, + suggestions: [], + isActive: true, + }), + + [ACTIONS.GET_SEARCH_SUGGESTIONS_START]: (state: SearchState): SearchState => ({ + ...state, + searchingForSuggestions: true, + suggestions: [], + }), + [ACTIONS.GET_SEARCH_SUGGESTIONS_SUCCESS]: (state: SearchState, action): SearchState => ({ + ...state, + searchingForSuggestions: false, + suggestions: action.data, + }), + [ACTIONS.GET_SEARCH_SUGGESTIONS_FAIL]: (state: SearchState): SearchState => ({ + ...state, + searchingForSuggestions: false, + // error, TODO: figure this out on the search page + }), + + // clear the searchQuery on back/forward + // it may be populated by the page title for search/file pages + // if going home, it should be blank + [ACTIONS.HISTORY_NAVIGATE]: (state: SearchState): SearchState => ({ + ...state, + searchQuery: '', + isActive: false, + }), + }, + defaultState +); diff --git a/src/renderer/redux/selectors/navigation.js b/src/renderer/redux/selectors/navigation.js index dc0b507a1..014a0b988 100644 --- a/src/renderer/redux/selectors/navigation.js +++ b/src/renderer/redux/selectors/navigation.js @@ -68,30 +68,6 @@ export const selectPageTitle = createSelector( selectCurrentParams, (page, params) => { switch (page) { - case 'settings': - return __('Settings'); - case 'report': - return __('Report'); - case 'wallet': - return __('Wallet'); - case 'send': - return __('Send or Receive LBRY Credits'); - case 'getcredits': - return __('Get LBRY Credits'); - case 'backup': - return __('Backup Your Wallet'); - case 'rewards': - return __('Rewards'); - case 'invite': - return __('Invites'); - case 'start': - return __('Start'); - case 'publish': - return params.id ? __('Edit') : __('Publish'); - case 'help': - return __('Help'); - case 'developer': - return __('Developer'); case 'show': { const parts = [Lbryuri.normalize(params.uri)]; // If the params has any keys other than "uri" @@ -100,21 +76,14 @@ export const selectPageTitle = createSelector( } return parts.join('?'); } - case 'downloaded': - return __('Downloads & Purchases'); - case 'published': - return __('Publications'); - case 'search': - return params.query ? __('Search results for %s', params.query) : __('Search'); - case 'subscriptions': - return __('Your Subscriptions'); case 'discover': + return __('Discover'); case false: case null: case '': return ''; default: - return page[0].toUpperCase() + (page.length > 0 ? page.substr(1) : ''); + return ''; } } ); diff --git a/src/renderer/redux/selectors/search.js b/src/renderer/redux/selectors/search.js index 4e49f47d8..8eb6c95c0 100644 --- a/src/renderer/redux/selectors/search.js +++ b/src/renderer/redux/selectors/search.js @@ -28,52 +28,15 @@ export const selectWunderBarAddress = createSelector( selectCurrentPage, selectPageTitle, selectSearchQuery, - (page, title, query) => (page !== 'search' ? title : query || title) -); - -export const selectWunderBarIcon = createSelector( - selectCurrentPage, - selectCurrentParams, - (page, params) => { - switch (page) { - case 'auth': - return 'icon-user'; - case 'settings': - return 'icon-gear'; - case 'help': - return 'icon-question'; - case 'report': - return 'icon-file'; - case 'downloaded': - return 'icon-folder'; - case 'published': - return 'icon-folder'; - case 'history': - return 'icon-history'; - case 'send': - return 'icon-send'; - case 'rewards': - return 'icon-rocket'; - case 'invite': - return 'icon-envelope-open'; - case 'getcredits': - return 'icon-shopping-cart'; - case 'wallet': - case 'backup': - return 'icon-bank'; - case 'show': - return 'icon-file'; - case 'publish': - return params.id ? __('icon-pencil') : __('icon-upload'); - case 'developer': - return 'icon-code'; - case 'discover': - case 'search': - return 'icon-search'; - case 'subscriptions': - return 'icon-th-list'; - default: - return 'icon-file'; + (page, title, query) => { + // only populate the wunderbar address if we are on the file/channel pages + // or show the search query + if (page === 'show') { + return title; + } else if (page === 'search') { + return query; } + + return ''; } ); diff --git a/src/renderer/scss/_gui.scss b/src/renderer/scss/_gui.scss index 92b82d146..bc8a138ab 100644 --- a/src/renderer/scss/_gui.scss +++ b/src/renderer/scss/_gui.scss @@ -1,4 +1,54 @@ -@import url(https://fonts.googleapis.com/css?family=Roboto:400,400i,500,500i,700); +// Generic html styles used accross the App +// component specific styling should go in the component scss file + +@font-face { + font-family: 'Metropolis'; + font-weight: normal; + font-style: normal; + text-rendering: optimizeLegibility; + src: url('../../../static/font/metropolis/Metropolis-Medium.woff2') format('woff2'); +} + +@font-face { + font-family: 'Metropolis'; + font-weight: 600; + font-style: normal; + text-rendering: optimizeLegibility; + src: url('../../../static/font/metropolis/Metropolis-SemiBold.woff2') format('woff2'); +} + +// @font-face { +// font-family: 'Metropolis'; +// font-weight: 700; +// font-style: normal; +// text-rendering: optimizeLegibility; +// src: url('../../../static/font/metropolis/Metropolis-SemiBold.woff2') format('woff2'); +// } + +@font-face { + font-family: 'Metropolis'; + font-weight: 800; + font-style: normal; + text-rendering: optimizeLegibility; + src: url('../../../static/font/metropolis/Metropolis-ExtraBold.woff2') format('woff2'); +} + +// TODO: use this +// @font-face { +// font-family: 'Metropolis'; +// font-weight: normal; +// font-style: italic; +// text-rendering: optimizeLegibility; +// src: url('../../../static/font/metropolis/Metropolis-MediumItalic.woff2') format('woff2'); +// } +// +// @font-face { +// font-family: 'Metropolis'; +// font-weight: lighter; +// font-style: normal; +// text-rendering: optimizeLegibility; +// src: url('../../../static/font/metropolis/Metropolis-Light.woff2') format('woff2'); +// } html { height: 100%; @@ -7,84 +57,20 @@ html { body { color: var(--text-color); - font-family: 'Roboto', sans-serif; + font-family: 'Metropolis', sans-serif; line-height: var(--font-line-height); + height: 100%; + overflow: hidden; } -/* Custom text selection */ -*::selection { - background: var(--text-selection-bg); - color: var(--text-selection-color); -} - -#window { - min-height: 100vh; - background: var(--window-bg); -} - -.credit-amount--indicator { - font-weight: 500; - color: var(--color-money); -} -.credit-amount--fee { - font-size: 0.9em; - color: var(--color-meta-light); -} - -.credit-amount--bold { +h1, +h2, +h3, +h4, +h5 { font-weight: 700; } -#main-content { - margin: auto; - display: flex; - flex-direction: column; - overflow: overlay; - padding: $spacing-vertical; - position: absolute; - top: var(--header-height); - bottom: 0; - left: 4px; - right: 4px; - main { - margin-left: auto; - margin-right: auto; - max-width: 100%; - } - main.main--single-column { - width: $width-page-constrained; - } - - main.main--no-margin { - margin-left: 0; - margin-right: 0; - } -} - -.reloading { - &:before { - $width: 30px; - position: absolute; - background: url('../../../static/img/busy.gif') no-repeat center center; - width: $width; - height: $spacing-vertical; - content: ''; - left: 50%; - margin-left: -1 / 2 * $width; - display: inline-block; - } -} - -.icon-fixed-width { - /* This borrowed is from a component of Font Awesome we're not using, maybe add it? */ - width: (18em / 14); - text-align: center; -} - -.icon--left-pad { - padding-left: 3px; -} - h2 { font-size: 1.75em; } @@ -100,11 +86,13 @@ h4 { h5 { font-size: 1.1em; } + sup, sub { vertical-align: baseline; position: relative; } + sup { top: -0.4em; } @@ -117,11 +105,66 @@ code { background-color: var(--color-bg-alt); } -p { - margin-bottom: 0.8em; - &:last-child { - margin-bottom: 0; - } +// Why is this needed? +button { + font-family: inherit; +} + +#window { + height: 100%; + overflow: hidden; // so the scrollbar will not extend into the header +} + +#main-content { + height: 100%; + overflow-y: auto; + position: absolute; + left: 0px; + right: 0px; + // don't use {bottom/top} here + // they cause flashes of un-rendered content when scrolling + margin-top: var(--header-height); + padding-bottom: var(--header-height); // fix this scrollbar extends beyond screen + background-color: var(--color-bg); +} + +.main { + padding: 0 $spacing-vertical * 2/3; +} + +.main--no-padding { + padding-left: 0; + padding-right: 0; +} + +.page__header { + padding: $spacing-vertical * 2/3; + padding-bottom: 0; +} + +.page__title { + font-weight: 800; + font-size: 3em; +} + +/* Custom text selection */ +*::selection { + background: var(--text-selection-bg); + color: var(--text-selection-color); +} + +.credit-amount--indicator { + font-weight: 500; + color: var(--color-money); +} + +.credit-amount--fee { + font-size: 0.9em; + color: var(--color-meta-light); +} + +.credit-amount--bold { + font-weight: 700; } .hidden { @@ -192,7 +235,3 @@ p { section.section-spaced { margin-bottom: $spacing-vertical; } - -.text-center { - text-align: center; -} diff --git a/src/renderer/scss/_icons.scss b/src/renderer/scss/_icons.scss index c589a1ac5..7797614a8 100644 --- a/src/renderer/scss/_icons.scss +++ b/src/renderer/scss/_icons.scss @@ -27,6 +27,16 @@ transform: translate(0, 0); } +.icon--fixed-width { + /* This borrowed is from a component of Font Awesome we're not using, maybe add it? */ + width: (18em / 14); + text-align: center; +} + +.icon--padded { + padding: 0 3px; +} + /* Adjustments for icon size and alignment */ .icon-rocket { color: orangered; diff --git a/src/renderer/scss/_vars.scss b/src/renderer/scss/_vars.scss index 15436a5b1..d93fd2217 100644 --- a/src/renderer/scss/_vars.scss +++ b/src/renderer/scss/_vars.scss @@ -6,6 +6,8 @@ $width-page-constrained: 800px; $text-color: #000; :root { + --spacing-vertical: 24px; + /* Colors */ --color-brand: #155b4a; --color-primary: #155b4a; @@ -21,7 +23,8 @@ $text-color: #000; --color-download: rgba(0, 0, 0, 0.75); --color-canvas: #f5f5f5; --color-bg: #ffffff; - --color-bg-alt: #d9d9d9; + --color-bg-alt: #f6f6f6; + --color-placeholder: #ececec; /* Misc */ --content-max-width: 1000px; @@ -34,7 +37,7 @@ $text-color: #000; --font-size-subtext-multiple: 0.82; /* Shadows */ - --box-shadow-layer: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); + --box-shadow-layer: 0px 1px 3px 0px rgba(0, 0, 0, 0.2); --box-shadow-focus: 2px 4px 4px 0 rgba(0, 0, 0, 0.14), 2px 5px 3px -2px rgba(0, 0, 0, 0.2), 2px 3px 7px 0 rgba(0, 0, 0, 0.12); @@ -50,9 +53,6 @@ $text-color: #000; --text-selection-bg: rgba(saturate(lighten(#155b4a, 20%), 20%), 1); // temp color --text-selection-color: #fff; - /* Window */ - --window-bg: var(--color-canvas); - /* Form */ --form-label-color: rgba(0, 0, 0, 0.54); @@ -80,21 +80,23 @@ $text-color: #000; --select-bg: var(--color-bg-alt); --select-color: var(--text-color); + //TODO: determine proper button variables; /* Button */ - --button-bg: var(--color-bg-alt); - --button-color: #fff; - --button-primary-bg: var(--color-primary); - --button-primary-color: #fff; - --button-padding: $spacing-vertical * 2/3; - --button-height: $spacing-vertical * 1.5; - --button-intra-margin: $spacing-vertical; - --button-radius: 3px; + --btn-primary-color: #fff; + --button-alt-color: var(--text-color); + --btn-primary-bg: var(--color-primary); + --btn-alt-bg: red; + --btn-radius: 10px; + // below needed? + --btn-padding: $spacing-vertical * 2/3; + --btn-height: $spacing-vertical * 1.5; + --btn-intra-margin: $spacing-vertical; /* Header */ --header-bg: var(--color-bg); --header-color: #666; --header-active-color: rgba(0, 0, 0, 0.85); - --header-height: $spacing-vertical * 2.5; + --header-height: 65px; --header-button-bg: transparent; //var(--button-bg); --header-button-hover-bg: rgba(100, 100, 100, 0.15); @@ -142,7 +144,6 @@ $text-color: #000; --tooltip-width: 300px; --tooltip-bg: var(--color-bg); --tooltip-color: var(--text-color); - --tooltip-border: 1px solid #aaa; /* Scrollbar */ --scrollbar-radius: 10px; diff --git a/src/renderer/scss/component/_button.scss b/src/renderer/scss/component/_button.scss index 576ca4c33..0d9df2b23 100644 --- a/src/renderer/scss/component/_button.scss +++ b/src/renderer/scss/component/_button.scss @@ -1,89 +1,100 @@ -@import '../mixin/link.scss'; +// Styles for the