Compare commits
14 Commits
79ada2ab58
...
62cf5b9c34
Author | SHA1 | Date | |
---|---|---|---|
|
62cf5b9c34 | ||
|
120dd4122f | ||
|
c3b7ad3fdd | ||
|
d2b5d23af5 | ||
|
a992426058 | ||
|
cce7195652 | ||
|
deccb0fea9 | ||
|
5a359feeed | ||
|
c08088bdbe | ||
|
eea121e30c | ||
|
83e72e21cc | ||
|
8a2bd84f69 | ||
|
73df5e0308 | ||
|
5631d6acf6 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -16,7 +16,7 @@ _Was anything unclear during your work on this PR? Anything we should definitely
|
|||||||
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
|
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
|
||||||
|
|
||||||
|
|
||||||
**For frontend changes: Tested on mobile? Please answer below:**
|
**For frontend changes: Tested on mobile, light and dark mode? Please answer below:**
|
||||||
|
|
||||||
|
|
||||||
**Did you introduce any new environment variables? If so, call them out explicitly here:**
|
**Did you introduce any new environment variables? If so, call them out explicitly here:**
|
||||||
|
@ -78,6 +78,6 @@ export async function onFail ({ invoice }, { tx }) {
|
|||||||
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
|
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function describe ({ itemId, sats }, { cost, actionId }) {
|
export async function describe ({ id: itemId, sats }, { cost, actionId }) {
|
||||||
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
|
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
|
||||||
}
|
}
|
||||||
|
@ -149,7 +149,7 @@ export default function Comment ({
|
|||||||
? <Boost item={item} className={styles.upvote} />
|
? <Boost item={item} className={styles.upvote} />
|
||||||
: item.meDontLikeSats > item.meSats
|
: item.meDontLikeSats > item.meSats
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} collapsed={collapse === 'yep'} />}
|
||||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||||
|
@ -48,6 +48,15 @@ const listenForThemeChange = (onChange) => {
|
|||||||
onChange({ user: true, dark })
|
onChange({ user: true, dark })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root = window.document.documentElement
|
||||||
|
const observer = new window.MutationObserver(() => {
|
||||||
|
const theme = root.getAttribute('data-bs-theme')
|
||||||
|
onChange(dark => ({ ...dark, dark: theme === 'dark' }))
|
||||||
|
})
|
||||||
|
observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] })
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useDarkMode () {
|
export default function useDarkMode () {
|
||||||
|
@ -131,6 +131,14 @@ export function InputSkeleton ({ label, hint }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fix https://github.com/stackernews/stacker.news/issues/1522
|
||||||
|
// see https://github.com/facebook/react/issues/11488#issuecomment-558874287
|
||||||
|
function setNativeValue (textarea, value) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||||
|
setter?.call(textarea, value)
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
||||||
const [tab, setTab] = useState('write')
|
const [tab, setTab] = useState('write')
|
||||||
const [, meta, helpers] = useField(props)
|
const [, meta, helpers] = useField(props)
|
||||||
@ -367,6 +375,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
let text = innerRef.current.value
|
let text = innerRef.current.value
|
||||||
text = text.replace(`![Uploading ${name}…]()`, ``)
|
text = text.replace(`![Uploading ${name}…]()`, ``)
|
||||||
helpers.setValue(text)
|
helpers.setValue(text)
|
||||||
|
setNativeValue(innerRef.current, text)
|
||||||
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
updateUploadFees({ variables: { s3Keys } })
|
updateUploadFees({ variables: { s3Keys } })
|
||||||
setSubmitDisabled?.(false)
|
setSubmitDisabled?.(false)
|
||||||
|
@ -6,6 +6,7 @@ import Footer from './footer'
|
|||||||
import Seo, { SeoSearch } from './seo'
|
import Seo, { SeoSearch } from './seo'
|
||||||
import Search from './search'
|
import Search from './search'
|
||||||
import styles from './layout.module.css'
|
import styles from './layout.module.css'
|
||||||
|
import PullToRefresh from './pull-to-refresh'
|
||||||
|
|
||||||
export default function Layout ({
|
export default function Layout ({
|
||||||
sub, contain = true, footer = true, footerLinks = true,
|
sub, contain = true, footer = true, footerLinks = true,
|
||||||
@ -14,16 +15,18 @@ export default function Layout ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{seo && <Seo sub={sub} item={item} user={user} />}
|
{seo && <Seo sub={sub} item={item} user={user} />}
|
||||||
<Navigation sub={sub} />
|
<PullToRefresh android> {/* android prop if true disables its native PTR */}
|
||||||
{contain
|
<Navigation sub={sub} />
|
||||||
? (
|
{contain
|
||||||
<Container as='main' className={`px-sm-0 ${styles.contain}`}>
|
? (
|
||||||
{children}
|
<Container as='main' className={`px-sm-0 ${styles.contain}`}>
|
||||||
</Container>
|
{children}
|
||||||
)
|
</Container>
|
||||||
: children}
|
)
|
||||||
{footer && <Footer links={footerLinks} />}
|
: children}
|
||||||
<NavFooter sub={sub} />
|
{footer && <Footer links={footerLinks} />}
|
||||||
|
<NavFooter sub={sub} />
|
||||||
|
</PullToRefresh>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import styles from './text.module.css'
|
import styles from './text.module.css'
|
||||||
import { useState, useEffect, useMemo, useCallback, memo } from 'react'
|
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
||||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
@ -23,13 +23,31 @@ const Media = memo(function Media ({
|
|||||||
src, bestResSrc, srcSet, sizes, width,
|
src, bestResSrc, srcSet, sizes, width,
|
||||||
height, onClick, onError, style, className, video
|
height, onClick, onError, style, className, video
|
||||||
}) {
|
}) {
|
||||||
|
const [loaded, setLoaded] = useState(!video)
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const handleLoadedMedia = () => {
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// events are not fired on elements during hydration
|
||||||
|
// https://github.com/facebook/react/issues/15446
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.src = src
|
||||||
|
}
|
||||||
|
}, [ref.current, src])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(className, styles.mediaContainer)}
|
// will set min-content ONLY after the media is loaded
|
||||||
|
// due to safari video bug
|
||||||
|
className={classNames(className, styles.mediaContainer, { [styles.loaded]: loaded })}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{video
|
{video
|
||||||
? <video
|
? <video
|
||||||
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
preload={bestResSrc !== src ? 'metadata' : undefined}
|
preload={bestResSrc !== src ? 'metadata' : undefined}
|
||||||
controls
|
controls
|
||||||
@ -37,8 +55,10 @@ const Media = memo(function Media ({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
onLoadedMetadata={handleLoadedMedia}
|
||||||
/>
|
/>
|
||||||
: <img
|
: <img
|
||||||
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
srcSet={srcSet}
|
srcSet={srcSet}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
@ -46,6 +66,7 @@ const Media = memo(function Media ({
|
|||||||
height={height}
|
height={height}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
onLoad={handleLoadedMedia}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -101,21 +122,28 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// don't load the video at all if user doesn't want these
|
// don't load the video at all if user doesn't want these
|
||||||
if (!showMedia || isVideo || isImage) return
|
if (!showMedia || isVideo || isImage) return
|
||||||
// make sure it's not a false negative by trying to load URL as <img>
|
|
||||||
const img = new window.Image()
|
// check if it's a video by trying to load it
|
||||||
img.onload = () => setIsImage(true)
|
|
||||||
img.src = src
|
|
||||||
const video = document.createElement('video')
|
const video = document.createElement('video')
|
||||||
video.onloadeddata = () => setIsVideo(true)
|
video.onloadedmetadata = () => {
|
||||||
|
setIsVideo(true)
|
||||||
|
setIsImage(false)
|
||||||
|
}
|
||||||
|
video.onerror = () => {
|
||||||
|
// hack
|
||||||
|
// if it's not a video it will throw an error, so we can assume it's an image
|
||||||
|
const img = new window.Image()
|
||||||
|
img.onload = () => setIsImage(true)
|
||||||
|
img.src = src
|
||||||
|
}
|
||||||
video.src = src
|
video.src = src
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
img.onload = null
|
video.onloadedmetadata = null
|
||||||
img.src = ''
|
video.onerror = null
|
||||||
video.onloadeddata = null
|
|
||||||
video.src = ''
|
video.src = ''
|
||||||
}
|
}
|
||||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
|
}, [src, setIsImage, setIsVideo, showMedia, isImage])
|
||||||
|
|
||||||
const srcSet = useMemo(() => {
|
const srcSet = useMemo(() => {
|
||||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||||
|
84
components/pull-to-refresh.js
Normal file
84
components/pull-to-refresh.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import styles from './pull-to-refresh.module.css'
|
||||||
|
|
||||||
|
const REFRESH_THRESHOLD = 50
|
||||||
|
|
||||||
|
export default function PullToRefresh ({ children, android }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [pullDistance, setPullDistance] = useState(0)
|
||||||
|
const [isPWA, setIsPWA] = useState(false)
|
||||||
|
const [isAndroid, setIsAndroid] = useState(false)
|
||||||
|
const touchStartY = useRef(0)
|
||||||
|
const touchEndY = useRef(0)
|
||||||
|
|
||||||
|
const checkPWA = () => {
|
||||||
|
const androidPWA = window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
const iosPWA = window.navigator.standalone === true
|
||||||
|
setIsAndroid(androidPWA) // we need to know if the user is on Android to enable toggling its native PTR
|
||||||
|
setIsPWA(androidPWA || iosPWA)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(checkPWA, [])
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e) => {
|
||||||
|
// don't handle if the user is not scrolling from the top of the page, is not on a PWA or if we want Android's native PTR
|
||||||
|
if (!isPWA || (isAndroid && !android) || window.scrollY > 0) return
|
||||||
|
touchStartY.current = e.touches[0].clientY
|
||||||
|
}, [isPWA, isAndroid, android])
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e) => {
|
||||||
|
if (touchStartY.current === 0) return
|
||||||
|
if (!isPWA || (isAndroid && !android)) return
|
||||||
|
touchEndY.current = e.touches[0].clientY
|
||||||
|
const distance = touchEndY.current - touchStartY.current
|
||||||
|
setPullDistance(distance)
|
||||||
|
document.body.style.marginTop = `${Math.max(0, Math.min(distance / 2, 25))}px`
|
||||||
|
}, [isPWA, isAndroid, android])
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
if (touchStartY.current === 0 || touchEndY.current === 0) return
|
||||||
|
if (touchEndY.current - touchStartY.current > REFRESH_THRESHOLD) {
|
||||||
|
router.push(router.asPath)
|
||||||
|
}
|
||||||
|
setPullDistance(0)
|
||||||
|
document.body.style.marginTop = '0px'
|
||||||
|
touchStartY.current = 0
|
||||||
|
touchEndY.current = 0
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPWA || (isAndroid && !android)) return
|
||||||
|
document.body.style.overscrollBehaviorY = 'contain'
|
||||||
|
document.addEventListener('touchstart', handleTouchStart)
|
||||||
|
document.addEventListener('touchmove', handleTouchMove)
|
||||||
|
document.addEventListener('touchend', handleTouchEnd)
|
||||||
|
return () => {
|
||||||
|
document.body.style.overscrollBehaviorY = ''
|
||||||
|
document.body.style.marginTop = '0px'
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
}
|
||||||
|
}, [isPWA, isAndroid, android, handleTouchStart, handleTouchMove, handleTouchEnd])
|
||||||
|
|
||||||
|
const pullMessage = useMemo(() => {
|
||||||
|
if (pullDistance > REFRESH_THRESHOLD) return 'release to refresh'
|
||||||
|
return 'pull down to refresh'
|
||||||
|
}, [pullDistance])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<p className={`${styles.pullMessage}`} style={{ top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}>
|
||||||
|
{pullMessage}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
13
components/pull-to-refresh.module.css
Normal file
13
components/pull-to-refresh.module.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.pullMessage {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -20px;
|
||||||
|
height: 15px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: small;
|
||||||
|
color: #a5a5a5;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
@ -184,10 +184,14 @@
|
|||||||
.p:has(> .mediaContainer) .mediaContainer
|
.p:has(> .mediaContainer) .mediaContainer
|
||||||
{
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
width: min-content;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p:has(> .mediaContainer) .mediaContainer.loaded
|
||||||
|
{
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
.p:has(> .mediaContainer) .mediaContainer img,
|
.p:has(> .mediaContainer) .mediaContainer img,
|
||||||
.p:has(> .mediaContainer) .mediaContainer video
|
.p:has(> .mediaContainer) .mediaContainer video
|
||||||
{
|
{
|
||||||
|
@ -103,7 +103,7 @@ export const nextTip = (meSats, { tipDefault, turboTipping, tipRandom, tipRandom
|
|||||||
return defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax })
|
return defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpVote ({ item, className }) {
|
export default function UpVote ({ item, className, collapsed }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const [voteShow, _setVoteShow] = useState(false)
|
const [voteShow, _setVoteShow] = useState(false)
|
||||||
const [tipShow, _setTipShow] = useState(false)
|
const [tipShow, _setTipShow] = useState(false)
|
||||||
@ -150,8 +150,8 @@ export default function UpVote ({ item, className }) {
|
|||||||
|
|
||||||
const zap = useZap()
|
const zap = useZap()
|
||||||
|
|
||||||
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
|
const disabled = useMemo(() => collapsed || item?.mine || item?.meForward || item?.deletedAt,
|
||||||
[item?.mine, item?.meForward, item?.deletedAt])
|
[collapsed, item?.mine, item?.meForward, item?.deletedAt])
|
||||||
|
|
||||||
const [meSats, overlayText, color, nextColor] = useMemo(() => {
|
const [meSats, overlayText, color, nextColor] = useMemo(() => {
|
||||||
const meSats = (me ? item?.meSats : item?.meAnonSats) || 0
|
const meSats = (me ? item?.meSats : item?.meAnonSats) || 0
|
||||||
@ -244,9 +244,7 @@ export default function UpVote ({ item, className }) {
|
|||||||
onShortPress={handleShortPress}
|
onShortPress={handleShortPress}
|
||||||
>
|
>
|
||||||
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
|
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
|
||||||
<div
|
<div className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}>
|
||||||
className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}
|
|
||||||
>
|
|
||||||
<UpBolt
|
<UpBolt
|
||||||
onPointerEnter={() => setHover(true)}
|
onPointerEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
const { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client')
|
const { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client')
|
||||||
|
const { quote } = require('../lib/md.js')
|
||||||
|
|
||||||
const ITEMS = gql`
|
const ITEMS = gql`
|
||||||
query items ($sort: String, $when: String, $sub: String, $by: String) {
|
query items ($sort: String, $when: String, $sub: String, $by: String) {
|
||||||
@ -143,8 +144,6 @@ async function getTopUsers ({ by, cowboys = false, includeHidden = false, count
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main () {
|
async function main () {
|
||||||
const { quote } = await import('../lib/md.js')
|
|
||||||
|
|
||||||
const top = await client.query({
|
const top = await client.query({
|
||||||
query: ITEMS,
|
query: ITEMS,
|
||||||
variables: { sort: 'top', when: 'week' }
|
variables: { sort: 'top', when: 'week' }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user