Compare commits

...

14 Commits

Author SHA1 Message Date
Keyan 62cf5b9c34
Merge pull request #1603 from stackernews/fix-dark-mode-update
Fix missing useDarkMode update
2024-11-18 16:09:16 -06:00
ekzyis 120dd4122f Fix missing useDarkMode update 2024-11-18 23:01:12 +01:00
Keyan c3b7ad3fdd
Merge pull request #1599 from Soxasora/feat_pwa_pull_to_refresh
feat: PWA pull to refresh
2024-11-18 13:11:56 -06:00
k00b d2b5d23af5 add body margin on ptr 2024-11-18 13:05:51 -06:00
Soxasora a992426058 enhance: cleanup, ux/ui changes, safer approach to android's ptr 2024-11-18 15:49:54 +01:00
Keyan cce7195652
Merge pull request #1600 from stackernews/update-pr-template
Also ask if tested on light/dark mode in pull request template
2024-11-17 16:16:10 -06:00
ekzyis deccb0fea9 Also ask if tested on light/dark mode 2024-11-17 22:58:28 +01:00
Soxasora 5a359feeed test: togglable Android's native PTR 2024-11-17 21:58:40 +01:00
Simone Cervino c08088bdbe
fix uploaded videos don't load on safari (#1593)
* fix uploaded videos don't load on safari

* fix safari loading video as image, min-content restored

* refinements for ssr and skip load check for images

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-17 13:38:11 -06:00
Soxasora eea121e30c feat: pull-to-refresh for PWA without native refresh 2024-11-17 20:23:26 +01:00
Lorenzo 83e72e21cc
fix: reply storage is updated with the new content on file upload (#1585)
* fix: reply storage is updated with the new content on file upload

* Revert "fix: reply storage is updated with the new content on file upload"

This reverts commit 350931fd0c7a47ffe59716722755ab294c481b71.

* chore: reworked image draft save by using events

* chore: helpers.setValue called just after setNativeValue

* chore: updated setNativeValue function to be more use-case specific
2024-11-16 17:23:07 -06:00
Lorenzo 8a2bd84f69
fix: upvote widget not rendered when comment is collapsed (#1583)
* fix: upvote widget not rendered when comment is collapsed

* fix: restored missing conditional on handleShortPress

* chore: icon horizontal space maintained even if the comment is collapsed

* chore: 'rendered' argument renamed to 'visible'

* chore: collapsed condition merged with the 'disabled' variable

* reduce unecessary code

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-16 17:04:49 -06:00
k00b 73df5e0308 improve newsletter import 2024-11-16 16:52:08 -06:00
ekzyis 5631d6acf6
Fix #undefined in downzap invoice description (#1597) 2024-11-16 14:58:33 -06:00
12 changed files with 180 additions and 33 deletions

View File

@ -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:**
**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:**

View File

@ -78,6 +78,6 @@ export async function onFail ({ invoice }, { tx }) {
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}`
}

View File

@ -149,7 +149,7 @@ export default function Comment ({
? <Boost item={item} className={styles.upvote} />
: item.meDontLikeSats > item.meSats
? <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='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'

View File

@ -48,6 +48,15 @@ const listenForThemeChange = (onChange) => {
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 () {

View File

@ -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 }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@ -367,6 +375,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
let text = innerRef.current.value
text = text.replace(`![Uploading ${name}…]()`, `![](${url})`)
helpers.setValue(text)
setNativeValue(innerRef.current, text)
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
updateUploadFees({ variables: { s3Keys } })
setSubmitDisabled?.(false)

View File

@ -6,6 +6,7 @@ import Footer from './footer'
import Seo, { SeoSearch } from './seo'
import Search from './search'
import styles from './layout.module.css'
import PullToRefresh from './pull-to-refresh'
export default function Layout ({
sub, contain = true, footer = true, footerLinks = true,
@ -14,16 +15,18 @@ export default function Layout ({
return (
<>
{seo && <Seo sub={sub} item={item} user={user} />}
<Navigation sub={sub} />
{contain
? (
<Container as='main' className={`px-sm-0 ${styles.contain}`}>
{children}
</Container>
)
: children}
{footer && <Footer links={footerLinks} />}
<NavFooter sub={sub} />
<PullToRefresh android> {/* android prop if true disables its native PTR */}
<Navigation sub={sub} />
{contain
? (
<Container as='main' className={`px-sm-0 ${styles.contain}`}>
{children}
</Container>
)
: children}
{footer && <Footer links={footerLinks} />}
<NavFooter sub={sub} />
</PullToRefresh>
</>
)
}

View File

@ -1,5 +1,5 @@
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 { useMe } from './me'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
@ -23,13 +23,31 @@ const Media = memo(function Media ({
src, bestResSrc, srcSet, sizes, width,
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 (
<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}
>
{video
? <video
ref={ref}
src={src}
preload={bestResSrc !== src ? 'metadata' : undefined}
controls
@ -37,8 +55,10 @@ const Media = memo(function Media ({
width={width}
height={height}
onError={onError}
onLoadedMetadata={handleLoadedMedia}
/>
: <img
ref={ref}
src={src}
srcSet={srcSet}
sizes={sizes}
@ -46,6 +66,7 @@ const Media = memo(function Media ({
height={height}
onClick={onClick}
onError={onError}
onLoad={handleLoadedMedia}
/>}
</div>
)
@ -101,21 +122,28 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
useEffect(() => {
// don't load the video at all if user doesn't want these
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()
img.onload = () => setIsImage(true)
img.src = src
// check if it's a video by trying to load it
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
return () => {
img.onload = null
img.src = ''
video.onloadeddata = null
video.onloadedmetadata = null
video.onerror = null
video.src = ''
}
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
}, [src, setIsImage, setIsVideo, showMedia, isImage])
const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined

View 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>
)
}

View 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;
}

View File

@ -184,10 +184,14 @@
.p:has(> .mediaContainer) .mediaContainer
{
display: flex;
width: min-content;
max-width: 100%;
}
.p:has(> .mediaContainer) .mediaContainer.loaded
{
width: min-content;
}
.p:has(> .mediaContainer) .mediaContainer img,
.p:has(> .mediaContainer) .mediaContainer video
{

View File

@ -103,7 +103,7 @@ export const nextTip = (meSats, { tipDefault, turboTipping, tipRandom, tipRandom
return defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax })
}
export default function UpVote ({ item, className }) {
export default function UpVote ({ item, className, collapsed }) {
const showModal = useShowModal()
const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false)
@ -150,8 +150,8 @@ export default function UpVote ({ item, className }) {
const zap = useZap()
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
[item?.mine, item?.meForward, item?.deletedAt])
const disabled = useMemo(() => collapsed || item?.mine || item?.meForward || item?.deletedAt,
[collapsed, item?.mine, item?.meForward, item?.deletedAt])
const [meSats, overlayText, color, nextColor] = useMemo(() => {
const meSats = (me ? item?.meSats : item?.meAnonSats) || 0
@ -244,9 +244,7 @@ export default function UpVote ({ item, className }) {
onShortPress={handleShortPress}
>
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
<div
className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}
>
<div className={classNames(disabled && styles.noSelfTips, styles.upvoteWrapper)}>
<UpBolt
onPointerEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}

View File

@ -1,4 +1,5 @@
const { ApolloClient, InMemoryCache, HttpLink, gql } = require('@apollo/client')
const { quote } = require('../lib/md.js')
const ITEMS = gql`
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 () {
const { quote } = await import('../lib/md.js')
const top = await client.query({
query: ITEMS,
variables: { sort: 'top', when: 'week' }