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;
}
.deck header {
.deck > header {
min-height: 3em;
position: sticky;
top: 0;
@ -93,25 +93,25 @@ a.mention span {
transition: transform 0.5s ease-in-out;
user-select: none;
}
.deck header[hidden] {
.deck > header[hidden] {
transform: translateY(-100%);
pointer-events: none;
user-select: none;
}
.deck header > .header-side:last-of-type {
.deck > header > .header-side:last-of-type {
text-align: right;
grid-column: 3;
}
.deck header :is(button, .button).plain {
.deck > header :is(button, .button).plain {
backdrop-filter: none;
}
.deck header h1 {
.deck > header h1 {
margin: 0 8px;
padding: 0;
font-size: 1.2em;
text-align: center;
}
.deck header h1:first-child {
.deck > header h1:first-child {
text-align: left;
padding-left: 8px;
}
@ -368,11 +368,109 @@ a.mention span {
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 {
padding: 16px;
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 {
position: fixed;
top: 0;
@ -867,7 +965,7 @@ meter.donut:is(.danger, .explode):after {
border: 0;
background-color: transparent;
}
.timeline-deck header {
.timeline-deck > header {
min-height: 6em;
border-bottom: 0;
background-color: var(--bg-faded-blur-color);
@ -884,10 +982,10 @@ meter.donut:is(.danger, .explode):after {
transparent
);
}
.deck header h1 {
.deck > header h1 {
font-size: 1.5em;
}
.timeline-deck .timeline:not(.flat) li {
.timeline-deck .timeline:not(.flat) > li {
border: 1px solid var(--divider-color);
margin: 16px 0;
background-color: var(--bg-color);

View file

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

View file

@ -47,6 +47,55 @@ function Home({ hidden }) {
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) {
states.home = homeValues;
} else {
@ -84,36 +133,35 @@ function Home({ hidden }) {
useHotkeys('j', () => {
// focus on next status after active status
// 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 allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const nextStatus = activeStatus.parentElement.nextElementSibling;
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
const nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) {
const statusLink = nextStatus.querySelector('.status-link');
if (statusLink) {
statusLink.focus();
}
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const statusLinks = document.querySelectorAll(
'.timeline li .status-link',
);
let topmostStatusLink;
for (const statusLink of statusLinks) {
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
if (statusLinkRect.top >= 44) {
// 44 is the magic number for header height, not real
topmostStatusLink = statusLink;
break;
}
}
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
@ -121,67 +169,68 @@ function Home({ hidden }) {
useHotkeys('k', () => {
// focus on previous status after active status
// 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 allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const prevStatus = activeStatus.parentElement.previousElementSibling;
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
const prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) {
const statusLink = prevStatus.querySelector('.status-link');
if (statusLink) {
statusLink.focus();
}
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const statusLinks = document.querySelectorAll(
'.timeline li .status-link',
);
let topmostStatusLink;
for (const statusLink of statusLinks) {
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
if (statusLinkRect.top >= 44) {
// 44 is the magic number for header height, not real
topmostStatusLink = statusLink;
break;
}
}
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
useHotkeys(['enter', 'o'], () => {
// open active status
const activeStatus = document.activeElement.closest('.status-link');
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
if (activeStatus) {
activeStatus.click();
}
});
const { scrollDirection, reachTop, nearReachTop, nearReachBottom } =
const { scrollDirection, reachStart, nearReachStart, nearReachEnd } =
useScroll({
scrollableElement: scrollableRef.current,
distanceFromTop: 0.1,
distanceFromBottom: 0.15,
scrollThresholdUp: 44,
distanceFromStart: 0.1,
distanceFromEnd: 0.15,
scrollThresholdStart: 44,
});
useEffect(() => {
if (nearReachBottom && showMore) {
if (nearReachEnd && showMore) {
loadStatuses();
}
}, [nearReachBottom]);
}, [nearReachEnd]);
useEffect(() => {
if (reachTop) {
if (reachStart) {
loadStatuses(true);
}
}, [reachTop]);
}, [reachStart]);
useEffect(() => {
(async () => {
@ -196,6 +245,10 @@ function Home({ hidden }) {
})();
}, []);
const snapHome = snapStates.settings.boostsCarousel
? snapStates.specialHome
: snapStates.home;
return (
<div
id="home-page"
@ -205,7 +258,7 @@ function Home({ hidden }) {
tabIndex="-1"
>
<button
hidden={scrollDirection === 'down' && !nearReachTop}
hidden={scrollDirection === 'down' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
@ -224,7 +277,7 @@ function Home({ hidden }) {
</button>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'down' && !nearReachTop}
hidden={scrollDirection === 'down' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
@ -263,8 +316,8 @@ function Home({ hidden }) {
</header>
{snapStates.homeNew.length > 0 &&
scrollDirection === 'up' &&
!nearReachTop &&
!nearReachBottom && (
!nearReachStart &&
!nearReachEnd && (
<button
class="updates-button"
type="button"
@ -285,11 +338,18 @@ function Home({ hidden }) {
<Icon icon="arrow-up" /> New posts
</button>
)}
{snapStates.home.length ? (
{snapHome.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog }) => {
{snapHome.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<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);

View file

@ -1,6 +1,7 @@
import './settings.css';
import { useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
@ -16,6 +17,7 @@ import store from '../utils/store';
*/
function Settings({ onClose }) {
const snapStates = useSnapshot(states);
// Accounts
const accounts = store.local.getJSON('accounts');
const currentAccount = store.session.get('currentAccount');
@ -184,6 +186,17 @@ function Settings({ onClose }) {
</label>
</div>
</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>
<p>
<button

View file

@ -295,9 +295,9 @@ function StatusPage({ id }) {
location.hash = closeLink;
});
const { nearReachTop } = useScroll({
const { nearReachStart } = useScroll({
scrollableElement: scrollableRef.current,
distanceFromTop: 0.1,
distanceFromStart: 0.1,
});
return (
@ -367,7 +367,7 @@ function StatusPage({ id }) {
behavior: 'smooth',
});
}}
hidden={!ancestors.length || nearReachTop}
hidden={!ancestors.length || nearReachStart}
>
<Icon icon="arrow-up" />
<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({
history: [],
statuses: {},
statusThreadNumber: {},
home: [],
specialHome: [],
homeNew: [],
homeLastFetchTime: null,
notifications: [],
@ -20,9 +23,19 @@ const states = proxy({
showAccount: false,
showDrafts: false,
composeCharacterCount: 0,
settings: {
boostsCarousel: store.local.get('settings:boostsCarousel') === '1' || true,
},
});
export default states;
subscribe(states.settings, () => {
store.local.set(
'settings:boostsCarousel',
states.settings.boostsCarousel ? '1' : '0',
);
});
export function saveStatus(status, opts) {
const { override, skipThreading } = Object.assign(
{ override: true, skipThreading: false },

View file

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