New experiment: dedupe boosts and group context

This commit is contained in:
Lim Chee Aun 2023-03-26 23:18:36 +08:00
parent 6a273b51bd
commit caee38c98f
5 changed files with 304 additions and 93 deletions

View file

@ -521,6 +521,48 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
margin-bottom: 3em; margin-bottom: 3em;
} }
.timeline:not(.flat) > li.timeline-item-container {
--line-start: 40px;
--line-width: 3px;
--line-end: calc(var(--line-start) + var(--line-width));
background-image: linear-gradient(
to right,
transparent,
transparent var(--line-start),
var(--comment-line-color) var(--line-start),
var(--comment-line-color) var(--line-end),
transparent var(--line-end),
transparent
);
background-repeat: no-repeat;
}
.timeline:not(.flat) > li.timeline-item-container-start {
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 0;
background-position: 0 16px;
}
.timeline:not(.flat) > li.timeline-item-container-middle {
margin-top: 0;
margin-bottom: 0;
border-radius: 0;
border-bottom: 0;
border-top: 0;
}
.timeline:not(.flat) > li.timeline-item-container-end {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
background-size: 100% 20px;
}
.timeline:not(.flat)
> li:is(.timeline-item-container-middle, .timeline-item-container-end)
.status {
background-image: none;
}
.status-loading { .status-loading {
text-align: center; text-align: center;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
@ -1612,7 +1654,7 @@ ul.link-list li a .icon {
--back-transition: transform 0.4s ease-out; --back-transition: transform 0.4s ease-out;
} }
.timeline:not(.flat) > li > a { .timeline:not(.flat) > li > a {
border-radius: var(--item-radius); border-radius: inherit;
} }
.timeline:not(.flat) > li:not(:has(.status-carousel)) { .timeline:not(.flat) > li:not(:has(.status-carousel)) {
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
@ -1623,6 +1665,7 @@ ul.link-list li a .icon {
} }
.timeline:not(.flat) .timeline:not(.flat)
> li:not(:has(.status-carousel)):has(+ li .status-link.is-active), > li:not(:has(.status-carousel)):has(+ li .status-link.is-active),
.timeline:not(.flat) > li.timeline-item-container:has(.status-link.is-active),
.timeline:not(.flat) .timeline:not(.flat)
> li:not(:has(.status-carousel)):has(.status-link.is-active) > li:not(:has(.status-carousel)):has(.status-link.is-active)
+ li { + li {

View file

@ -151,6 +151,26 @@
transform: translateX(0); transform: translateX(0);
} }
.status.compact-thread {
display: flex;
gap: 8px;
}
.status.compact-thread .status-thread-badge {
flex-shrink: 0;
min-width: 50px;
justify-content: center;
}
.status.compact-thread .content-compact {
overflow: hidden;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
font-size: 90%;
}
.status .container { .status .container {
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;

View file

@ -2,7 +2,11 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval'; import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
@ -49,6 +53,7 @@ function Timeline({
if (boostsCarousel) { if (boostsCarousel) {
value = groupBoosts(value); value = groupBoosts(value);
} }
value = groupContext(value);
console.log(value); console.log(value);
if (firstLoad) { if (firstLoad) {
setItems(value); setItems(value);
@ -313,54 +318,101 @@ function Timeline({
} else if (type === 'pinned') { } else if (type === 'pinned') {
title = 'Pinned posts'; title = 'Pinned posts';
} }
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) { if (items) {
// Here, we don't hide filtered posts, but we sort them last if (isCarousel) {
items.sort((a, b) => { // Here, we don't hide filtered posts, but we sort them last
if (a._filtered && !b._filtered) { items.sort((a, b) => {
return 1; if (a._filtered && !b._filtered) {
} return 1;
if (!a._filtered && b._filtered) { }
return -1; if (!a._filtered && b._filtered) {
} return -1;
return 0; }
return 0;
});
return (
<li key={`timeline-${statusID}`}>
<StatusCarousel
title={title}
class={`${type}-carousel`}
>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const { id: statusID } = item;
const url = instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0
? 'start'
: i === items.length - 1
? 'end'
: 'middle'
}`}
>
<Link class="status-link timeline-item" to={url}>
{manyItems && isMiddle && type === 'thread' ? (
<TimelineStatusCompact
status={item}
instance={instance}
/>
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
}); });
return (
<li key={`timeline-${statusID}`}>
<StatusCarousel title={title} class={`${type}-carousel`}>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
} }
return ( return (
<li key={`timeline-${statusID + _pinned}`}> <li key={`timeline-${statusID + _pinned}`}>
@ -452,52 +504,6 @@ function Timeline({
); );
} }
function groupBoosts(values) {
let newValues = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (item.reblog) {
boostStash.push(item);
serialBoosts++;
} else {
newValues.push(item);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of values
// or if there are 3 or more boosts in a row
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of values
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (values.length * 3) / 4) {
// insert boost array at the end of specialHome list
newValues = [
...newValues,
{ id: boostStashID, items: boostStash, type: 'boosts' },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(newValues.length / 2);
newValues = [
...newValues.slice(0, half),
{
id: boostStashID,
items: boostStash,
type: 'boosts',
},
...newValues.slice(half),
];
}
return newValues;
} else {
return values;
}
}
function StatusCarousel({ title, class: className, children }) { function StatusCarousel({ title, class: className, children }) {
const carouselRef = useRef(); const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({ const { reachStart, reachEnd, init } = useScroll({
@ -546,4 +552,24 @@ function StatusCarousel({ title, class: className, children }) {
); );
} }
function TimelineStatusCompact({ status, instance }) {
const snapStates = useSnapshot(states);
const { id } = status;
const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance);
return (
<article class="status compact-thread" tabindex="-1">
{!!snapStates.statusThreadNumber[sKey] && (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
</div>
)}
<div class="content-compact">{statusPeekText}</div>
</article>
);
}
export default Timeline; export default Timeline;

View file

@ -6,6 +6,7 @@ import { api } from '../utils/api';
import { filteredItems } from '../utils/filters'; import { filteredItems } from '../utils/filters';
import states from '../utils/states'; import states from '../utils/states';
import { getStatus, saveStatus } from '../utils/states'; import { getStatus, saveStatus } from '../utils/states';
import { dedupeBoosts } from '../utils/timeline-utils';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
@ -29,6 +30,7 @@ function Following({ title, path, id, ...props }) {
console.log('First load', latestItem.current); console.log('First load', latestItem.current);
} }
value = dedupeBoosts(value, instance);
value = filteredItems(value, 'home'); value = filteredItems(value, 'home');
value.forEach((item) => { value.forEach((item) => {
saveStatus(item, instance); saveStatus(item, instance);
@ -56,6 +58,7 @@ function Following({ title, path, id, ...props }) {
console.log('checkForUpdates', latestItem.current, value); console.log('checkForUpdates', latestItem.current, value);
if (value?.length) { if (value?.length) {
latestItem.current = value[0].id; latestItem.current = value[0].id;
value = dedupeBoosts(value, instance);
value = filteredItems(value, 'home'); value = filteredItems(value, 'home');
if (value.some((item) => !item.reblog)) { if (value.some((item) => !item.reblog)) {
return true; return true;

View file

@ -0,0 +1,119 @@
import { getStatus } from './states';
export function groupBoosts(values) {
let newValues = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (item.reblog) {
boostStash.push(item);
serialBoosts++;
} else {
newValues.push(item);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of values
// or if there are 3 or more boosts in a row
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of values
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (values.length * 3) / 4) {
// insert boost array at the end of specialHome list
newValues = [
...newValues,
{ id: boostStashID, items: boostStash, type: 'boosts' },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(newValues.length / 2);
newValues = [
...newValues.slice(0, half),
{
id: boostStashID,
items: boostStash,
type: 'boosts',
},
...newValues.slice(half),
];
}
return newValues;
} else {
return values;
}
}
export function dedupeBoosts(items, instance) {
return items.filter((item) => {
if (!item.reblog) return true;
const s = getStatus(item.reblog.id, instance);
if (s) {
console.warn('🚫 Duplicate boost', item);
return false;
}
const s2 = getStatus(item.id, instance);
if (s2) {
console.warn('🚫 Re-boosted boost', item);
return false;
}
return true;
});
}
export function groupContext(items) {
const contexts = [];
let contextIndex = 0;
items.forEach((item) => {
for (let i = 0; i < contexts.length; i++) {
if (contexts[i].find((t) => t.id === item.id)) return;
if (
contexts[i].find((t) => t.id === item.inReplyToId) ||
contexts[i].find((t) => t.inReplyToId === item.id)
) {
contexts[i].push(item);
return;
}
}
const repliedItem = items.find((i) => i.id === item.inReplyToId);
if (repliedItem) {
contexts[contextIndex++] = [item, repliedItem];
}
});
if (contexts.length) console.log('🧵 Contexts', contexts);
const newItems = [];
const appliedContextIndices = [];
items.forEach((item) => {
if (item.reblog) {
newItems.push(item);
return;
}
for (let i = 0; i < contexts.length; i++) {
if (contexts[i].find((t) => t.id === item.id)) {
if (appliedContextIndices.includes(i)) return;
const contextItems = contexts[i];
contextItems.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate - bDate;
});
const firstItemAccountID = contextItems[0].account.id;
newItems.push({
id: contextItems.map((i) => i.id),
items: contextItems,
type: contextItems.every((it) => it.account.id === firstItemAccountID)
? 'thread'
: 'conversation',
});
appliedContextIndices.push(i);
return;
}
}
newItems.push(item);
});
return newItems;
}