New experiment: Boosts Carousel™️

This commit is contained in:
Lim Chee Aun 2023-01-14 19:42:04 +08:00
parent 62e88e4b78
commit e2139399ee
7 changed files with 361 additions and 91 deletions

View file

@ -75,7 +75,7 @@ a.mention span {
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.deck header { .deck > header {
min-height: 3em; min-height: 3em;
position: sticky; position: sticky;
top: 0; top: 0;
@ -93,25 +93,25 @@ a.mention span {
transition: transform 0.5s ease-in-out; transition: transform 0.5s ease-in-out;
user-select: none; user-select: none;
} }
.deck header[hidden] { .deck > header[hidden] {
transform: translateY(-100%); transform: translateY(-100%);
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
.deck header > .header-side:last-of-type { .deck > header > .header-side:last-of-type {
text-align: right; text-align: right;
grid-column: 3; grid-column: 3;
} }
.deck header :is(button, .button).plain { .deck > header :is(button, .button).plain {
backdrop-filter: none; backdrop-filter: none;
} }
.deck header h1 { .deck > header h1 {
margin: 0 8px; margin: 0 8px;
padding: 0; padding: 0;
font-size: 1.2em; font-size: 1.2em;
text-align: center; text-align: center;
} }
.deck header h1:first-child { .deck > header h1:first-child {
text-align: left; text-align: left;
padding-left: 8px; padding-left: 8px;
} }
@ -368,11 +368,109 @@ a.mention span {
filter: brightness(0.95); filter: brightness(0.95);
} }
.boost-carousel {
background: linear-gradient(
to bottom right,
var(--reblog-faded-color),
transparent 60%
);
position: relative;
}
.boost-carousel:after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: radial-gradient(
ellipse 50% 32px at bottom center,
var(--reblog-faded-color),
transparent
),
linear-gradient(to top, var(--bg-color), transparent 64px);
background-repeat: no-repeat;
background-position: bottom center;
}
.boost-carousel .status-reblog {
background-image: none;
}
.boost-carousel header {
padding: 8px 16px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.boost-carousel h3 {
margin: 0;
padding: 0;
font-size: 14px;
text-transform: uppercase;
color: var(--reblog-color);
text-shadow: 0 1px var(--bg-color);
}
.boost-carousel ul {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
margin: 0;
padding: 8px 16px;
gap: 16px;
align-items: flex-start;
counter-reset: index;
}
.boost-carousel ul > li {
scroll-snap-align: center;
scroll-snap-stop: always;
flex-shrink: 0;
display: flex;
width: 100%;
max-width: min(320px, calc(100% - 16px));
list-style: none;
margin: 0;
padding: 0;
max-height: 70vh;
max-height: 70dvh;
counter-increment: index;
position: relative;
}
.boost-carousel ul > li:before {
content: counter(index);
position: absolute;
right: 0;
font-size: 10px;
color: var(--reblog-color);
padding: 8px;
opacity: 0.5;
}
.ui-state { .ui-state {
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.status-boost-link {
display: block;
width: 100%;
text-decoration-line: none;
color: inherit;
user-select: none;
transition: background-color 0.2s ease-out;
-webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out;
border: 1px solid var(--outline-color);
background-color: var(--bg-blur-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px var(--bg-color);
}
.status-boost-link:is(:hover, :focus) {
background-color: var(--link-bg-hover-color);
}
.status-boost-link:active:not(:has(:is(.media, button):active)) {
filter: brightness(0.95);
}
.deck-backdrop { .deck-backdrop {
position: fixed; position: fixed;
top: 0; top: 0;
@ -867,7 +965,7 @@ meter.donut:is(.danger, .explode):after {
border: 0; border: 0;
background-color: transparent; background-color: transparent;
} }
.timeline-deck header { .timeline-deck > header {
min-height: 6em; min-height: 6em;
border-bottom: 0; border-bottom: 0;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
@ -884,10 +982,10 @@ meter.donut:is(.danger, .explode):after {
transparent transparent
); );
} }
.deck header h1 { .deck > header h1 {
font-size: 1.5em; font-size: 1.5em;
} }
.timeline-deck .timeline:not(.flat) li { .timeline-deck .timeline:not(.flat) > li {
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
margin: 16px 0; margin: 16px 0;
background-color: var(--bg-color); background-color: var(--bg-color);

View file

@ -43,6 +43,7 @@ const ICONS = {
popin: ['mingcute:external-link-line', '180deg'], popin: ['mingcute:external-link-line', '180deg'],
plus: 'mingcute:add-circle-line', plus: 'mingcute:add-circle-line',
'chevron-left': 'mingcute:left-line', 'chevron-left': 'mingcute:left-line',
'chevron-right': 'mingcute:right-line',
reply: ['mingcute:share-forward-line', '180deg', 'horizontal'], reply: ['mingcute:share-forward-line', '180deg', 'horizontal'],
thread: 'mingcute:route-line', thread: 'mingcute:route-line',
group: 'mingcute:group-line', group: 'mingcute:group-line',

View file

@ -47,6 +47,55 @@ function Home({ hidden }) {
reply: !!status.inReplyToAccountId, reply: !!status.inReplyToAccountId,
}; };
}); });
{
// BOOSTS CAROUSEL
let specialHome = [];
let boostStash = [];
for (let i = 0; i < homeValues.length; i++) {
const status = homeValues[i];
if (status.reblog) {
boostStash.push(status);
} else {
specialHome.push(status);
}
}
// if boostStash is more than quarter of homeValues
if (boostStash.length > homeValues.length / 4) {
// if boostStash is more than 3 quarter of homeValues
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (homeValues.length * 3) / 4) {
// insert boost array at the end of specialHome list
specialHome = [
...specialHome,
{ id: boostStashID, boosts: boostStash },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(specialHome.length / 2);
specialHome = [
...specialHome.slice(0, half),
{
id: boostStashID,
boosts: boostStash,
},
...specialHome.slice(half),
];
}
} else {
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.specialHome = specialHome;
} else {
states.specialHome.push(...specialHome);
}
}
if (firstLoad) { if (firstLoad) {
states.home = homeValues; states.home = homeValues;
} else { } else {
@ -84,36 +133,35 @@ function Home({ hidden }) {
useHotkeys('j', () => { useHotkeys('j', () => {
// focus on next status after active status // focus on next status after active status
// Traverses .timeline li .status-link, focus on .status-link // Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest('.status-link'); const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect(); const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if ( if (
activeStatus && activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0 activeStatusRect.bottom > 0
) { ) {
const nextStatus = activeStatus.parentElement.nextElementSibling; const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
const nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) { if (nextStatus) {
const statusLink = nextStatus.querySelector('.status-link'); nextStatus.focus();
if (statusLink) { nextStatus.scrollIntoViewIfNeeded?.();
statusLink.focus();
}
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
const statusLinks = document.querySelectorAll( const topmostStatusLink = allStatusLinks.find((statusLink) => {
'.timeline li .status-link',
);
let topmostStatusLink;
for (const statusLink of statusLinks) {
const statusLinkRect = statusLink.getBoundingClientRect(); const statusLinkRect = statusLink.getBoundingClientRect();
if (statusLinkRect.top >= 44) { return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
// 44 is the magic number for header height, not real });
topmostStatusLink = statusLink;
break;
}
}
if (topmostStatusLink) { if (topmostStatusLink) {
topmostStatusLink.focus(); topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
} }
} }
}); });
@ -121,67 +169,68 @@ function Home({ hidden }) {
useHotkeys('k', () => { useHotkeys('k', () => {
// focus on previous status after active status // focus on previous status after active status
// Traverses .timeline li .status-link, focus on .status-link // Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest('.status-link'); const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect(); const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if ( if (
activeStatus && activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0 activeStatusRect.bottom > 0
) { ) {
const prevStatus = activeStatus.parentElement.previousElementSibling; const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
const prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) { if (prevStatus) {
const statusLink = prevStatus.querySelector('.status-link'); prevStatus.focus();
if (statusLink) { prevStatus.scrollIntoViewIfNeeded?.();
statusLink.focus();
}
} }
} else { } else {
// If active status is not in viewport, get the topmost status-link in viewport // If active status is not in viewport, get the topmost status-link in viewport
const statusLinks = document.querySelectorAll( const topmostStatusLink = allStatusLinks.find((statusLink) => {
'.timeline li .status-link',
);
let topmostStatusLink;
for (const statusLink of statusLinks) {
const statusLinkRect = statusLink.getBoundingClientRect(); const statusLinkRect = statusLink.getBoundingClientRect();
if (statusLinkRect.top >= 44) { return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
// 44 is the magic number for header height, not real });
topmostStatusLink = statusLink;
break;
}
}
if (topmostStatusLink) { if (topmostStatusLink) {
topmostStatusLink.focus(); topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
} }
} }
}); });
useHotkeys(['enter', 'o'], () => { useHotkeys(['enter', 'o'], () => {
// open active status // open active status
const activeStatus = document.activeElement.closest('.status-link'); const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
if (activeStatus) { if (activeStatus) {
activeStatus.click(); activeStatus.click();
} }
}); });
const { scrollDirection, reachTop, nearReachTop, nearReachBottom } = const { scrollDirection, reachStart, nearReachStart, nearReachEnd } =
useScroll({ useScroll({
scrollableElement: scrollableRef.current, scrollableElement: scrollableRef.current,
distanceFromTop: 0.1, distanceFromStart: 0.1,
distanceFromBottom: 0.15, distanceFromEnd: 0.15,
scrollThresholdUp: 44, scrollThresholdStart: 44,
}); });
useEffect(() => { useEffect(() => {
if (nearReachBottom && showMore) { if (nearReachEnd && showMore) {
loadStatuses(); loadStatuses();
} }
}, [nearReachBottom]); }, [nearReachEnd]);
useEffect(() => { useEffect(() => {
if (reachTop) { if (reachStart) {
loadStatuses(true); loadStatuses(true);
} }
}, [reachTop]); }, [reachStart]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -196,6 +245,10 @@ function Home({ hidden }) {
})(); })();
}, []); }, []);
const snapHome = snapStates.settings.boostsCarousel
? snapStates.specialHome
: snapStates.home;
return ( return (
<div <div
id="home-page" id="home-page"
@ -205,7 +258,7 @@ function Home({ hidden }) {
tabIndex="-1" tabIndex="-1"
> >
<button <button
hidden={scrollDirection === 'down' && !nearReachTop} hidden={scrollDirection === 'down' && !nearReachStart}
type="button" type="button"
id="compose-button" id="compose-button"
onClick={(e) => { onClick={(e) => {
@ -224,7 +277,7 @@ function Home({ hidden }) {
</button> </button>
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
hidden={scrollDirection === 'down' && !nearReachTop} hidden={scrollDirection === 'down' && !nearReachStart}
onClick={() => { onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
@ -263,8 +316,8 @@ function Home({ hidden }) {
</header> </header>
{snapStates.homeNew.length > 0 && {snapStates.homeNew.length > 0 &&
scrollDirection === 'up' && scrollDirection === 'up' &&
!nearReachTop && !nearReachStart &&
!nearReachBottom && ( !nearReachEnd && (
<button <button
class="updates-button" class="updates-button"
type="button" type="button"
@ -285,11 +338,18 @@ function Home({ hidden }) {
<Icon icon="arrow-up" /> New posts <Icon icon="arrow-up" /> New posts
</button> </button>
)} )}
{snapStates.home.length ? ( {snapHome.length ? (
<> <>
<ul class="timeline"> <ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog }) => { {snapHome.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID; const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return ( return (
<li key={statusID}> <li key={statusID}>
<Link <Link
@ -367,4 +427,61 @@ function Home({ hidden }) {
); );
} }
function BoostsCarousel({ boosts }) {
const carouselRef = useRef();
const { reachStart, reachEnd } = useScroll({
scrollableElement: carouselRef.current,
direction: 'horizontal',
});
console.log({ reachStart, reachEnd });
return (
<div class="boost-carousel">
<header>
<h3>{boosts.length} Boosts</h3>
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID;
return (
<li>
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</a>
</li>
);
})}
</ul>
</div>
);
}
export default memo(Home); export default memo(Home);

View file

@ -1,6 +1,7 @@
import './settings.css'; import './settings.css';
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
@ -16,6 +17,7 @@ import store from '../utils/store';
*/ */
function Settings({ onClose }) { function Settings({ onClose }) {
const snapStates = useSnapshot(states);
// Accounts // Accounts
const accounts = store.local.getJSON('accounts'); const accounts = store.local.getJSON('accounts');
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
@ -184,6 +186,17 @@ function Settings({ onClose }) {
</label> </label>
</div> </div>
</form> </form>
<h2>Settings</h2>
<label>
<input
type="checkbox"
checked={snapStates.settings.boostsCarousel}
onChange={(e) => {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel (experimental)
</label>
<h2>Hidden features</h2> <h2>Hidden features</h2>
<p> <p>
<button <button

View file

@ -295,9 +295,9 @@ function StatusPage({ id }) {
location.hash = closeLink; location.hash = closeLink;
}); });
const { nearReachTop } = useScroll({ const { nearReachStart } = useScroll({
scrollableElement: scrollableRef.current, scrollableElement: scrollableRef.current,
distanceFromTop: 0.1, distanceFromStart: 0.1,
}); });
return ( return (
@ -367,7 +367,7 @@ function StatusPage({ id }) {
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
hidden={!ancestors.length || nearReachTop} hidden={!ancestors.length || nearReachStart}
> >
<Icon icon="arrow-up" /> <Icon icon="arrow-up" />
<Icon icon="comment" />{' '} <Icon icon="comment" />{' '}

View file

@ -1,10 +1,13 @@
import { proxy } from 'valtio'; import { proxy, subscribe } from 'valtio';
import store from './store';
const states = proxy({ const states = proxy({
history: [], history: [],
statuses: {}, statuses: {},
statusThreadNumber: {}, statusThreadNumber: {},
home: [], home: [],
specialHome: [],
homeNew: [], homeNew: [],
homeLastFetchTime: null, homeLastFetchTime: null,
notifications: [], notifications: [],
@ -20,9 +23,19 @@ const states = proxy({
showAccount: false, showAccount: false,
showDrafts: false, showDrafts: false,
composeCharacterCount: 0, composeCharacterCount: 0,
settings: {
boostsCarousel: store.local.get('settings:boostsCarousel') === '1' || true,
},
}); });
export default states; export default states;
subscribe(states.settings, () => {
store.local.set(
'settings:boostsCarousel',
states.settings.boostsCarousel ? '1' : '0',
);
});
export function saveStatus(status, opts) { export function saveStatus(status, opts) {
const { override, skipThreading } = Object.assign( const { override, skipThreading } = Object.assign(
{ override: true, skipThreading: false }, { override: true, skipThreading: false },

View file

@ -1,55 +1,83 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
export default function useScroll({ export default function useScroll({
scrollableElement = window, scrollableElement,
distanceFromTop = 0, distanceFromStart = 0,
distanceFromBottom = 0, distanceFromEnd = 0,
scrollThresholdUp = 10, scrollThresholdStart = 10,
scrollThresholdDown = 10, scrollThresholdEnd = 10,
direction = 'vertical',
} = {}) { } = {}) {
const [scrollDirection, setScrollDirection] = useState(null); const [scrollDirection, setScrollDirection] = useState(null);
const [reachTop, setReachTop] = useState(false); const [reachStart, setReachStart] = useState(false);
const [nearReachTop, setNearReachTop] = useState(false); const [reachEnd, setReachEnd] = useState(false);
const [nearReachBottom, setNearReachBottom] = useState(false); const [nearReachStart, setNearReachStart] = useState(false);
const [nearReachEnd, setNearReachEnd] = useState(false);
const isVertical = direction === 'vertical';
if (!scrollableElement) {
console.warn('Scrollable element is not defined');
scrollableElement = window;
}
useEffect(() => { useEffect(() => {
let previousScrollTop = scrollableElement.scrollTop; let previousScrollStart = isVertical
? scrollableElement.scrollTop
: scrollableElement.scrollLeft;
function onScroll() { function onScroll() {
const { scrollTop, scrollHeight, clientHeight } = scrollableElement; const {
const scrollDistance = Math.abs(scrollTop - previousScrollTop); scrollTop,
const distanceFromTopPx = scrollLeft,
scrollHeight * Math.min(1, Math.max(0, distanceFromTop)); scrollHeight,
const distanceFromBottomPx = scrollWidth,
scrollHeight * Math.min(1, Math.max(0, distanceFromBottom)); clientHeight,
clientWidth,
} = scrollableElement;
const scrollStart = isVertical ? scrollTop : scrollLeft;
const scrollDimension = isVertical ? scrollHeight : scrollWidth;
const clientDimension = isVertical ? clientHeight : clientWidth;
const scrollDistance = Math.abs(scrollStart - previousScrollStart);
const distanceFromStartPx =
scrollDimension * Math.min(1, Math.max(0, distanceFromStart));
const distanceFromEndPx =
scrollDimension * Math.min(1, Math.max(0, distanceFromEnd));
if ( if (
scrollDistance >= scrollDistance >=
(previousScrollTop < scrollTop (previousScrollStart < scrollStart
? scrollThresholdDown ? scrollThresholdEnd
: scrollThresholdUp) : scrollThresholdStart)
) { ) {
setScrollDirection(previousScrollTop < scrollTop ? 'down' : 'up'); setScrollDirection(previousScrollStart < scrollStart ? 'end' : 'start');
previousScrollTop = scrollTop; previousScrollStart = scrollStart;
} }
setReachTop(scrollTop === 0); setReachStart(scrollStart === 0);
setNearReachTop(scrollTop <= distanceFromTopPx); setReachEnd(scrollStart + clientDimension >= scrollDimension);
setNearReachBottom( setNearReachStart(scrollStart <= distanceFromStartPx);
scrollTop + clientHeight >= scrollHeight - distanceFromBottomPx, setNearReachEnd(
scrollStart + clientDimension >= scrollDimension - distanceFromEndPx,
); );
} }
scrollableElement.addEventListener('scroll', onScroll, { passive: true }); scrollableElement.addEventListener('scroll', onScroll, { passive: true });
scrollableElement.dispatchEvent(new Event('scroll'));
return () => scrollableElement.removeEventListener('scroll', onScroll); return () => scrollableElement.removeEventListener('scroll', onScroll);
}, [ }, [
scrollableElement, scrollableElement,
distanceFromTop, distanceFromStart,
distanceFromBottom, distanceFromEnd,
scrollThresholdUp, scrollThresholdStart,
scrollThresholdDown, scrollThresholdEnd,
]); ]);
return { scrollDirection, reachTop, nearReachTop, nearReachBottom }; return {
scrollDirection,
reachStart,
reachEnd,
nearReachStart,
nearReachEnd,
};
} }