mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-01-23 09:06:23 +01:00
More code porting
This commit is contained in:
parent
9921e487e8
commit
f511b0a5ab
9 changed files with 282 additions and 240 deletions
39
src/app.css
39
src/app.css
|
@ -84,43 +84,46 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
|
||||
.deck > header {
|
||||
min-height: 3em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-blur-color);
|
||||
background-image: linear-gradient(to bottom, var(--bg-color), transparent);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||
z-index: 1;
|
||||
cursor: default;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
user-select: none;
|
||||
}
|
||||
.deck > header[hidden] {
|
||||
display: block;
|
||||
transform: translateY(-100%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.deck > header > .header-side:last-of-type {
|
||||
.deck > header .header-grid {
|
||||
background-color: var(--bg-blur-color);
|
||||
background-image: linear-gradient(to bottom, var(--bg-color), transparent);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||
min-height: 3em;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.deck > header .header-grid > .header-side:last-of-type {
|
||||
text-align: right;
|
||||
grid-column: 3;
|
||||
}
|
||||
.deck > header :is(button, .button).plain {
|
||||
.deck > header .header-grid :is(button, .button).plain {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.deck > header h1 {
|
||||
.deck > header .header-grid h1 {
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck > header h1:first-child {
|
||||
.deck > header .header-grid h1:first-child {
|
||||
text-align: left;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
@ -1211,14 +1214,16 @@ meter.donut:is(.danger, .explode):after {
|
|||
}
|
||||
.timeline-deck > header {
|
||||
--margin-top: 8px;
|
||||
min-height: 4em;
|
||||
top: var(--margin-top);
|
||||
border-bottom: 0;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
background-image: none;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
.timeline-deck > header .header-grid {
|
||||
border-bottom: 0;
|
||||
border-radius: 16px;
|
||||
margin-inline: 8px;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
background-image: none;
|
||||
border-radius: 16px;
|
||||
min-height: 4em;
|
||||
}
|
||||
.timeline-deck > header[hidden] {
|
||||
transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
|||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import useInterval from '../utils/useInterval';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
import useScroll from '../utils/useScroll';
|
||||
|
||||
|
@ -21,11 +22,13 @@ function Timeline({
|
|||
boostsCarousel,
|
||||
fetchItems = () => {},
|
||||
checkForUpdates = () => {},
|
||||
checkForUpdatesInterval = 60_000, // 1 minute
|
||||
}) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const scrollableRef = useRef();
|
||||
|
||||
const loadItems = useDebouncedCallback(
|
||||
|
@ -185,26 +188,40 @@ function Timeline({
|
|||
usePageVisibility(
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
if (lastHiddenTime.current) {
|
||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||
if (timeDiff > 1000 * 60) {
|
||||
(async () => {
|
||||
console.log('✨ Check updates');
|
||||
const hasUpdate = await checkForUpdates();
|
||||
if (hasUpdate) {
|
||||
console.log('✨ Has new updates');
|
||||
setShowNew(true);
|
||||
}
|
||||
})();
|
||||
}
|
||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
|
||||
(async () => {
|
||||
console.log('✨ Check updates');
|
||||
const hasUpdate = await checkForUpdates();
|
||||
if (hasUpdate) {
|
||||
console.log('✨ Has new updates');
|
||||
setShowNew(true);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else {
|
||||
lastHiddenTime.current = Date.now();
|
||||
}
|
||||
setVisible(visible);
|
||||
},
|
||||
[checkForUpdates],
|
||||
);
|
||||
|
||||
// checkForUpdates interval
|
||||
useInterval(
|
||||
() => {
|
||||
(async () => {
|
||||
console.log('✨ Check updates');
|
||||
const hasUpdate = await checkForUpdates();
|
||||
if (hasUpdate) {
|
||||
console.log('✨ Has new updates');
|
||||
setShowNew(true);
|
||||
}
|
||||
})();
|
||||
},
|
||||
visible && !showNew ? checkForUpdatesInterval : null,
|
||||
);
|
||||
|
||||
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
|
||||
|
||||
return (
|
||||
|
@ -231,14 +248,16 @@ function Timeline({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
</div>
|
||||
</div>
|
||||
{items.length > 0 &&
|
||||
uiState !== 'loading' &&
|
||||
|
|
|
@ -61,7 +61,8 @@ function Following() {
|
|||
}
|
||||
|
||||
const ws = useRef();
|
||||
async function streamUser() {
|
||||
const streamUser = async () => {
|
||||
console.log('🎏 Start streaming user', ws.current);
|
||||
if (
|
||||
ws.current &&
|
||||
(ws.current.readyState === WebSocket.CONNECTING ||
|
||||
|
@ -72,7 +73,8 @@ function Following() {
|
|||
}
|
||||
const stream = await masto.v1.stream.streamUser();
|
||||
ws.current = stream.ws;
|
||||
console.log('🎏 Streaming user');
|
||||
ws.current.__id = Math.random();
|
||||
console.log('🎏 Streaming user', ws.current);
|
||||
|
||||
stream.on('status.update', (status) => {
|
||||
console.log(`🔄 Status ${status.id} updated`);
|
||||
|
@ -86,14 +88,20 @@ function Following() {
|
|||
if (s) s._deleted = true;
|
||||
});
|
||||
|
||||
stream.ws.onclose = () => {
|
||||
console.log('🎏 Streaming user closed');
|
||||
};
|
||||
|
||||
return stream;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
streamUser();
|
||||
let stream;
|
||||
(async () => {
|
||||
stream = await streamUser();
|
||||
})();
|
||||
return () => {
|
||||
if (ws.current) {
|
||||
console.log('🎏 Closing streaming user');
|
||||
ws.current.close();
|
||||
if (stream) {
|
||||
stream.ws.close();
|
||||
ws.current = null;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -320,63 +320,66 @@ function Home({ hidden }) {
|
|||
loadStatuses(true);
|
||||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.showSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="gear" size="l" alt="Settings" />
|
||||
</button>
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.showSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="gear" size="l" alt="Settings" />
|
||||
</button>
|
||||
</div>
|
||||
<h1>Home</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />{' '}
|
||||
<Link
|
||||
to="/notifications"
|
||||
class={`button plain ${
|
||||
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon icon="notification" size="l" alt="Notifications" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<h1>Home</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />{' '}
|
||||
<Link
|
||||
to="/notifications"
|
||||
class={`button plain ${
|
||||
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon icon="notification" size="l" alt="Notifications" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
{snapStates.homeNew.length > 0 &&
|
||||
uiState !== 'loading' &&
|
||||
((scrollDirection === 'start' &&
|
||||
!nearReachStart &&
|
||||
!nearReachEnd) ||
|
||||
showUpdatesButton) && (
|
||||
<button
|
||||
class="updates-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!snapStates.settings.boostsCarousel) {
|
||||
const uniqueHomeNew = snapStates.homeNew.filter(
|
||||
(status) => !states.home.some((s) => s.id === status.id),
|
||||
);
|
||||
states.home.unshift(...uniqueHomeNew);
|
||||
}
|
||||
loadStatuses(true);
|
||||
states.homeNew = [];
|
||||
{snapStates.homeNew.length > 0 &&
|
||||
uiState !== 'loading' &&
|
||||
((scrollDirection === 'start' &&
|
||||
!nearReachStart &&
|
||||
!nearReachEnd) ||
|
||||
showUpdatesButton) && (
|
||||
<button
|
||||
class="updates-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!snapStates.settings.boostsCarousel) {
|
||||
const uniqueHomeNew = snapStates.homeNew.filter(
|
||||
(status) =>
|
||||
!states.home.some((s) => s.id === status.id),
|
||||
);
|
||||
states.home.unshift(...uniqueHomeNew);
|
||||
}
|
||||
loadStatuses(true);
|
||||
states.homeNew = [];
|
||||
|
||||
scrollableRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-up" /> New posts
|
||||
</button>
|
||||
)}
|
||||
scrollableRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-up" /> New posts
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{snapStates.home.length ? (
|
||||
<>
|
||||
<ul class="timeline">
|
||||
|
|
|
@ -143,14 +143,16 @@ function Notifications() {
|
|||
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
>
|
||||
<div class="header-side">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
<Link to="/" class="button plain">
|
||||
<Icon icon="home" size="l" />
|
||||
</Link>
|
||||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{snapStates.notificationsNew.length > 0 && uiState !== 'loading' && (
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
grid-column: 1 / 3;
|
||||
}
|
||||
.status-deck header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-deck header h1 {
|
||||
|
|
|
@ -469,132 +469,135 @@ function StatusPage() {
|
|||
<Icon icon="chevron-left" size="xl" />
|
||||
</Link>
|
||||
</div> */}
|
||||
<h1>
|
||||
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
||||
<>
|
||||
<span class="hero-heading">
|
||||
<NameText
|
||||
account={heroStatus.account}
|
||||
instance={instance}
|
||||
showAvatar
|
||||
short
|
||||
/>{' '}
|
||||
<span class="insignificant">
|
||||
•{' '}
|
||||
<RelativeTime
|
||||
datetime={heroStatus.createdAt}
|
||||
format="micro"
|
||||
<div class="header-grid">
|
||||
<h1>
|
||||
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
||||
<>
|
||||
<span class="hero-heading">
|
||||
<NameText
|
||||
account={heroStatus.account}
|
||||
instance={instance}
|
||||
showAvatar
|
||||
short
|
||||
/>{' '}
|
||||
<span class="insignificant">
|
||||
•{' '}
|
||||
<RelativeTime
|
||||
datetime={heroStatus.createdAt}
|
||||
format="micro"
|
||||
/>
|
||||
</span>
|
||||
</span>{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="ancestors-indicator light small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
heroStatusRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||||
/>
|
||||
</span>
|
||||
</span>{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="ancestors-indicator light small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
heroStatusRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Status{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="ancestors-indicator light small"
|
||||
onClick={(e) => {
|
||||
// Scroll to top
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollableRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
hidden={!ancestors.length || nearReachStart}
|
||||
>
|
||||
<Icon icon="arrow-up" />
|
||||
<Icon icon="comment" />{' '}
|
||||
<span class="insignificant">
|
||||
{shortenNumber(ancestors.length)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<Menu
|
||||
align="end"
|
||||
portal={{
|
||||
// Need this, else the menu click will cause scroll jump
|
||||
target: scrollableRef.current,
|
||||
}}
|
||||
menuButton={
|
||||
<button type="button" class="button plain4">
|
||||
<Icon icon="more" alt="Actions" size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// Click all buttons with class .spoiler but not .spoiling
|
||||
const buttons = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
'button.spoiler:not(.spoiling)',
|
||||
),
|
||||
);
|
||||
buttons.forEach((button) => {
|
||||
button.click();
|
||||
});
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Status{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="ancestors-indicator light small"
|
||||
onClick={(e) => {
|
||||
// Scroll to top
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollableRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
hidden={!ancestors.length || nearReachStart}
|
||||
>
|
||||
<Icon icon="arrow-up" />
|
||||
<Icon icon="comment" />{' '}
|
||||
<span class="insignificant">
|
||||
{shortenNumber(ancestors.length)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<Menu
|
||||
align="end"
|
||||
portal={{
|
||||
// Need this, else the menu click will cause scroll jump
|
||||
target: scrollableRef.current,
|
||||
}}
|
||||
menuButton={
|
||||
<button type="button" class="button plain4">
|
||||
<Icon icon="more" alt="Actions" size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
||||
</MenuItem>
|
||||
{import.meta.env.DEV && !authenticated && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { masto } = api();
|
||||
const results = await masto.v2.search({
|
||||
q: heroStatus.url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
limit: 1,
|
||||
});
|
||||
if (results.statuses.length) {
|
||||
const status = results.statuses[0];
|
||||
navigate(`/s/${status.id}`);
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e);
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
// Click all buttons with class .spoiler but not .spoiling
|
||||
const buttons = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
'button.spoiler:not(.spoiling)',
|
||||
),
|
||||
);
|
||||
buttons.forEach((button) => {
|
||||
button.click();
|
||||
});
|
||||
}}
|
||||
>
|
||||
See post in currently logged-in instance
|
||||
<Icon icon="eye-open" />{' '}
|
||||
<span>Show all sensitive content</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<Link
|
||||
class="button plain deck-close"
|
||||
to={closeLink}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon icon="x" size="xl" />
|
||||
</Link>
|
||||
{import.meta.env.DEV && !authenticated && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { masto } = api();
|
||||
const results = await masto.v2.search({
|
||||
q: heroStatus.url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
limit: 1,
|
||||
});
|
||||
if (results.statuses.length) {
|
||||
const status = results.statuses[0];
|
||||
navigate(`/s/${status.id}`);
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e);
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
See post in currently logged-in instance
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<Link
|
||||
class="button plain deck-close"
|
||||
to={closeLink}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon icon="x" size="xl" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!!statuses.length && heroStatus ? (
|
||||
|
|
|
@ -1,22 +1,25 @@
|
|||
// useInterval with Preact
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
|
||||
export default function useInterval(callback, delay) {
|
||||
const savedCallback = useRef();
|
||||
const noop = () => {};
|
||||
|
||||
function useInterval(callback, delay, immediate) {
|
||||
const savedCallback = useRef(noop);
|
||||
|
||||
// Remember the latest callback.
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
}, []);
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
savedCallback.current();
|
||||
}
|
||||
if (delay !== null) {
|
||||
let id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
if (!immediate || delay === null || delay === false) return;
|
||||
savedCallback.current();
|
||||
}, [immediate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === null || delay === false) return;
|
||||
const tick = () => savedCallback.current();
|
||||
const id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
export default useInterval;
|
||||
|
|
|
@ -4,6 +4,7 @@ export default function usePageVisibility(fn = () => {}, deps = []) {
|
|||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const hidden = document.hidden || document.visibilityState === 'hidden';
|
||||
console.log('👀 Page visibility changed', hidden);
|
||||
fn(!hidden);
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue