* WIP: Account switching * Fix empty USER query ANON_USER_ID was undefined and thus the query for @anon had no variables. * Apply multiAuthMiddleware in /api/graphql * Fix 'you must be logged in' query error on switch to anon * Add smart 'switch account' button "smart" means that it only shows if there are accounts to which one can switch * Fix multiAuth not set in backend * Comment fixes, minor changes * Use fw-bold instead of 'selected' * Close dropdown and offcanvas Inside a dropdown, we can rely on autoClose but need to wrap the buttons with <Dropdown.Item> for that to work. For the offcanvas, we need to pass down handleClose. * Use button to add account * Some pages require hard reload on account switch * Reinit settings form on account switch * Also don't refetch WalletHistory * Formatting * Use width: fit-content for standalone SignUpButton * Remove unused className * Use fw-bold and text-underline on selected * Fix inconsistent padding of login buttons * Fix duplicate redirect from /settings on anon switch * Never throw during refetch * Throw errors which extend GraphQLError * Only use meAnonSats if logged out * Use reactive variable for meAnonSats The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats. Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables. We do this now and thus also don't need the useEffect hack in item-info.js anymore. * Switch to new user * Fix missing cleanup during logout If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set. This meant that during logout, the other 'multi_auth.*' cookies were not deleted. This broke the account switch modal. This is fixed by setting the 'multi_auth.user-id' cookie on login. Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set). * Fix comments in middleware * Remove unnecessary effect dependencies setState is stable and thus only noise in effect dependencies * Show but disable unavailable auth methods * make signup button consistent with others * Always reload page on switch * refine account switch styling * logout barrier --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
449 lines
13 KiB
JavaScript
449 lines
13 KiB
JavaScript
import Link from 'next/link'
|
|
import { Button, Dropdown, Nav, Navbar } from 'react-bootstrap'
|
|
import styles from '../header.module.css'
|
|
import { useRouter } from 'next/router'
|
|
import BackArrow from '../../svgs/arrow-left-line.svg'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import Price from '../price'
|
|
import SubSelect from '../sub-select'
|
|
import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
|
|
import Head from 'next/head'
|
|
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
|
import { useMe } from '../me'
|
|
import HiddenWalletSummary from '../hidden-wallet-summary'
|
|
import { abbrNum, msatsToSats } from '../../lib/format'
|
|
import { useServiceWorker } from '../serviceworker'
|
|
import { signOut } from 'next-auth/react'
|
|
import Hat from '../hat'
|
|
import { randInRange } from '../../lib/rand'
|
|
import { useLightning } from '../lightning'
|
|
import LightningIcon from '../../svgs/bolt.svg'
|
|
import SearchIcon from '../../svgs/search-line.svg'
|
|
import classNames from 'classnames'
|
|
import SnIcon from '@/svgs/sn.svg'
|
|
import { useHasNewNotes } from '../use-has-new-notes'
|
|
import { useWallets } from 'wallets'
|
|
import SwitchAccountList, { useAccounts } from '@/components/account'
|
|
import { useShowModal } from '@/components/modal'
|
|
|
|
export function Brand ({ className }) {
|
|
return (
|
|
<Link href='/' passHref legacyBehavior>
|
|
<Navbar.Brand className={classNames(styles.brand, className)}>
|
|
<SnIcon width={36} height={36} />
|
|
</Navbar.Brand>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function hasNavSelect ({ path, pathname }) {
|
|
return (
|
|
pathname.startsWith('/~') &&
|
|
!path.endsWith('/post') &&
|
|
!path.endsWith('/edit')
|
|
)
|
|
}
|
|
|
|
export function Back () {
|
|
const router = useRouter()
|
|
const [back, setBack] = useState(router.asPath !== '/')
|
|
|
|
useEffect(() => {
|
|
setBack(router.asPath !== '/' && (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack))
|
|
}, [router.asPath])
|
|
|
|
if (!back) return null
|
|
|
|
return (
|
|
<a
|
|
role='button' tabIndex='0' className='nav-link p-0 me-2' onClick={() => {
|
|
if (back) {
|
|
router.back()
|
|
} else {
|
|
router.push('/')
|
|
}
|
|
}}
|
|
>
|
|
<BackArrow className='theme me-1 me-md-2' width={24} height={24} />
|
|
</a>
|
|
)
|
|
}
|
|
|
|
export function BackOrBrand ({ className }) {
|
|
const router = useRouter()
|
|
const [back, setBack] = useState(router.asPath !== '/')
|
|
|
|
useEffect(() => {
|
|
setBack(router.asPath !== '/' && (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack))
|
|
}, [router.asPath])
|
|
|
|
return (
|
|
<div className='d-flex align-items-center'>
|
|
{back ? <Back /> : <Brand className={className} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SearchItem ({ prefix, className }) {
|
|
return (
|
|
<Link href='/search' passHref legacyBehavior>
|
|
<Nav.Link eventKey='search' className={className}>
|
|
<SearchIcon className='theme' width={22} height={28} />
|
|
</Nav.Link>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function NavPrice ({ className }) {
|
|
return (
|
|
<Nav.Item className={classNames(styles.price, className)}>
|
|
<Price className='nav-link text-monospace' />
|
|
</Nav.Item>
|
|
)
|
|
}
|
|
|
|
const PREPEND_SUBS = ['home']
|
|
const APPEND_SUBS = [{ label: '--------', items: ['create'] }]
|
|
export function NavSelect ({ sub: subName, className, size }) {
|
|
const sub = subName || 'home'
|
|
|
|
return (
|
|
<Nav.Item className={className}>
|
|
<SubSelect
|
|
sub={sub} prependSubs={PREPEND_SUBS} appendSubs={APPEND_SUBS} noForm
|
|
groupClassName='mb-0' size={size}
|
|
/>
|
|
</Nav.Item>
|
|
)
|
|
}
|
|
|
|
export function NavNotifications ({ className }) {
|
|
const hasNewNotes = useHasNewNotes()
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
|
</Head>
|
|
<Link href='/notifications' passHref legacyBehavior>
|
|
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
|
|
<NoteIcon height={28} width={20} className='theme' />
|
|
{hasNewNotes &&
|
|
<span className={styles.notification}>
|
|
<span className='invisible'>{' '}</span>
|
|
</span>}
|
|
</Nav.Link>
|
|
</Link>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function WalletSummary () {
|
|
const { me } = useMe()
|
|
if (!me) return null
|
|
if (me.privates?.hideWalletBalance) {
|
|
return <HiddenWalletSummary abbreviate fixedWidth />
|
|
}
|
|
return `${abbrNum(me.privates?.sats)}`
|
|
}
|
|
|
|
export function NavWalletSummary ({ className }) {
|
|
const { me } = useMe()
|
|
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
|
|
|
return (
|
|
<Nav.Item className={className}>
|
|
<Link href='/wallet' passHref legacyBehavior>
|
|
<Nav.Link eventKey='wallet' className={`${walletLimitReached ? 'text-warning' : 'text-success'} text-monospace px-0 text-nowrap`}>
|
|
<WalletSummary me={me} />
|
|
</Nav.Link>
|
|
</Link>
|
|
</Nav.Item>
|
|
)
|
|
}
|
|
|
|
export function MeDropdown ({ me, dropNavKey }) {
|
|
if (!me) return null
|
|
return (
|
|
<div className='position-relative'>
|
|
<Dropdown className={styles.dropdown} align='end'>
|
|
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
|
|
<Nav.Link eventKey={me.name} as='span' className='p-0'>
|
|
{`@${me.name}`}<Hat user={me} />
|
|
</Nav.Link>
|
|
</Dropdown.Toggle>
|
|
<Dropdown.Menu>
|
|
<Link href={'/' + me.name} passHref legacyBehavior>
|
|
<Dropdown.Item active={me.name === dropNavKey}>
|
|
profile
|
|
{me && !me.bioId &&
|
|
<div className='p-1 d-inline-block bg-secondary ms-1'>
|
|
<span className='invisible'>{' '}</span>
|
|
</div>}
|
|
</Dropdown.Item>
|
|
</Link>
|
|
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
|
<Dropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</Dropdown.Item>
|
|
</Link>
|
|
<Link href='/wallet' passHref legacyBehavior>
|
|
<Dropdown.Item eventKey='wallet'>wallet</Dropdown.Item>
|
|
</Link>
|
|
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
|
<Dropdown.Item eventKey='satistics'>satistics</Dropdown.Item>
|
|
</Link>
|
|
<Dropdown.Divider />
|
|
<Link href='/referrals/month' passHref legacyBehavior>
|
|
<Dropdown.Item eventKey='referrals'>referrals</Dropdown.Item>
|
|
</Link>
|
|
<Dropdown.Divider />
|
|
<div className='d-flex align-items-center'>
|
|
<Link href='/settings' passHref legacyBehavior>
|
|
<Dropdown.Item eventKey='settings'>settings</Dropdown.Item>
|
|
</Link>
|
|
</div>
|
|
<Dropdown.Divider />
|
|
<LogoutDropdownItem />
|
|
</Dropdown.Menu>
|
|
</Dropdown>
|
|
{!me.bioId &&
|
|
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
|
|
<span className='invisible'>{' '}</span>
|
|
</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SignUpButton ({ className = 'py-0', width }) {
|
|
const router = useRouter()
|
|
const handleLogin = useCallback(async pathname => await router.push({
|
|
pathname,
|
|
query: { callbackUrl: window.location.origin + router.asPath }
|
|
}), [router])
|
|
|
|
return (
|
|
<Button
|
|
className={classNames('align-items-center ps-2 py-1 pe-3', className)}
|
|
style={{ borderWidth: '2px', width: width || '150px' }}
|
|
id='signup'
|
|
onClick={() => handleLogin('/signup')}
|
|
>
|
|
<LightningIcon
|
|
width={17}
|
|
height={17}
|
|
className='me-1'
|
|
/>sign up
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export default function LoginButton () {
|
|
const router = useRouter()
|
|
const handleLogin = useCallback(async pathname => await router.push({
|
|
pathname,
|
|
query: { callbackUrl: window.location.origin + router.asPath }
|
|
}), [router])
|
|
|
|
return (
|
|
<Button
|
|
className='align-items-center px-3 py-1'
|
|
id='login'
|
|
style={{ borderWidth: '2px', width: '150px' }}
|
|
variant='outline-grey-darkmode'
|
|
onClick={() => handleLogin('/login')}
|
|
>
|
|
login
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function LogoutObstacle ({ onClose }) {
|
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
|
const wallets = useWallets()
|
|
const { multiAuthSignout } = useAccounts()
|
|
|
|
return (
|
|
<div className='d-flex m-auto flex-column w-fit-content'>
|
|
<h4 className='mb-3'>I reckon you want to logout?</h4>
|
|
<div className='mt-2 d-flex justify-content-between'>
|
|
<Button
|
|
className='me-2'
|
|
variant='grey-medium'
|
|
onClick={onClose}
|
|
>
|
|
cancel
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
const switchSuccess = await multiAuthSignout()
|
|
// only signout if multiAuth did not find a next available account
|
|
if (switchSuccess) {
|
|
onClose()
|
|
return
|
|
}
|
|
|
|
// order is important because we need to be logged in to delete push subscription on server
|
|
const pushSubscription = await swRegistration?.pushManager.getSubscription()
|
|
if (pushSubscription) {
|
|
await togglePushSubscription().catch(console.error)
|
|
}
|
|
|
|
await wallets.resetClient().catch(console.error)
|
|
|
|
await signOut({ callbackUrl: '/' })
|
|
}}
|
|
>
|
|
logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function LogoutDropdownItem ({ handleClose }) {
|
|
const showModal = useShowModal()
|
|
|
|
return (
|
|
<>
|
|
<Dropdown.Item onClick={() => {
|
|
handleClose?.()
|
|
showModal(onClose => <SwitchAccountList onClose={onClose} />)
|
|
}}
|
|
>switch account
|
|
</Dropdown.Item>
|
|
<Dropdown.Item
|
|
onClick={async () => {
|
|
showModal(onClose => (<LogoutObstacle onClose={onClose} />))
|
|
}}
|
|
>logout
|
|
</Dropdown.Item>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SwitchAccountButton ({ handleClose }) {
|
|
const showModal = useShowModal()
|
|
const { accounts } = useAccounts()
|
|
|
|
if (accounts.length === 0) return null
|
|
|
|
return (
|
|
<Button
|
|
className='align-items-center px-3 py-1'
|
|
variant='outline-grey-darkmode'
|
|
style={{ borderWidth: '2px', width: '150px' }}
|
|
onClick={() => {
|
|
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
|
|
// so we manually close the offcanvas in that case by passing down handleClose here
|
|
handleClose?.()
|
|
showModal(onClose => <SwitchAccountList onClose={onClose} />)
|
|
}}
|
|
>
|
|
switch account
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export function LoginButtons ({ handleClose }) {
|
|
return (
|
|
<>
|
|
<Dropdown.Item className='py-1'>
|
|
<LoginButton />
|
|
</Dropdown.Item>
|
|
<Dropdown.Item className='py-1'>
|
|
<SignUpButton />
|
|
</Dropdown.Item>
|
|
<Dropdown.Item className='py-1'>
|
|
<SwitchAccountButton handleClose={handleClose} />
|
|
</Dropdown.Item>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function AnonDropdown ({ path }) {
|
|
const strike = useLightning()
|
|
|
|
useEffect(() => {
|
|
if (!window.localStorage.getItem('striked')) {
|
|
const to = setTimeout(() => {
|
|
strike()
|
|
window.localStorage.setItem('striked', 'yep')
|
|
}, randInRange(3000, 10000))
|
|
return () => clearTimeout(to)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<div className='position-relative'>
|
|
<Dropdown className={styles.dropdown} align='end' autoClose>
|
|
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
|
|
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
|
|
@anon<Hat user={{ id: USER_ID.anon }} />
|
|
</Nav.Link>
|
|
</Dropdown.Toggle>
|
|
<Dropdown.Menu className='p-3'>
|
|
<LoginButtons />
|
|
</Dropdown.Menu>
|
|
</Dropdown>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Sorts ({ sub, prefix, className }) {
|
|
return (
|
|
<>
|
|
<Nav.Item className={className}>
|
|
<Link href={prefix + '/'} passHref legacyBehavior>
|
|
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
|
|
</Link>
|
|
</Nav.Item>
|
|
<Nav.Item className={className}>
|
|
<Link href={prefix + '/recent'} passHref legacyBehavior>
|
|
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
|
</Link>
|
|
</Nav.Item>
|
|
<Nav.Item className={className}>
|
|
<Link href={prefix + '/random'} passHref legacyBehavior>
|
|
<Nav.Link eventKey='random' className={styles.navLink}>random</Nav.Link>
|
|
</Link>
|
|
</Nav.Item>
|
|
{sub !== 'jobs' &&
|
|
<Nav.Item className={className}>
|
|
<Link
|
|
href={{
|
|
pathname: '/~/top/[type]/[when]',
|
|
query: { type: 'posts', when: 'day', sub }
|
|
}} as={prefix + '/top/posts/day'} passHref legacyBehavior
|
|
>
|
|
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
|
</Link>
|
|
</Nav.Item>}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function PostItem ({ className, prefix }) {
|
|
return (
|
|
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary py-md-1`}>
|
|
post
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function MeCorner ({ dropNavKey, me, className }) {
|
|
return (
|
|
<div className={className}>
|
|
<NavNotifications />
|
|
<MeDropdown me={me} dropNavKey={dropNavKey} />
|
|
<NavWalletSummary className='d-inline-block' />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function AnonCorner ({ dropNavKey, className }) {
|
|
return (
|
|
<div className={className}>
|
|
<AnonDropdown dropNavKey={dropNavKey} />
|
|
</div>
|
|
)
|
|
}
|