From caee38c98fd59538803a8c927712c1bf0b2ab402 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Sun, 26 Mar 2023 23:18:36 +0800 Subject: [PATCH] New experiment: dedupe boosts and group context --- src/app.css | 45 +++++++- src/components/status.css | 20 ++++ src/components/timeline.jsx | 210 ++++++++++++++++++++--------------- src/pages/following.jsx | 3 + src/utils/timeline-utils.jsx | 119 ++++++++++++++++++++ 5 files changed, 304 insertions(+), 93 deletions(-) create mode 100644 src/utils/timeline-utils.jsx diff --git a/src/app.css b/src/app.css index 75ca6250..c81890f6 100644 --- a/src/app.css +++ b/src/app.css @@ -521,6 +521,48 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { 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 { text-align: center; color: var(--text-insignificant-color); @@ -1612,7 +1654,7 @@ ul.link-list li a .icon { --back-transition: transform 0.4s ease-out; } .timeline:not(.flat) > li > a { - border-radius: var(--item-radius); + border-radius: inherit; } .timeline:not(.flat) > li:not(:has(.status-carousel)) { transform: translate3d(0, 0, 0); @@ -1623,6 +1665,7 @@ ul.link-list li a .icon { } .timeline:not(.flat) > 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) > li:not(:has(.status-carousel)):has(.status-link.is-active) + li { diff --git a/src/components/status.css b/src/components/status.css index a6121382..86ebfd81 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -151,6 +151,26 @@ 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 { flex-grow: 1; min-width: 0; diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index cf6a3066..f1efe4b7 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -2,7 +2,11 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; 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 usePageVisibility from '../utils/usePageVisibility'; import useScroll from '../utils/useScroll'; @@ -49,6 +53,7 @@ function Timeline({ if (boostsCarousel) { value = groupBoosts(value); } + value = groupContext(value); console.log(value); if (firstLoad) { setItems(value); @@ -313,54 +318,101 @@ function Timeline({ } else if (type === 'pinned') { title = 'Pinned posts'; } + const isCarousel = type === 'boosts' || type === 'pinned'; if (items) { - // Here, we don't hide filtered posts, but we sort them last - items.sort((a, b) => { - if (a._filtered && !b._filtered) { - return 1; - } - if (!a._filtered && b._filtered) { - return -1; - } - return 0; + if (isCarousel) { + // Here, we don't hide filtered posts, but we sort them last + items.sort((a, b) => { + if (a._filtered && !b._filtered) { + return 1; + } + if (!a._filtered && b._filtered) { + return -1; + } + return 0; + }); + return ( +
  • + + {items.map((item) => { + const { id: statusID, reblog } = item; + const actualStatusID = reblog?.id || statusID; + const url = instance + ? `/${instance}/s/${actualStatusID}` + : `/s/${actualStatusID}`; + return ( +
  • + + {useItemID ? ( + + ) : ( + + )} + +
  • + ); + })} + + + ); + } + 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 ( +
  • + + {manyItems && isMiddle && type === 'thread' ? ( + + ) : useItemID ? ( + + ) : ( + + )} + +
  • + ); }); - return ( -
  • - - {items.map((item) => { - const { id: statusID, reblog } = item; - const actualStatusID = reblog?.id || statusID; - const url = instance - ? `/${instance}/s/${actualStatusID}` - : `/s/${actualStatusID}`; - return ( -
  • - - {useItemID ? ( - - ) : ( - - )} - -
  • - ); - })} - - - ); } return (
  • @@ -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 }) { const carouselRef = useRef(); 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 ( +
    + {!!snapStates.statusThreadNumber[sKey] && ( +
    + + {snapStates.statusThreadNumber[sKey] + ? ` ${snapStates.statusThreadNumber[sKey]}/X` + : ''} +
    + )} +
    {statusPeekText}
    +
    + ); +} + export default Timeline; diff --git a/src/pages/following.jsx b/src/pages/following.jsx index c87be920..668bf48f 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -6,6 +6,7 @@ import { api } from '../utils/api'; import { filteredItems } from '../utils/filters'; import states from '../utils/states'; import { getStatus, saveStatus } from '../utils/states'; +import { dedupeBoosts } from '../utils/timeline-utils'; import useTitle from '../utils/useTitle'; const LIMIT = 20; @@ -29,6 +30,7 @@ function Following({ title, path, id, ...props }) { console.log('First load', latestItem.current); } + value = dedupeBoosts(value, instance); value = filteredItems(value, 'home'); value.forEach((item) => { saveStatus(item, instance); @@ -56,6 +58,7 @@ function Following({ title, path, id, ...props }) { console.log('checkForUpdates', latestItem.current, value); if (value?.length) { latestItem.current = value[0].id; + value = dedupeBoosts(value, instance); value = filteredItems(value, 'home'); if (value.some((item) => !item.reblog)) { return true; diff --git a/src/utils/timeline-utils.jsx b/src/utils/timeline-utils.jsx new file mode 100644 index 00000000..9637ce09 --- /dev/null +++ b/src/utils/timeline-utils.jsx @@ -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; +}