diff options
author | Vitaly Takmazov | 2019-10-29 17:11:07 +0300 |
---|---|---|
committer | Vitaly Takmazov | 2023-01-13 10:37:55 +0300 |
commit | 522a1cdbaf0f478fb23a9f770b9caf732ea13803 (patch) | |
tree | 2baf7497f4c4717ee423c227e2ba24a22b44c27c /vnext/src | |
parent | 713566a435fea6c00cbd2e37d7c8c2a54ef2895d (diff) |
Hide header on scroll (mobile only)
Diffstat (limited to 'vnext/src')
-rw-r--r-- | vnext/src/App.js | 115 | ||||
-rw-r--r-- | vnext/src/index.css | 4 | ||||
-rw-r--r-- | vnext/src/ui/Feeds.js | 137 | ||||
-rw-r--r-- | vnext/src/ui/Header.js | 134 |
4 files changed, 197 insertions, 193 deletions
diff --git a/vnext/src/App.js b/vnext/src/App.js index f39875c1..011e5cbd 100644 --- a/vnext/src/App.js +++ b/vnext/src/App.js @@ -1,5 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'; +import { useScroll, useRafState } from 'react-use'; import qs from 'qs'; import svg4everybody from 'svg4everybody'; @@ -10,18 +11,23 @@ import { Friends, Readers } from './ui/Users'; import Settings from './ui/Settings'; import Contacts from './ui/Contacts'; import Chat from './ui/Chat'; +import Header from './ui/Header'; import Post from './ui/Post'; import Thread from './ui/Thread'; import Login from './ui/Login'; -import { UserLink } from './ui/UserInfo'; -import SearchBox from './ui/SearchBox'; import cookie from 'react-cookies'; import { me } from './api'; +const elClassHidden = 'header--hidden'; + +const elClassFull = 'content--full'; + export default function App() { + let contentRef = useRef(null); + useEffect(() => { svg4everybody(); }, []); @@ -49,6 +55,48 @@ export default function App() { }); }; + const [scrollState, setScrollState] = useRafState({ + hidden: false, + bottom: false, + prevScroll: 0 + }); + + let { x, y } = useScroll(contentRef); + + useEffect(() => { + let dHeight = contentRef.current.scrollHeight; + let wHeight = contentRef.current.clientHeight; + setScrollState((scrollState) => { + let wScrollDiff = scrollState.prevScroll - y; + let hidden = scrollState.hidden; + let bottom = scrollState.bottom; + if (y <= 0) { + // scrolled to the very top; element sticks to the top + hidden = false; + bottom = false; + } else if ((wScrollDiff > 0) && hidden) { + // scrolled up; element slides in + hidden = false; + bottom = false; + } else if (wScrollDiff < 0) { + // scrolled down + if ((y + wHeight) >= dHeight && hidden) { + // scrolled to the very bottom; element slides in + hidden = false; + bottom = true; + } else { + // scrolled down; element slides out + hidden = true; + bottom = false; + } + } + return { + hidden: hidden, + bottom: bottom, + prevScroll: y + }; + }); + }, [x, y, setScrollState]); const [hash, setHash] = useState(cookie.load('hash')); const [eventSource, setEventSource] = useState({}); @@ -111,66 +159,9 @@ export default function App() { return ( <Router> <> - <div id="header"> - <div id="header_wrapper"> - { - visitor.uid < 0 ? - <> - <div id="logo"><a href="/" /></div> - <nav id="global"> - <a href="/">Loading...</a> - </nav> - </> - : visitor.uid > 0 ? - <UserLink user={visitor} /> - : <div id="logo"> - <Link to="/">Juick</Link> - </div> - } - { - visitor.uid >= 0 && - <> - <div id="search" className="desktop"> - <SearchBox pathname="/discover" onSearch={search} /> - </div> - <nav id="global"> - {visitor.uid > 0 ? - <Link to={{ pathname: '/' }}> - <Icon name="ei-bell" size="s" /><span className="desktop">Discuss</span> - { - visitor.unreadCount && - <span className="badge">{visitor.unreadCount}</span> - } - </Link> - : - <Link to='/?media=1' rel="nofollow"> - <Icon name="ei-camera" size="s" /> - <span className="desktop">Photos</span> - </Link> - } - <Link to={{ pathname: '/discover' }} rel="nofollow"> - <Icon name="ei-search" size="s" /> - <span className="desktop">Discover</span> - </Link> - - {visitor.uid > 0 ? - <Link to={{ pathname: '/post' }}> - <Icon name="ei-pencil" size="s" /> - <span className="desktop">Post</span> - </Link> - : - <Link to={{ pathname: '/login', state: { retpath: window.location.pathname } }}> - <Icon name="ei-user" size="s" /> - <span className="desktop">Login</span> - </Link> - } - </nav> - </> - } - </div> - </div> + <Header visitor={visitor} className={scrollState.hidden ? elClassHidden : ''} /> <div id="wrapper"> - <section id="content"> + <section id="content" ref={contentRef} className={scrollState.hidden || scrollState.bottom ? elClassFull : ''}> <Switch> <Route exact path="/" render={(props) => <Discussions visitor={visitor} {...props} />} /> <Route exact path="/home" render={(props) => <Home visitor={visitor} {...props} />} /> diff --git a/vnext/src/index.css b/vnext/src/index.css index a371ae55..109eb678 100644 --- a/vnext/src/index.css +++ b/vnext/src/index.css @@ -167,6 +167,10 @@ hr { overflow: auto; } +.content--full { + margin-top: 0 !important; +} + #footer { display: none; background: var(--background-color); diff --git a/vnext/src/ui/Feeds.js b/vnext/src/ui/Feeds.js index 8c79f779..e687f1e2 100644 --- a/vnext/src/ui/Feeds.js +++ b/vnext/src/ui/Feeds.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import qs from 'qs'; @@ -23,9 +23,9 @@ import { getMessages } from '../api'; * @property {import('history').History} history * @property {import('history').Location} location * @property {import('react-router').match} match - * @property {string} search + * @property {string=} search * @property {import('../api').SecureUser} visitor - * @property {import('../api').Message[]} msgs + * @property {import('../api').Message[]=} msgs */ /** @@ -101,84 +101,95 @@ export function Home(props) { } /** - * @param {{ - authRequired?: boolean, - visitor: import('../api').SecureUser, - history: import('history').History, - location: import('history').Location, - msgs: import('../api').Message[], - query: Query -}} props + * @typedef {Object} FeedState + * @property authRequired?: boolean + * @property visitor: import('../api').SecureUser + * @property history: import('history').History + * @property location: import('history').Location + * @property msgs: import('../api').Message[] + * @property query: Query + */ + +/** + * @param {FeedState} props */ function Feed(props) { - const [msgs, setMsgs] = useState([]); - const [loading, setLoading] = useState(true); - const [nextpage, setNextpage] = useState(null); - const [error, setError] = useState(false); + const [state, setState] = useState({ + history: props.history, + authRequired: props.authRequired, + query: props.query, + hash: props.visitor.hash, + filter: props.location.search.substring(1), + msgs: [], + loading: true, + nextpage: null, + error: false, + tag: '' + }); + + const stateRef = useRef(state); useEffect(() => { - let loadMessages = (hash = '', filter = '') => { - document.body.scrollTop = 0; - document.documentElement.scrollTop = 0; - setMsgs([]); - setLoading(true); - const filterParams = qs.parse(filter); - let params = Object.assign({}, filterParams || {}, props.query.search || {}); - let url = props.query.baseUrl; - if (hash) { - params.hash = hash; - } - if (!params.hash && props.authRequired) { - props.history.push('/'); - } - getMessages(url, params) - .then(response => { - const { data } = response; - const { pageParam } = props.query; - const lastMessage = data.slice(-1)[0] || {}; - const nextpage = getPageParam(pageParam, lastMessage, filterParams); - setMsgs(data); - setLoading(false); - setNextpage(nextpage); - }).catch(ex => { - setError(true); - }); + let getPageParam = (pageParam, lastMessage, filterParams) => { + const pageValue = pageParam === 'before_mid' ? lastMessage.mid : pageParam === 'page' ? (Number(filterParams.page) || 0) + 1 : moment.utc(lastMessage.updated).valueOf(); + let newFilter = { ...filterParams }; + newFilter[pageParam] = pageValue; + return `?${qs.stringify(newFilter)}`; }; - loadMessages(props.visitor.hash, props.location.search.substring(1)); - }, [props]); - - let getPageParam = (pageParam, lastMessage, filterParams) => { - const pageValue = pageParam === 'before_mid' ? lastMessage.mid : pageParam === 'page' ? (Number(filterParams.page) || 0) + 1 : moment.utc(lastMessage.updated).valueOf(); - let newFilter = { ...filterParams }; - newFilter[pageParam] = pageValue; - return `?${qs.stringify(newFilter)}`; - }; - const { tag } = qs.parse(location.search.substring(1)) || {}; - const nodes = ( - <> + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + const filterParams = qs.parse(stateRef.current.filter); + let params = Object.assign({}, filterParams || {}, stateRef.current.query.search || {}); + let url = stateRef.current.query.baseUrl; + if (stateRef.current.hash) { + params.hash = stateRef.current.hash; + } + if (!params.hash && stateRef.current.authRequired) { + stateRef.current.history.push('/'); + } + getMessages(url, params) + .then(response => { + const { data } = response; + const { pageParam } = stateRef.current.query; + const lastMessage = data.slice(-1)[0] || {}; + const nextpage = getPageParam(pageParam, lastMessage, filterParams); + setState({ + ...stateRef.current, + msgs: data, + loading: false, + nextpage: nextpage, + tag: qs.parse(location.search.substring(1))['tag'] || '' + }); + }).catch(ex => { + setState({ + ...stateRef.current, + error: true + }); + }); + }, []); + return (state.msgs.length > 0 ? ( + <div className="msgs"> { - tag && ( + state.tag && ( <p className="page"> - <Link to={{ pathname: `/tag/${tag}` }}> - <span>← All posts with tag </span><b>{tag}</b> + <Link to={{ pathname: `/tag/${state.tag}` }}> + <span>← All posts with tag </span><b>{state.tag}</b> </Link> </p> ) } { - msgs.map(msg => + state.msgs.map(msg => <Message key={msg.mid} data={msg} visitor={props.visitor} />) } { - msgs.length >= 20 && ( + state.msgs.length >= 20 && ( <p className="page"> - <Link to={{ pathname: props.location.pathname, search: nextpage }} rel="prev">Next →</Link> + <Link to={{ pathname: props.location.pathname, search: state.nextpage }} rel="prev">Next →</Link> </p> ) } - </> + </div> + ) : state.error ? <div>error</div> : state.loading ? <div className="msgs"><Spinner /><Spinner /><Spinner /><Spinner /></div> : <div>No more messages</div> ); - return msgs.length > 0 ? ( - <div className="msgs">{nodes}</div> - ) : error ? <div>error</div> : loading ? <div className="msgs"><Spinner /><Spinner /><Spinner /><Spinner /></div> : <div>No more messages</div>; } diff --git a/vnext/src/ui/Header.js b/vnext/src/ui/Header.js index 2d042bfe..a7663dd3 100644 --- a/vnext/src/ui/Header.js +++ b/vnext/src/ui/Header.js @@ -1,73 +1,71 @@ -import React, { useEffect, useCallback, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; +import React, { memo } from 'react'; +import { Link, withRouter } from 'react-router-dom'; -const elClassHidden = 'header--hidden'; +import Icon from './Icon'; +import { UserLink } from './UserInfo'; +import SearchBox from './SearchBox'; -const header = document.getElementById('header'); +function Header({ visitor, search, className }) { + return ( + <div id="header" className={className}> + <div id="header_wrapper"> + { + visitor.uid < 0 ? + <> + <div id="logo"><a href="/" /></div> + <nav id="global"> + <a href="/">Loading...</a> + </nav> + </> + : visitor.uid > 0 ? + <UserLink user={visitor} /> + : <div id="logo"> + <Link to="/">Juick</Link> + </div> + } + { + visitor.uid >= 0 && + <> + <div id="search" className="desktop"> + <SearchBox pathname="/discover" onSearch={search} /> + </div> + <nav id="global"> + {visitor.uid > 0 ? + <Link to={{ pathname: '/' }}> + <Icon name="ei-bell" size="s" /><span className="desktop">Discuss</span> + { + visitor.unreadCount && + <span className="badge">{visitor.unreadCount}</span> + } + </Link> + : + <Link to='/?media=1' rel="nofollow"> + <Icon name="ei-camera" size="s" /> + <span className="desktop">Photos</span> + </Link> + } + <Link to={{ pathname: '/discover' }} rel="nofollow"> + <Icon name="ei-search" size="s" /> + <span className="desktop">Discover</span> + </Link> -export default function Header({ children }) { - let dHeight = useRef(0); - let wHeight = useRef(0); - let wScrollCurrent = useRef(0); - let wScrollBefore = useRef(0); - let wScrollDiff = useRef(0); - - /** - * @param {number} delay - * @param {{ (): void; apply?: any; }} fn - */ - let throttle = (delay, fn) => { - var last, deferTimer; - return function () { - var context = this, args = arguments, now = +new Date; - if (last && now < last + delay) { - clearTimeout(deferTimer); - deferTimer = setTimeout( - function () { - last = now; - fn.apply(context, args); - }, - delay); - } else { - last = now; - fn.apply(context, args); - } - }; - }; - let updateHeader = useCallback(() => { - dHeight.current = document.body.offsetHeight; - wHeight.current = window.innerHeight; - wScrollCurrent.current = window.pageYOffset; - wScrollDiff.current = wScrollBefore.current - wScrollCurrent.current; - - if (wScrollCurrent.current <= 0) { - // scrolled to the very top; element sticks to the top - header.classList.remove(elClassHidden); - } else if (wScrollDiff.current > 0 && header.classList.contains(elClassHidden)) { - // scrolled up; element slides in - header.classList.remove(elClassHidden); - } else if (wScrollDiff.current < 0) { - // scrolled down - if (wScrollCurrent.current + wHeight.current >= dHeight.current && header.classList.contains(elClassHidden)) { - // scrolled to the very bottom; element slides in - header.classList.remove(elClassHidden); - } else { - // scrolled down; element slides out - header.classList.add(elClassHidden); - } - } - wScrollBefore.current = wScrollCurrent.current; - }, []); - - useEffect(() => { - window.addEventListener('scroll', () => (!window.requestAnimationFrame) - ? throttle(250, updateHeader) - : window.requestAnimationFrame(updateHeader), false); - }, [updateHeader]); - return ReactDOM.createPortal(children, header); + {visitor.uid > 0 ? + <Link to={{ pathname: '/post' }}> + <Icon name="ei-pencil" size="s" /> + <span className="desktop">Post</span> + </Link> + : + <Link to={{ pathname: '/login', state: { retpath: window.location.pathname } }}> + <Icon name="ei-user" size="s" /> + <span className="desktop">Login</span> + </Link> + } + </nav> + </> + } + </div> + </div> + ); } -Header.propTypes = { - children: PropTypes.node -}; +export default memo(withRouter(Header)); |