mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-25 18:55:44 +03:00
New experiment: dedupe boosts and group context
This commit is contained in:
parent
6a273b51bd
commit
caee38c98f
5 changed files with 304 additions and 93 deletions
45
src/app.css
45
src/app.css
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
119
src/utils/timeline-utils.jsx
Normal file
119
src/utils/timeline-utils.jsx
Normal 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;
|
||||||
|
}
|
Loading…
Reference in a new issue