2022-12-21 13:02:13 +03:00
|
|
|
import debounce from 'just-debounce-it';
|
2022-12-10 12:14:48 +03:00
|
|
|
import { Link } from 'preact-router/match';
|
|
|
|
import {
|
|
|
|
useEffect,
|
|
|
|
useLayoutEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'preact/hooks';
|
|
|
|
import { useSnapshot } from 'valtio';
|
|
|
|
|
|
|
|
import Icon from '../components/icon';
|
|
|
|
import Loader from '../components/loader';
|
|
|
|
import Status from '../components/status';
|
2022-12-22 19:30:55 +03:00
|
|
|
import htmlContentLength from '../utils/html-content-length';
|
2022-12-19 12:38:20 +03:00
|
|
|
import shortenNumber from '../utils/shorten-number';
|
2022-12-10 12:14:48 +03:00
|
|
|
import states from '../utils/states';
|
2022-12-20 20:02:48 +03:00
|
|
|
import store from '../utils/store';
|
2022-12-10 12:14:48 +03:00
|
|
|
import useTitle from '../utils/useTitle';
|
|
|
|
|
2022-12-22 19:30:55 +03:00
|
|
|
const LIMIT = 40;
|
|
|
|
|
2022-12-16 08:27:04 +03:00
|
|
|
function StatusPage({ id }) {
|
2022-12-10 12:14:48 +03:00
|
|
|
const snapStates = useSnapshot(states);
|
2022-12-21 13:02:13 +03:00
|
|
|
const [statuses, setStatuses] = useState([]);
|
2022-12-10 12:14:48 +03:00
|
|
|
const [uiState, setUIState] = useState('default');
|
2022-12-21 13:02:13 +03:00
|
|
|
const userInitiated = useRef(true); // Initial open is user-initiated
|
2022-12-10 12:14:48 +03:00
|
|
|
const heroStatusRef = useRef();
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
const scrollableRef = useRef();
|
2022-12-20 20:02:48 +03:00
|
|
|
useEffect(() => {
|
2022-12-21 13:02:13 +03:00
|
|
|
const onScroll = debounce(() => {
|
|
|
|
// console.log('onScroll');
|
|
|
|
const { scrollTop } = scrollableRef.current;
|
|
|
|
states.scrollPositions.set(id, scrollTop);
|
|
|
|
}, 100);
|
|
|
|
scrollableRef.current.addEventListener('scroll', onScroll, {
|
|
|
|
passive: true,
|
|
|
|
});
|
|
|
|
onScroll();
|
|
|
|
return () => {
|
|
|
|
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
|
|
|
};
|
|
|
|
}, [id]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setUIState('loading');
|
|
|
|
|
2022-12-23 06:28:25 +03:00
|
|
|
const cachedStatuses = store.session.getJSON('statuses-' + id);
|
|
|
|
if (cachedStatuses) {
|
|
|
|
// Case 1: It's cached, let's restore them to make it snappy
|
|
|
|
const reallyCachedStatuses = cachedStatuses.filter(
|
|
|
|
(s) => states.statuses.has(s.id),
|
|
|
|
// Some are not cached in the global state, so we need to filter them out
|
|
|
|
);
|
|
|
|
setStatuses(reallyCachedStatuses);
|
2022-12-20 20:02:48 +03:00
|
|
|
} else {
|
2022-12-23 06:28:25 +03:00
|
|
|
const heroIndex = statuses.findIndex((s) => s.id === id);
|
|
|
|
if (heroIndex !== -1) {
|
|
|
|
// Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe
|
2022-12-21 13:02:13 +03:00
|
|
|
const slicedStatuses = statuses.slice(0, heroIndex + 1);
|
|
|
|
setStatuses(slicedStatuses);
|
2022-12-23 06:28:25 +03:00
|
|
|
} else {
|
|
|
|
// Case 3: Not cached and not in statuses, let's start from scratch
|
|
|
|
setStatuses([{ id }]);
|
2022-12-20 20:02:48 +03:00
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
(async () => {
|
2022-12-23 07:30:49 +03:00
|
|
|
const heroFetch = masto.statuses.fetch(id);
|
|
|
|
const contextFetch = masto.statuses.fetchContext(id);
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
const hasStatus = snapStates.statuses.has(id);
|
|
|
|
let heroStatus = snapStates.statuses.get(id);
|
|
|
|
try {
|
2022-12-23 07:30:49 +03:00
|
|
|
heroStatus = await heroFetch;
|
2022-12-21 13:02:13 +03:00
|
|
|
states.statuses.set(id, heroStatus);
|
|
|
|
} catch (e) {
|
|
|
|
// Silent fail if status is cached
|
|
|
|
if (!hasStatus) {
|
|
|
|
setUIState('error');
|
|
|
|
alert('Error fetching status');
|
|
|
|
}
|
|
|
|
return;
|
2022-12-10 12:14:48 +03:00
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
try {
|
2022-12-23 07:30:49 +03:00
|
|
|
const context = await contextFetch;
|
2022-12-21 13:02:13 +03:00
|
|
|
const { ancestors, descendants } = context;
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
ancestors.forEach((status) => {
|
|
|
|
states.statuses.set(status.id, status);
|
|
|
|
});
|
|
|
|
const nestedDescendants = [];
|
|
|
|
descendants.forEach((status) => {
|
|
|
|
states.statuses.set(status.id, status);
|
|
|
|
if (status.inReplyToAccountId === status.account.id) {
|
|
|
|
// If replying to self, it's part of the thread, level 1
|
|
|
|
nestedDescendants.push(status);
|
|
|
|
} else if (status.inReplyToId === heroStatus.id) {
|
|
|
|
// If replying to the hero status, it's a reply, level 1
|
|
|
|
nestedDescendants.push(status);
|
2022-12-18 15:46:13 +03:00
|
|
|
} else {
|
2022-12-21 13:02:13 +03:00
|
|
|
// If replying to someone else, it's a reply to a reply, level 2
|
|
|
|
const parent = descendants.find((s) => s.id === status.inReplyToId);
|
|
|
|
if (parent) {
|
|
|
|
if (!parent.__replies) {
|
|
|
|
parent.__replies = [];
|
|
|
|
}
|
|
|
|
parent.__replies.push(status);
|
|
|
|
} else {
|
|
|
|
// If no parent, it's probably a reply to a reply to a reply, level 3
|
|
|
|
console.warn('[LEVEL 3] No parent found for', status);
|
|
|
|
}
|
2022-12-18 15:46:13 +03:00
|
|
|
}
|
2022-12-21 13:02:13 +03:00
|
|
|
});
|
2022-12-18 15:46:13 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
console.log({ ancestors, descendants, nestedDescendants });
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
const allStatuses = [
|
|
|
|
...ancestors.map((s) => ({
|
|
|
|
id: s.id,
|
|
|
|
ancestor: true,
|
|
|
|
accountID: s.account.id,
|
|
|
|
})),
|
|
|
|
{ id, accountID: heroStatus.account.id },
|
|
|
|
...nestedDescendants.map((s) => ({
|
|
|
|
id: s.id,
|
|
|
|
accountID: s.account.id,
|
|
|
|
descendant: true,
|
|
|
|
thread: s.account.id === heroStatus.account.id,
|
|
|
|
replies: s.__replies?.map((r) => r.id),
|
|
|
|
})),
|
|
|
|
];
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
setUIState('default');
|
|
|
|
console.log({ allStatuses });
|
|
|
|
setStatuses(allStatuses);
|
|
|
|
store.session.setJSON('statuses-' + id, allStatuses);
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
|
|
|
setUIState('error');
|
|
|
|
}
|
|
|
|
})();
|
2022-12-10 12:14:48 +03:00
|
|
|
}, [id, snapStates.reloadStatusPage]);
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
2022-12-21 13:02:13 +03:00
|
|
|
if (!statuses.length) return;
|
|
|
|
const isLoading = uiState === 'loading';
|
|
|
|
if (userInitiated.current) {
|
|
|
|
const hasAncestors = statuses.findIndex((s) => s.id === id) > 0; // Cannot use `ancestor` key because the hero state is dynamic
|
|
|
|
if (!isLoading && hasAncestors) {
|
|
|
|
// Case 1: User initiated, has ancestors, after statuses are loaded, SNAP to hero status
|
|
|
|
console.log('Case 1');
|
|
|
|
heroStatusRef.current?.scrollIntoView();
|
|
|
|
} else if (isLoading && statuses.length > 1) {
|
|
|
|
// Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
|
|
|
|
console.log('Case 2');
|
|
|
|
heroStatusRef.current?.scrollIntoView({
|
|
|
|
behavior: 'smooth',
|
|
|
|
block: 'start',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const scrollPosition = states.scrollPositions.get(id);
|
|
|
|
if (scrollPosition && scrollableRef.current) {
|
|
|
|
// Case 3: Not user initiated (e.g. back/forward button), restore to saved scroll position
|
|
|
|
console.log('Case 3');
|
|
|
|
scrollableRef.current.scrollTop = scrollPosition;
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
}
|
2022-12-21 13:02:13 +03:00
|
|
|
console.log('No case', {
|
|
|
|
isLoading,
|
|
|
|
userInitiated: userInitiated.current,
|
|
|
|
statusesLength: statuses.length,
|
|
|
|
// scrollPosition,
|
|
|
|
});
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
if (!isLoading) {
|
|
|
|
// Reset user initiated flag after statuses are loaded
|
|
|
|
userInitiated.current = false;
|
2022-12-18 08:43:34 +03:00
|
|
|
}
|
2022-12-21 13:02:13 +03:00
|
|
|
}, [statuses, uiState]);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-18 15:46:13 +03:00
|
|
|
const heroStatus = snapStates.statuses.get(id);
|
2022-12-10 12:14:48 +03:00
|
|
|
const heroDisplayName = useMemo(() => {
|
|
|
|
// Remove shortcodes from display name
|
|
|
|
if (!heroStatus) return '';
|
|
|
|
const { account } = heroStatus;
|
|
|
|
const div = document.createElement('div');
|
|
|
|
div.innerHTML = account.displayName;
|
|
|
|
return div.innerText.trim();
|
|
|
|
}, [heroStatus]);
|
|
|
|
const heroContentText = useMemo(() => {
|
|
|
|
if (!heroStatus) return '';
|
2022-12-15 12:14:33 +03:00
|
|
|
const { spoilerText, content } = heroStatus;
|
|
|
|
let text;
|
|
|
|
if (spoilerText) {
|
|
|
|
text = spoilerText;
|
|
|
|
} else {
|
|
|
|
const div = document.createElement('div');
|
|
|
|
div.innerHTML = content;
|
|
|
|
text = div.innerText.trim();
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
if (text.length > 64) {
|
2022-12-15 12:14:33 +03:00
|
|
|
// "The title should ideally be less than 64 characters in length"
|
|
|
|
// https://www.w3.org/Provider/Style/TITLE.html
|
2022-12-10 12:14:48 +03:00
|
|
|
text = text.slice(0, 64) + '…';
|
|
|
|
}
|
|
|
|
return text;
|
|
|
|
}, [heroStatus]);
|
|
|
|
useTitle(
|
|
|
|
heroDisplayName && heroContentText
|
2022-12-15 12:14:33 +03:00
|
|
|
? `${heroDisplayName}: "${heroContentText}"`
|
2022-12-10 12:14:48 +03:00
|
|
|
: 'Status',
|
|
|
|
);
|
|
|
|
|
|
|
|
const prevRoute = states.history.findLast((h) => {
|
|
|
|
return h === '/' || /notifications/i.test(h);
|
|
|
|
});
|
|
|
|
const closeLink = `#${prevRoute || '/'}`;
|
|
|
|
|
2022-12-22 19:30:55 +03:00
|
|
|
const [limit, setLimit] = useState(LIMIT);
|
2022-12-18 15:46:13 +03:00
|
|
|
const showMore = useMemo(() => {
|
|
|
|
// return number of statuses to show
|
|
|
|
return statuses.length - limit;
|
|
|
|
}, [statuses.length, limit]);
|
|
|
|
|
2022-12-22 19:30:55 +03:00
|
|
|
const hasManyStatuses = statuses.length > LIMIT;
|
2022-12-21 13:02:13 +03:00
|
|
|
const hasDescendants = statuses.some((s) => s.descendant);
|
2022-12-19 05:05:27 +03:00
|
|
|
|
2022-12-10 12:14:48 +03:00
|
|
|
return (
|
|
|
|
<div class="deck-backdrop">
|
|
|
|
<Link href={closeLink}></Link>
|
|
|
|
<div
|
2022-12-21 13:02:13 +03:00
|
|
|
ref={scrollableRef}
|
2022-12-10 12:14:48 +03:00
|
|
|
class={`status-deck deck contained ${
|
|
|
|
statuses.length > 1 ? 'padded-bottom' : ''
|
|
|
|
}`}
|
|
|
|
>
|
|
|
|
<header>
|
2022-12-19 11:25:57 +03:00
|
|
|
{/* <div>
|
2022-12-18 15:53:32 +03:00
|
|
|
<Link class="button plain deck-close" href={closeLink}>
|
|
|
|
<Icon icon="chevron-left" size="xl" />
|
|
|
|
</Link>
|
2022-12-19 11:25:57 +03:00
|
|
|
</div> */}
|
2022-12-10 12:14:48 +03:00
|
|
|
<h1>Status</h1>
|
|
|
|
<div class="header-side">
|
|
|
|
<Loader hidden={uiState !== 'loading'} />
|
2022-12-19 11:25:57 +03:00
|
|
|
<Link class="button plain deck-close" href={closeLink}>
|
|
|
|
<Icon icon="x" size="xl" />
|
|
|
|
</Link>
|
2022-12-10 12:14:48 +03:00
|
|
|
</div>
|
|
|
|
</header>
|
2022-12-20 10:32:31 +03:00
|
|
|
<ul
|
|
|
|
class={`timeline flat contextual ${
|
|
|
|
uiState === 'loading' ? 'loading' : ''
|
|
|
|
}`}
|
|
|
|
>
|
2022-12-18 15:46:13 +03:00
|
|
|
{statuses.slice(0, limit).map((status) => {
|
|
|
|
const {
|
|
|
|
id: statusID,
|
|
|
|
ancestor,
|
|
|
|
descendant,
|
|
|
|
thread,
|
|
|
|
replies,
|
|
|
|
} = status;
|
2022-12-10 12:14:48 +03:00
|
|
|
const isHero = statusID === id;
|
|
|
|
return (
|
|
|
|
<li
|
|
|
|
key={statusID}
|
|
|
|
ref={isHero ? heroStatusRef : null}
|
2022-12-17 20:14:44 +03:00
|
|
|
class={`${ancestor ? 'ancestor' : ''} ${
|
|
|
|
descendant ? 'descendant' : ''
|
2022-12-20 10:32:31 +03:00
|
|
|
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
2022-12-10 12:14:48 +03:00
|
|
|
>
|
|
|
|
{isHero ? (
|
|
|
|
<Status statusID={statusID} withinContext size="l" />
|
|
|
|
) : (
|
|
|
|
<Link
|
|
|
|
class="
|
|
|
|
status-link
|
|
|
|
"
|
|
|
|
href={`#/s/${statusID}`}
|
2022-12-21 13:02:13 +03:00
|
|
|
onClick={() => {
|
|
|
|
userInitiated.current = true;
|
|
|
|
}}
|
2022-12-10 12:14:48 +03:00
|
|
|
>
|
2022-12-18 15:46:13 +03:00
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
|
|
|
withinContext
|
|
|
|
size={thread || ancestor ? 'm' : 's'}
|
|
|
|
/>
|
2022-12-22 19:30:55 +03:00
|
|
|
{replies?.length > LIMIT && (
|
|
|
|
<div class="replies-link">
|
|
|
|
<Icon icon="comment" />{' '}
|
|
|
|
<span title={replies.length}>
|
|
|
|
{shortenNumber(replies.length)}
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
</Link>
|
|
|
|
)}
|
2022-12-22 19:30:55 +03:00
|
|
|
{descendant &&
|
|
|
|
replies?.length > 0 &&
|
|
|
|
replies?.length <= LIMIT && (
|
|
|
|
<SubComments
|
|
|
|
hasManyStatuses={hasManyStatuses}
|
|
|
|
replies={replies}
|
2022-12-23 07:30:07 +03:00
|
|
|
onStatusLinkClick={() => {
|
|
|
|
userInitiated.current = true;
|
|
|
|
}}
|
2022-12-22 19:30:55 +03:00
|
|
|
/>
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
{uiState === 'loading' &&
|
|
|
|
isHero &&
|
2022-12-10 16:19:38 +03:00
|
|
|
!!heroStatus?.repliesCount &&
|
2022-12-21 13:02:13 +03:00
|
|
|
!hasDescendants && (
|
2022-12-10 12:14:48 +03:00
|
|
|
<div class="status-loading">
|
2022-12-10 16:19:38 +03:00
|
|
|
<Loader />
|
2022-12-10 12:14:48 +03:00
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
})}
|
2022-12-19 05:04:50 +03:00
|
|
|
{showMore > 0 && (
|
|
|
|
<li>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="plain block"
|
|
|
|
disabled={uiState === 'loading'}
|
2022-12-22 19:30:55 +03:00
|
|
|
onClick={() => setLimit((l) => l + LIMIT)}
|
2022-12-19 05:04:50 +03:00
|
|
|
style={{ marginBlockEnd: '6em' }}
|
|
|
|
>
|
|
|
|
Show more…{' '}
|
2022-12-22 19:30:55 +03:00
|
|
|
<span class="tag">
|
|
|
|
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
|
|
|
</span>
|
2022-12-19 05:04:50 +03:00
|
|
|
</button>
|
|
|
|
</li>
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
</ul>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
2022-12-16 08:27:04 +03:00
|
|
|
}
|
|
|
|
|
2022-12-23 07:30:07 +03:00
|
|
|
function SubComments({
|
|
|
|
hasManyStatuses,
|
|
|
|
replies,
|
|
|
|
onStatusLinkClick = () => {},
|
|
|
|
}) {
|
2022-12-22 19:30:55 +03:00
|
|
|
// If less than or 2 replies and total number of characters of content from replies is less than 500
|
|
|
|
let isBrief = false;
|
|
|
|
if (replies.length <= 2) {
|
|
|
|
let totalLength = replies.reduce((acc, reply) => {
|
|
|
|
const { content } = reply;
|
|
|
|
const length = htmlContentLength(content);
|
|
|
|
return acc + length;
|
|
|
|
}, 0);
|
|
|
|
isBrief = totalLength < 500;
|
|
|
|
}
|
|
|
|
|
|
|
|
const open = isBrief || !hasManyStatuses;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<details class="replies" open={open}>
|
|
|
|
<summary hidden={open}>
|
|
|
|
<span title={replies.length}>{shortenNumber(replies.length)}</span> repl
|
|
|
|
{replies.length === 1 ? 'y' : 'ies'}
|
|
|
|
</summary>
|
|
|
|
<ul>
|
|
|
|
{replies.map((replyID) => (
|
|
|
|
<li key={replyID}>
|
|
|
|
<Link
|
|
|
|
class="status-link"
|
|
|
|
href={`#/s/${replyID}`}
|
2022-12-23 07:30:07 +03:00
|
|
|
onClick={onStatusLinkClick}
|
2022-12-22 19:30:55 +03:00
|
|
|
>
|
|
|
|
<Status statusID={replyID} withinContext size="s" />
|
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
</details>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-12-16 08:27:04 +03:00
|
|
|
export default StatusPage;
|