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;
+}