2022-12-24 17:26:43 +03:00
|
|
|
import './status.css';
|
|
|
|
|
2023-03-24 10:05:57 +03:00
|
|
|
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
|
2022-12-21 13:02:13 +03:00
|
|
|
import debounce from 'just-debounce-it';
|
2023-01-25 11:41:28 +03:00
|
|
|
import pRetry from 'p-retry';
|
2023-04-18 14:47:21 +03:00
|
|
|
import { memo } from 'preact/compat';
|
2023-04-16 19:14:09 +03:00
|
|
|
import {
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
2023-06-14 12:37:54 +03:00
|
|
|
useLayoutEffect,
|
2023-04-16 19:14:09 +03:00
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'preact/hooks';
|
2022-12-31 04:52:31 +03:00
|
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
2022-12-24 17:26:43 +03:00
|
|
|
import { InView } from 'react-intersection-observer';
|
2023-04-14 10:30:04 +03:00
|
|
|
import { matchPath, useParams, useSearchParams } from 'react-router-dom';
|
2023-01-30 15:51:06 +03:00
|
|
|
import { useDebouncedCallback } from 'use-debounce';
|
2022-12-10 12:14:48 +03:00
|
|
|
import { useSnapshot } from 'valtio';
|
|
|
|
|
2023-01-28 20:02:25 +03:00
|
|
|
import Avatar from '../components/avatar';
|
2022-12-10 12:14:48 +03:00
|
|
|
import Icon from '../components/icon';
|
2023-01-20 19:23:59 +03:00
|
|
|
import Link from '../components/link';
|
2022-12-10 12:14:48 +03:00
|
|
|
import Loader from '../components/loader';
|
2023-04-14 10:30:04 +03:00
|
|
|
import MediaModal from '../components/media-modal';
|
2022-12-24 17:26:43 +03:00
|
|
|
import NameText from '../components/name-text';
|
2023-01-05 05:50:27 +03:00
|
|
|
import RelativeTime from '../components/relative-time';
|
2022-12-10 12:14:48 +03:00
|
|
|
import Status from '../components/status';
|
2023-02-05 19:17:19 +03:00
|
|
|
import { api } from '../utils/api';
|
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';
|
2023-02-06 11:35:03 +03:00
|
|
|
import states, {
|
2023-04-14 10:30:04 +03:00
|
|
|
getStatus,
|
2023-02-06 11:35:03 +03:00
|
|
|
saveStatus,
|
|
|
|
statusKey,
|
|
|
|
threadifyStatus,
|
|
|
|
} from '../utils/states';
|
2023-03-21 19:09:36 +03:00
|
|
|
import statusPeek from '../utils/status-peek';
|
2023-01-11 08:28:42 +03:00
|
|
|
import { getCurrentAccount } from '../utils/store-utils';
|
2023-01-10 05:44:16 +03:00
|
|
|
import useScroll from '../utils/useScroll';
|
2022-12-10 12:14:48 +03:00
|
|
|
import useTitle from '../utils/useTitle';
|
|
|
|
|
2023-04-17 13:56:09 +03:00
|
|
|
import getInstanceStatusURL from './../utils/get-instance-status-url';
|
|
|
|
|
2022-12-22 19:30:55 +03:00
|
|
|
const LIMIT = 40;
|
2023-05-11 08:56:21 +03:00
|
|
|
const SUBCOMMENTS_OPEN_ALL_LIMIT = 10;
|
2023-05-08 18:16:04 +03:00
|
|
|
const MAX_WEIGHT = 5;
|
2022-12-22 19:30:55 +03:00
|
|
|
|
2023-03-28 15:33:00 +03:00
|
|
|
let cachedRepliesToggle = {};
|
2023-01-23 12:58:33 +03:00
|
|
|
let cachedStatusesMap = {};
|
2023-01-21 14:52:51 +03:00
|
|
|
function resetScrollPosition(id) {
|
2023-01-23 12:58:33 +03:00
|
|
|
delete cachedStatusesMap[id];
|
2023-01-21 14:52:51 +03:00
|
|
|
delete states.scrollPositions[id];
|
|
|
|
}
|
|
|
|
|
2023-04-18 14:47:21 +03:00
|
|
|
function StatusPage(params) {
|
|
|
|
const { id } = params;
|
2023-02-21 19:42:43 +03:00
|
|
|
const { masto, instance } = api({ instance: params.instance });
|
2023-05-26 08:31:02 +03:00
|
|
|
const snapStates = useSnapshot(states);
|
2023-04-14 10:30:04 +03:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const mediaParam = searchParams.get('media');
|
|
|
|
const mediaOnlyParam = searchParams.get('media-only');
|
|
|
|
const mediaIndex = parseInt(mediaParam || mediaOnlyParam, 10);
|
|
|
|
let showMedia = mediaIndex > 0;
|
|
|
|
const mediaStatusID = searchParams.get('mediaStatusID');
|
|
|
|
const mediaStatus = getStatus(mediaStatusID, instance);
|
|
|
|
if (mediaStatusID && !mediaStatus) {
|
|
|
|
showMedia = false;
|
|
|
|
}
|
|
|
|
const showMediaOnly = showMedia && !!mediaOnlyParam;
|
|
|
|
|
|
|
|
const sKey = statusKey(id, instance);
|
|
|
|
const [heroStatus, setHeroStatus] = useState(states.statuses[sKey]);
|
2023-04-28 06:00:28 +03:00
|
|
|
useEffect(() => {
|
|
|
|
if (states.statuses[sKey]) {
|
|
|
|
setHeroStatus(states.statuses[sKey]);
|
|
|
|
}
|
|
|
|
}, [sKey]);
|
2023-04-14 10:30:04 +03:00
|
|
|
|
|
|
|
const closeLink = useMemo(() => {
|
|
|
|
const { prevLocation } = states;
|
|
|
|
const pathname =
|
|
|
|
(prevLocation?.pathname || '') + (prevLocation?.search || '');
|
|
|
|
const matchStatusPath =
|
|
|
|
matchPath('/:instance/s/:id', pathname) || matchPath('/s/:id', pathname);
|
|
|
|
if (!pathname || matchStatusPath) {
|
|
|
|
return '/';
|
|
|
|
}
|
|
|
|
return pathname;
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!heroStatus && showMedia) {
|
|
|
|
(async () => {
|
|
|
|
try {
|
|
|
|
const status = await masto.v1.statuses.fetch(id);
|
|
|
|
saveStatus(status, instance);
|
|
|
|
setHeroStatus(status);
|
|
|
|
} catch (err) {
|
|
|
|
console.error(err);
|
2023-04-29 17:22:07 +03:00
|
|
|
alert('Unable to load post.');
|
2023-04-14 10:30:04 +03:00
|
|
|
location.hash = closeLink;
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
}
|
2023-04-15 07:26:11 +03:00
|
|
|
}, [showMedia]);
|
2023-04-14 10:30:04 +03:00
|
|
|
|
|
|
|
const mediaAttachments = mediaStatusID
|
|
|
|
? mediaStatus?.mediaAttachments
|
|
|
|
: heroStatus?.mediaAttachments;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div class="deck-backdrop">
|
|
|
|
{showMedia ? (
|
|
|
|
mediaAttachments?.length ? (
|
|
|
|
<MediaModal
|
|
|
|
mediaAttachments={mediaAttachments}
|
|
|
|
statusID={mediaStatusID || id}
|
|
|
|
instance={instance}
|
|
|
|
index={mediaIndex - 1}
|
|
|
|
onClose={() => {
|
2023-06-29 04:52:41 +03:00
|
|
|
if (
|
|
|
|
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
|
|
|
|
snapStates.prevLocation
|
|
|
|
) {
|
2023-05-26 08:31:02 +03:00
|
|
|
history.back();
|
2023-04-14 10:30:04 +03:00
|
|
|
} else {
|
2023-05-26 08:31:02 +03:00
|
|
|
if (showMediaOnly) {
|
|
|
|
location.hash = closeLink;
|
|
|
|
} else {
|
|
|
|
searchParams.delete('media');
|
|
|
|
searchParams.delete('mediaStatusID');
|
|
|
|
setSearchParams(searchParams);
|
|
|
|
}
|
2023-04-14 10:30:04 +03:00
|
|
|
}
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
) : (
|
2023-04-15 07:26:11 +03:00
|
|
|
<div class="media-modal-container loading">
|
|
|
|
<Loader abrupt />
|
2023-04-14 10:30:04 +03:00
|
|
|
</div>
|
|
|
|
)
|
|
|
|
) : (
|
|
|
|
<Link to={closeLink} />
|
|
|
|
)}
|
2023-04-18 14:47:21 +03:00
|
|
|
{!showMediaOnly && (
|
|
|
|
<StatusThread
|
|
|
|
id={id}
|
|
|
|
instance={params.instance}
|
|
|
|
closeLink={closeLink}
|
|
|
|
/>
|
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-04-18 14:47:21 +03:00
|
|
|
function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
2023-04-14 10:30:04 +03:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const mediaParam = searchParams.get('media');
|
|
|
|
const showMedia = parseInt(mediaParam, 10) > 0;
|
2023-04-16 19:14:09 +03:00
|
|
|
const [viewMode, setViewMode] = useState(searchParams.get('view'));
|
2023-05-03 05:22:15 +03:00
|
|
|
const translate = !!parseInt(searchParams.get('translate'));
|
2023-04-18 14:47:21 +03:00
|
|
|
const { masto, instance } = api({ instance: propInstance });
|
2023-02-21 19:42:43 +03:00
|
|
|
const {
|
|
|
|
masto: currentMasto,
|
|
|
|
instance: currentInstance,
|
|
|
|
authenticated,
|
|
|
|
} = api();
|
2023-02-17 12:37:31 +03:00
|
|
|
const sameInstance = instance === currentInstance;
|
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');
|
|
|
|
const heroStatusRef = useRef();
|
2023-02-06 11:35:03 +03:00
|
|
|
const sKey = statusKey(id, instance);
|
2023-05-11 08:56:21 +03:00
|
|
|
const totalDescendants = useRef(0);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
const scrollableRef = useRef();
|
2022-12-30 15:37:57 +03:00
|
|
|
useEffect(() => {
|
|
|
|
scrollableRef.current?.focus();
|
|
|
|
}, []);
|
2022-12-20 20:02:48 +03:00
|
|
|
useEffect(() => {
|
2022-12-21 13:02:13 +03:00
|
|
|
const onScroll = debounce(() => {
|
|
|
|
// console.log('onScroll');
|
2022-12-27 04:05:45 +03:00
|
|
|
if (!scrollableRef.current) return;
|
2022-12-21 13:02:13 +03:00
|
|
|
const { scrollTop } = scrollableRef.current;
|
2023-01-16 15:32:51 +03:00
|
|
|
if (uiState !== 'loading') {
|
|
|
|
states.scrollPositions[id] = scrollTop;
|
|
|
|
}
|
2023-02-20 15:59:46 +03:00
|
|
|
}, 50);
|
2023-04-14 10:30:04 +03:00
|
|
|
scrollableRef.current?.addEventListener('scroll', onScroll, {
|
2022-12-21 13:02:13 +03:00
|
|
|
passive: true,
|
|
|
|
});
|
|
|
|
onScroll();
|
|
|
|
return () => {
|
2023-02-20 15:59:46 +03:00
|
|
|
onScroll.cancel();
|
2022-12-21 13:02:13 +03:00
|
|
|
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
|
|
|
};
|
2023-01-16 15:32:51 +03:00
|
|
|
}, [id, uiState !== 'loading']);
|
2022-12-21 13:02:13 +03:00
|
|
|
|
2023-01-09 11:56:16 +03:00
|
|
|
const scrollOffsets = useRef();
|
2023-03-13 19:36:40 +03:00
|
|
|
const initContext = ({ reloadHero } = {}) => {
|
2023-01-09 11:56:16 +03:00
|
|
|
console.debug('initContext', id);
|
2022-12-21 13:02:13 +03:00
|
|
|
setUIState('loading');
|
2022-12-28 13:06:05 +03:00
|
|
|
let heroTimer;
|
2022-12-21 13:02:13 +03:00
|
|
|
|
2023-01-23 12:58:33 +03:00
|
|
|
const cachedStatuses = cachedStatusesMap[id];
|
2022-12-23 06:28:25 +03:00
|
|
|
if (cachedStatuses) {
|
|
|
|
// Case 1: It's cached, let's restore them to make it snappy
|
|
|
|
const reallyCachedStatuses = cachedStatuses.filter(
|
2023-02-06 11:35:03 +03:00
|
|
|
(s) => states.statuses[sKey],
|
2022-12-23 06:28:25 +03:00
|
|
|
// 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 {
|
2023-01-09 11:56:16 +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
|
|
|
|
// const slicedStatuses = statuses.slice(0, heroIndex + 1);
|
|
|
|
// setStatuses(slicedStatuses);
|
|
|
|
// } else {
|
|
|
|
// Case 3: Not cached and not in statuses, let's start from scratch
|
|
|
|
setStatuses([{ id }]);
|
|
|
|
// }
|
2022-12-10 12:14:48 +03:00
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
(async () => {
|
2023-01-25 11:41:28 +03:00
|
|
|
const heroFetch = () =>
|
|
|
|
pRetry(() => masto.v1.statuses.fetch(id), {
|
2023-01-26 16:02:39 +03:00
|
|
|
retries: 4,
|
2023-01-25 11:41:28 +03:00
|
|
|
});
|
|
|
|
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
|
2023-01-26 16:02:39 +03:00
|
|
|
retries: 8,
|
2023-01-25 11:41:28 +03:00
|
|
|
});
|
2022-12-23 07:30:49 +03:00
|
|
|
|
2023-02-06 11:35:03 +03:00
|
|
|
const hasStatus = !!snapStates.statuses[sKey];
|
|
|
|
let heroStatus = snapStates.statuses[sKey];
|
2023-03-13 19:36:40 +03:00
|
|
|
if (hasStatus && !reloadHero) {
|
2023-01-09 11:56:16 +03:00
|
|
|
console.debug('Hero status is cached');
|
2022-12-28 13:06:05 +03:00
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
heroStatus = await heroFetch();
|
2023-02-06 11:35:03 +03:00
|
|
|
saveStatus(heroStatus, instance);
|
2023-01-09 20:31:38 +03:00
|
|
|
// Give time for context to appear
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
setTimeout(resolve, 100);
|
|
|
|
});
|
2022-12-28 13:06:05 +03:00
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
2022-12-21 13:02:13 +03:00
|
|
|
setUIState('error');
|
2022-12-27 16:30:18 +03:00
|
|
|
return;
|
2022-12-21 13:02:13 +03:00
|
|
|
}
|
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
|
|
|
|
2023-05-11 08:56:21 +03:00
|
|
|
totalDescendants.current = descendants?.length || 0;
|
|
|
|
|
2023-05-18 18:42:49 +03:00
|
|
|
const missingStatuses = new Set();
|
2022-12-21 13:02:13 +03:00
|
|
|
ancestors.forEach((status) => {
|
2023-02-06 11:35:03 +03:00
|
|
|
saveStatus(status, instance, {
|
|
|
|
skipThreading: true,
|
|
|
|
});
|
2023-05-18 18:42:49 +03:00
|
|
|
if (
|
|
|
|
status.inReplyToId &&
|
|
|
|
!ancestors.find((s) => s.id === status.inReplyToId)
|
|
|
|
) {
|
|
|
|
missingStatuses.add(status.inReplyToId);
|
|
|
|
}
|
2022-12-21 13:02:13 +03:00
|
|
|
});
|
2023-04-08 14:47:54 +03:00
|
|
|
const ancestorsIsThread = ancestors.every(
|
|
|
|
(s) => s.account.id === heroStatus.account.id,
|
|
|
|
);
|
2022-12-21 13:02:13 +03:00
|
|
|
const nestedDescendants = [];
|
|
|
|
descendants.forEach((status) => {
|
2023-02-06 11:35:03 +03:00
|
|
|
saveStatus(status, instance, {
|
|
|
|
skipThreading: true,
|
|
|
|
});
|
2023-05-18 18:42:49 +03:00
|
|
|
|
|
|
|
if (
|
|
|
|
status.inReplyToId &&
|
2023-05-19 20:19:25 +03:00
|
|
|
!descendants.find((s) => s.id === status.inReplyToId) &&
|
|
|
|
status.inReplyToId !== heroStatus.id
|
2023-05-18 18:42:49 +03:00
|
|
|
) {
|
|
|
|
missingStatuses.add(status.inReplyToId);
|
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
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);
|
2023-04-17 16:43:26 +03:00
|
|
|
} else if (
|
|
|
|
!status.inReplyToAccountId &&
|
|
|
|
nestedDescendants.find((s) => s.id === status.inReplyToId) &&
|
|
|
|
status.account.id === heroStatus.account.id
|
|
|
|
) {
|
|
|
|
// If replying to hero's own statuses, it's part of the thread, 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 {
|
2023-01-28 20:29:26 +03:00
|
|
|
// If no parent, something is wrong
|
|
|
|
console.warn('No parent found for', status);
|
2022-12-21 13:02:13 +03:00
|
|
|
}
|
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 });
|
2023-05-18 18:42:49 +03:00
|
|
|
if (missingStatuses.size) {
|
|
|
|
console.error('Missing statuses', [...missingStatuses]);
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2023-03-30 15:07:07 +03:00
|
|
|
function expandReplies(_replies) {
|
|
|
|
return _replies?.map((_r) => ({
|
|
|
|
id: _r.id,
|
|
|
|
account: _r.account,
|
|
|
|
repliesCount: _r.repliesCount,
|
|
|
|
content: _r.content,
|
2023-05-08 17:42:15 +03:00
|
|
|
weight: calcStatusWeight(_r),
|
2023-03-30 15:07:07 +03:00
|
|
|
replies: expandReplies(_r.__replies),
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
const allStatuses = [
|
|
|
|
...ancestors.map((s) => ({
|
|
|
|
id: s.id,
|
|
|
|
ancestor: true,
|
2023-04-08 14:47:54 +03:00
|
|
|
isThread: ancestorsIsThread,
|
2022-12-21 13:02:13 +03:00
|
|
|
accountID: s.account.id,
|
2023-04-08 11:52:34 +03:00
|
|
|
repliesCount: s.repliesCount,
|
2023-05-08 17:42:15 +03:00
|
|
|
weight: calcStatusWeight(s),
|
2022-12-21 13:02:13 +03:00
|
|
|
})),
|
2023-05-08 17:42:15 +03:00
|
|
|
{
|
|
|
|
id,
|
|
|
|
accountID: heroStatus.account.id,
|
|
|
|
weight: calcStatusWeight(heroStatus),
|
|
|
|
},
|
2022-12-21 13:02:13 +03:00
|
|
|
...nestedDescendants.map((s) => ({
|
|
|
|
id: s.id,
|
2023-05-24 12:16:45 +03:00
|
|
|
account: s.account,
|
2022-12-21 13:02:13 +03:00
|
|
|
accountID: s.account.id,
|
|
|
|
descendant: true,
|
|
|
|
thread: s.account.id === heroStatus.account.id,
|
2023-05-08 17:42:15 +03:00
|
|
|
weight: calcStatusWeight(s),
|
2023-03-30 15:07:07 +03:00
|
|
|
replies: expandReplies(s.__replies),
|
2022-12-21 13:02:13 +03:00
|
|
|
})),
|
|
|
|
];
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
setUIState('default');
|
2023-01-09 11:56:16 +03:00
|
|
|
scrollOffsets.current = {
|
|
|
|
offsetTop: heroStatusRef.current?.offsetTop,
|
|
|
|
scrollTop: scrollableRef.current?.scrollTop,
|
|
|
|
};
|
2023-04-18 14:47:21 +03:00
|
|
|
|
|
|
|
// Set limit to hero's index
|
|
|
|
const heroLimit = allStatuses.findIndex((s) => s.id === id);
|
|
|
|
if (heroLimit >= limit) {
|
|
|
|
setLimit(heroLimit + 1);
|
|
|
|
}
|
|
|
|
|
2022-12-21 13:02:13 +03:00
|
|
|
console.log({ allStatuses });
|
|
|
|
setStatuses(allStatuses);
|
2023-01-23 12:58:33 +03:00
|
|
|
cachedStatusesMap[id] = allStatuses;
|
2023-01-11 12:45:37 +03:00
|
|
|
|
|
|
|
// Let's threadify this one
|
|
|
|
// Note that all non-hero statuses will trigger saveStatus which will threadify them too
|
|
|
|
// By right, at this point, all descendant statuses should be cached
|
2023-02-06 11:35:03 +03:00
|
|
|
threadifyStatus(heroStatus, instance);
|
2022-12-21 13:02:13 +03:00
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
|
|
|
setUIState('error');
|
|
|
|
}
|
|
|
|
})();
|
2022-12-28 13:06:05 +03:00
|
|
|
|
|
|
|
return () => {
|
|
|
|
clearTimeout(heroTimer);
|
|
|
|
};
|
2023-01-06 16:29:16 +03:00
|
|
|
};
|
|
|
|
|
2023-02-05 19:17:19 +03:00
|
|
|
useEffect(initContext, [id, masto]);
|
2023-01-09 11:56:16 +03:00
|
|
|
useEffect(() => {
|
|
|
|
if (!statuses.length) return;
|
2023-01-09 20:31:38 +03:00
|
|
|
console.debug('STATUSES', statuses);
|
2023-06-11 18:27:09 +03:00
|
|
|
const scrollPosition = snapStates.scrollPositions[id];
|
2023-01-09 11:56:16 +03:00
|
|
|
console.debug('scrollPosition', scrollPosition);
|
2023-01-09 20:31:38 +03:00
|
|
|
if (!!scrollPosition) {
|
2023-01-09 11:56:16 +03:00
|
|
|
console.debug('Case 1', {
|
2023-01-21 14:52:51 +03:00
|
|
|
id,
|
2023-01-09 11:56:16 +03:00
|
|
|
scrollPosition,
|
|
|
|
});
|
|
|
|
scrollableRef.current.scrollTop = scrollPosition;
|
|
|
|
} else if (scrollOffsets.current) {
|
|
|
|
const newScrollOffsets = {
|
|
|
|
offsetTop: heroStatusRef.current?.offsetTop,
|
|
|
|
scrollTop: scrollableRef.current?.scrollTop,
|
|
|
|
};
|
|
|
|
const newScrollTop =
|
2023-01-27 06:48:13 +03:00
|
|
|
newScrollOffsets.offsetTop -
|
|
|
|
scrollOffsets.current.offsetTop +
|
|
|
|
newScrollOffsets.scrollTop;
|
2023-01-09 11:56:16 +03:00
|
|
|
console.debug('Case 2', {
|
|
|
|
scrollOffsets: scrollOffsets.current,
|
|
|
|
newScrollOffsets,
|
|
|
|
newScrollTop,
|
|
|
|
statuses: [...statuses],
|
|
|
|
});
|
|
|
|
scrollableRef.current.scrollTop = newScrollTop;
|
2023-01-27 06:48:13 +03:00
|
|
|
} else if (statuses.length === 1) {
|
|
|
|
console.debug('Case 3', {
|
|
|
|
id,
|
|
|
|
});
|
|
|
|
scrollableRef.current.scrollTop = 0;
|
2023-01-09 11:56:16 +03:00
|
|
|
}
|
|
|
|
|
2023-01-09 20:31:38 +03:00
|
|
|
// RESET
|
2023-01-09 11:56:16 +03:00
|
|
|
scrollOffsets.current = null;
|
|
|
|
}, [statuses]);
|
2023-01-06 16:29:16 +03:00
|
|
|
|
|
|
|
useEffect(() => {
|
2023-01-09 11:56:16 +03:00
|
|
|
if (snapStates.reloadStatusPage <= 0) return;
|
2023-01-06 16:29:16 +03:00
|
|
|
// Delete the cache for the context
|
|
|
|
(async () => {
|
|
|
|
try {
|
2023-01-11 08:28:42 +03:00
|
|
|
const { instanceURL } = getCurrentAccount();
|
2023-01-06 16:29:16 +03:00
|
|
|
const contextURL = `https://${instanceURL}/api/v1/statuses/${id}/context`;
|
|
|
|
console.log('Clear cache', contextURL);
|
|
|
|
const apiCache = await caches.open('api');
|
|
|
|
await apiCache.delete(contextURL, { ignoreVary: true });
|
|
|
|
|
2023-03-13 19:36:40 +03:00
|
|
|
return initContext({
|
|
|
|
reloadHero: true,
|
|
|
|
});
|
2023-01-06 16:29:16 +03:00
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
}, [snapStates.reloadStatusPage]);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2023-01-09 11:56:16 +03:00
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
2023-01-10 15:05:47 +03:00
|
|
|
// RESET
|
2023-01-09 11:56:16 +03:00
|
|
|
states.scrollPositions = {};
|
|
|
|
states.reloadStatusPage = 0;
|
2023-01-23 12:58:33 +03:00
|
|
|
cachedStatusesMap = {};
|
2023-03-28 15:33:00 +03:00
|
|
|
cachedRepliesToggle = {};
|
2023-05-08 17:42:15 +03:00
|
|
|
statusWeightCache.clear();
|
2023-01-09 11:56:16 +03:00
|
|
|
};
|
|
|
|
}, []);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
2023-02-11 13:55:21 +03:00
|
|
|
const heroStatus = snapStates.statuses[sKey] || snapStates.statuses[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 '';
|
2023-03-21 19:09:36 +03:00
|
|
|
let text = statusPeek(heroStatus);
|
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',
|
2023-02-09 18:01:33 +03:00
|
|
|
'/:instance?/s/:id',
|
2022-12-10 12:14:48 +03:00
|
|
|
);
|
|
|
|
|
2023-03-24 10:05:57 +03:00
|
|
|
const postInstance = useMemo(() => {
|
|
|
|
if (!heroStatus) return;
|
|
|
|
const { url } = heroStatus;
|
|
|
|
if (!url) return;
|
|
|
|
return new URL(url).hostname;
|
|
|
|
}, [heroStatus]);
|
|
|
|
const postSameInstance = useMemo(() => {
|
|
|
|
if (!postInstance) return;
|
|
|
|
return postInstance === instance;
|
|
|
|
}, [postInstance, instance]);
|
|
|
|
|
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-21 13:02:13 +03:00
|
|
|
const hasDescendants = statuses.some((s) => s.descendant);
|
2023-01-10 05:44:16 +03:00
|
|
|
const ancestors = statuses.filter((s) => s.ancestor);
|
2022-12-19 05:05:27 +03:00
|
|
|
|
2022-12-24 17:26:43 +03:00
|
|
|
const [heroInView, setHeroInView] = useState(true);
|
|
|
|
const onView = useDebouncedCallback(setHeroInView, 100);
|
2022-12-29 06:47:10 +03:00
|
|
|
const heroPointer = useMemo(() => {
|
|
|
|
// get top offset of heroStatus
|
2022-12-29 11:15:58 +03:00
|
|
|
if (!heroStatusRef.current || heroInView) return null;
|
2022-12-29 06:47:10 +03:00
|
|
|
const { top } = heroStatusRef.current.getBoundingClientRect();
|
|
|
|
return top > 0 ? 'down' : 'up';
|
|
|
|
}, [heroInView]);
|
2022-12-24 17:26:43 +03:00
|
|
|
|
2023-04-14 10:30:04 +03:00
|
|
|
useHotkeys(
|
|
|
|
'esc',
|
|
|
|
() => {
|
|
|
|
location.hash = closeLink;
|
|
|
|
},
|
|
|
|
{
|
|
|
|
// If media is open, esc to close media first
|
|
|
|
// Else close the status page
|
|
|
|
enabled: !showMedia,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
// For backspace, will always close both media and status page
|
|
|
|
useHotkeys('backspace', () => {
|
|
|
|
location.hash = closeLink;
|
2023-01-27 15:54:18 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
useHotkeys('j', () => {
|
|
|
|
const activeStatus = document.activeElement.closest(
|
|
|
|
'.status-link, .status-focus',
|
|
|
|
);
|
|
|
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
|
|
|
const allStatusLinks = Array.from(
|
2023-01-31 20:19:14 +03:00
|
|
|
// Select all statuses except those inside collapsed details/summary
|
|
|
|
// Hat-tip to @AmeliaBR@front-end.social
|
|
|
|
// https://front-end.social/@AmeliaBR/109784776146144471
|
|
|
|
scrollableRef.current.querySelectorAll(
|
|
|
|
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
|
|
|
|
),
|
|
|
|
);
|
2023-01-27 15:54:18 +03:00
|
|
|
console.log({ allStatusLinks });
|
|
|
|
if (
|
|
|
|
activeStatus &&
|
|
|
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
|
|
|
activeStatusRect.bottom > 0
|
|
|
|
) {
|
|
|
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
|
|
|
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
|
|
|
if (nextStatus) {
|
|
|
|
nextStatus.focus();
|
|
|
|
nextStatus.scrollIntoViewIfNeeded?.();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If active status is not in viewport, get the topmost status-link in viewport
|
|
|
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
|
|
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
|
|
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
|
|
|
});
|
|
|
|
if (topmostStatusLink) {
|
|
|
|
topmostStatusLink.focus();
|
|
|
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
useHotkeys('k', () => {
|
|
|
|
const activeStatus = document.activeElement.closest(
|
|
|
|
'.status-link, .status-focus',
|
|
|
|
);
|
|
|
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
|
|
|
const allStatusLinks = Array.from(
|
2023-01-31 20:19:14 +03:00
|
|
|
scrollableRef.current.querySelectorAll(
|
|
|
|
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
|
|
|
|
),
|
|
|
|
);
|
2023-01-27 15:54:18 +03:00
|
|
|
if (
|
|
|
|
activeStatus &&
|
|
|
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
|
|
|
activeStatusRect.bottom > 0
|
|
|
|
) {
|
|
|
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
|
|
|
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
|
|
|
if (prevStatus) {
|
|
|
|
prevStatus.focus();
|
|
|
|
prevStatus.scrollIntoViewIfNeeded?.();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If active status is not in viewport, get the topmost status-link in viewport
|
|
|
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
|
|
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
|
|
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
|
|
|
});
|
|
|
|
if (topmostStatusLink) {
|
|
|
|
topmostStatusLink.focus();
|
|
|
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
|
|
|
}
|
|
|
|
}
|
2022-12-31 04:52:31 +03:00
|
|
|
});
|
|
|
|
|
2023-01-31 19:10:38 +03:00
|
|
|
// NOTE: I'm not sure if 'x' is the best shortcut for this, might change it later
|
|
|
|
// IDEA: x is for expand
|
|
|
|
useHotkeys('x', () => {
|
|
|
|
const activeStatus = document.activeElement.closest(
|
|
|
|
'.status-link, .status-focus',
|
|
|
|
);
|
|
|
|
if (activeStatus) {
|
|
|
|
const details = activeStatus.nextElementSibling;
|
|
|
|
if (details && details.tagName.toLowerCase() === 'details') {
|
|
|
|
details.open = !details.open;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2023-01-14 14:42:04 +03:00
|
|
|
const { nearReachStart } = useScroll({
|
2023-02-28 16:56:41 +03:00
|
|
|
scrollableRef,
|
2023-02-25 05:50:02 +03:00
|
|
|
distanceFromStartPx: 16,
|
2023-01-10 05:44:16 +03:00
|
|
|
});
|
|
|
|
|
2023-04-14 10:30:04 +03:00
|
|
|
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
|
|
|
|
|
2023-05-20 08:02:47 +03:00
|
|
|
const handleMediaClick = useCallback(
|
|
|
|
(e, i, media, status) => {
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
setSearchParams({
|
|
|
|
media: i + 1,
|
|
|
|
mediaStatusID: status.id,
|
|
|
|
});
|
|
|
|
},
|
|
|
|
[id],
|
|
|
|
);
|
2023-04-16 19:14:09 +03:00
|
|
|
|
2022-12-10 12:14:48 +03:00
|
|
|
return (
|
2023-04-14 10:30:04 +03:00
|
|
|
<div
|
|
|
|
tabIndex="-1"
|
|
|
|
ref={scrollableRef}
|
|
|
|
class={`status-deck deck contained ${
|
|
|
|
statuses.length > 1 ? 'padded-bottom' : ''
|
2023-04-16 19:14:09 +03:00
|
|
|
} ${initialPageState.current === 'status' ? 'slide-in' : ''} ${
|
|
|
|
viewMode ? `deck-view-${viewMode}` : ''
|
|
|
|
}`}
|
2023-06-29 05:08:31 +03:00
|
|
|
onAnimationEnd={(e) => {
|
|
|
|
// Fix the bounce effect when switching viewMode
|
|
|
|
// `slide-in` animation kicks in when switching viewMode
|
|
|
|
if (initialPageState.current === 'status') {
|
|
|
|
// e.target.classList.remove('slide-in');
|
|
|
|
initialPageState.current = null;
|
|
|
|
}
|
|
|
|
}}
|
2023-04-14 10:30:04 +03:00
|
|
|
>
|
|
|
|
<header
|
2023-04-14 10:46:11 +03:00
|
|
|
class={`${heroInView ? 'inview' : ''} ${
|
|
|
|
uiState === 'loading' ? 'loading' : ''
|
|
|
|
}`}
|
2023-04-14 10:30:04 +03:00
|
|
|
onDblClick={(e) => {
|
|
|
|
// reload statuses
|
|
|
|
states.reloadStatusPage++;
|
|
|
|
}}
|
2022-12-10 12:14:48 +03:00
|
|
|
>
|
2023-04-14 10:30:04 +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> */}
|
2023-04-14 10:30:04 +03:00
|
|
|
<div class="header-grid header-grid-2">
|
|
|
|
<h1>
|
|
|
|
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
|
|
|
<>
|
|
|
|
<span class="hero-heading">
|
|
|
|
<NameText
|
|
|
|
account={heroStatus.account}
|
|
|
|
instance={instance}
|
|
|
|
showAvatar
|
|
|
|
short
|
|
|
|
/>{' '}
|
|
|
|
<span class="insignificant">
|
|
|
|
•{' '}
|
|
|
|
<RelativeTime
|
|
|
|
datetime={heroStatus.createdAt}
|
|
|
|
format="micro"
|
2023-01-28 20:02:25 +03:00
|
|
|
/>
|
2023-04-14 10:30:04 +03:00
|
|
|
</span>
|
|
|
|
</span>{' '}
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="ancestors-indicator light small"
|
|
|
|
onClick={(e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
heroStatusRef.current.scrollIntoView({
|
|
|
|
behavior: 'smooth',
|
|
|
|
block: 'start',
|
|
|
|
});
|
2023-02-06 14:09:25 +03:00
|
|
|
}}
|
|
|
|
>
|
2023-04-14 10:30:04 +03:00
|
|
|
<Icon
|
|
|
|
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
|
|
|
/>
|
|
|
|
</button>
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<>
|
2023-04-29 17:22:07 +03:00
|
|
|
Post{' '}
|
2023-04-14 10:30:04 +03:00
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="ancestors-indicator light small"
|
|
|
|
onClick={(e) => {
|
|
|
|
// Scroll to top
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
scrollableRef.current.scrollTo({
|
|
|
|
top: 0,
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
hidden={!ancestors.length || nearReachStart}
|
|
|
|
>
|
|
|
|
<Icon icon="arrow-up" />
|
|
|
|
<Icon icon="comment" />{' '}
|
|
|
|
<span class="insignificant">
|
|
|
|
{shortenNumber(ancestors.length)}
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</h1>
|
|
|
|
<div class="header-side">
|
2023-04-14 10:46:11 +03:00
|
|
|
<Menu
|
|
|
|
align="end"
|
|
|
|
portal={{
|
|
|
|
// Need this, else the menu click will cause scroll jump
|
|
|
|
target: scrollableRef.current,
|
|
|
|
}}
|
|
|
|
menuButton={
|
|
|
|
<button type="button" class="button plain4">
|
|
|
|
<Icon icon="more" alt="Actions" size="xl" />
|
|
|
|
</button>
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<MenuItem
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
onClick={() => {
|
|
|
|
states.reloadStatusPage++;
|
2023-04-14 10:30:04 +03:00
|
|
|
}}
|
2023-02-08 14:11:33 +03:00
|
|
|
>
|
2023-04-14 10:46:11 +03:00
|
|
|
<Icon icon="refresh" />
|
|
|
|
<span>Refresh</span>
|
|
|
|
</MenuItem>
|
2023-04-16 19:14:09 +03:00
|
|
|
<MenuItem
|
|
|
|
className="menu-switch-view"
|
|
|
|
onClick={() => {
|
|
|
|
setViewMode(viewMode === 'full' ? null : 'full');
|
2023-04-18 19:27:10 +03:00
|
|
|
searchParams.delete('media');
|
|
|
|
searchParams.delete('media-only');
|
2023-04-16 19:14:09 +03:00
|
|
|
if (viewMode === 'full') {
|
|
|
|
searchParams.delete('view');
|
|
|
|
} else {
|
|
|
|
searchParams.set('view', 'full');
|
|
|
|
}
|
|
|
|
setSearchParams(searchParams);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon
|
|
|
|
icon={
|
|
|
|
{
|
|
|
|
'': 'layout5',
|
|
|
|
full: 'layout4',
|
|
|
|
}[viewMode || '']
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
<span>
|
|
|
|
Switch to {viewMode === 'full' ? 'Side Peek' : 'Full'} view
|
|
|
|
</span>
|
|
|
|
</MenuItem>
|
2023-04-14 10:46:11 +03:00
|
|
|
<MenuItem
|
|
|
|
onClick={() => {
|
|
|
|
// Click all buttons with class .spoiler but not .spoiling
|
|
|
|
const buttons = Array.from(
|
|
|
|
scrollableRef.current.querySelectorAll(
|
|
|
|
'button.spoiler:not(.spoiling)',
|
|
|
|
),
|
|
|
|
);
|
|
|
|
buttons.forEach((button) => {
|
|
|
|
button.click();
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
|
|
|
</MenuItem>
|
|
|
|
<MenuDivider />
|
|
|
|
<MenuHeader className="plain">Experimental</MenuHeader>
|
|
|
|
<MenuItem
|
|
|
|
disabled={postSameInstance}
|
|
|
|
onClick={() => {
|
|
|
|
const statusURL = getInstanceStatusURL(heroStatus.url);
|
|
|
|
if (statusURL) {
|
|
|
|
location.hash = statusURL;
|
|
|
|
} else {
|
|
|
|
alert('Unable to switch');
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon icon="transfer" />
|
|
|
|
<small class="menu-double-lines">
|
|
|
|
Switch to post's instance (<b>{postInstance}</b>)
|
|
|
|
</small>
|
|
|
|
</MenuItem>
|
|
|
|
</Menu>
|
2023-04-14 10:30:04 +03:00
|
|
|
<Link class="button plain deck-close" to={closeLink}>
|
|
|
|
<Icon icon="x" size="xl" />
|
|
|
|
</Link>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</header>
|
|
|
|
{!!statuses.length && heroStatus ? (
|
|
|
|
<ul
|
|
|
|
class={`timeline flat contextual grow ${
|
|
|
|
uiState === 'loading' ? 'loading' : ''
|
|
|
|
}`}
|
|
|
|
>
|
|
|
|
{statuses.slice(0, limit).map((status) => {
|
|
|
|
const {
|
|
|
|
id: statusID,
|
|
|
|
ancestor,
|
|
|
|
isThread,
|
|
|
|
descendant,
|
|
|
|
thread,
|
|
|
|
replies,
|
|
|
|
repliesCount,
|
2023-05-08 17:42:15 +03:00
|
|
|
weight,
|
2023-04-14 10:30:04 +03:00
|
|
|
} = status;
|
|
|
|
const isHero = statusID === id;
|
|
|
|
return (
|
|
|
|
<li
|
|
|
|
key={statusID}
|
|
|
|
ref={isHero ? heroStatusRef : null}
|
|
|
|
class={`${ancestor ? 'ancestor' : ''} ${
|
|
|
|
descendant ? 'descendant' : ''
|
|
|
|
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
|
|
|
>
|
|
|
|
{isHero ? (
|
|
|
|
<>
|
|
|
|
<InView
|
|
|
|
threshold={0.1}
|
|
|
|
onChange={onView}
|
|
|
|
class="status-focus"
|
|
|
|
tabIndex={0}
|
2023-01-21 14:52:51 +03:00
|
|
|
>
|
2023-01-01 10:28:07 +03:00
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
2023-02-05 19:17:19 +03:00
|
|
|
instance={instance}
|
2023-01-01 10:28:07 +03:00
|
|
|
withinContext
|
2023-04-14 10:30:04 +03:00
|
|
|
size="l"
|
2023-03-07 17:38:06 +03:00
|
|
|
enableTranslate
|
2023-05-03 05:22:15 +03:00
|
|
|
forceTranslate={translate}
|
2023-01-01 10:28:07 +03:00
|
|
|
/>
|
2023-04-14 10:30:04 +03:00
|
|
|
</InView>
|
|
|
|
{uiState !== 'loading' && !authenticated ? (
|
|
|
|
<div class="post-status-banner">
|
|
|
|
<p>
|
|
|
|
You're not logged in. Interactions (reply, boost, etc)
|
|
|
|
are not possible.
|
|
|
|
</p>
|
|
|
|
<Link to="/login" class="button">
|
|
|
|
Log in
|
|
|
|
</Link>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
!sameInstance && (
|
|
|
|
<div class="post-status-banner">
|
|
|
|
<p>
|
|
|
|
This post is from another instance (
|
|
|
|
<b>{instance}</b>). Interactions (reply, boost, etc)
|
|
|
|
are not possible.
|
|
|
|
</p>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
onClick={() => {
|
|
|
|
setUIState('loading');
|
|
|
|
(async () => {
|
|
|
|
try {
|
|
|
|
const results = await currentMasto.v2.search({
|
|
|
|
q: heroStatus.url,
|
|
|
|
type: 'statuses',
|
|
|
|
resolve: true,
|
|
|
|
limit: 1,
|
|
|
|
});
|
|
|
|
if (results.statuses.length) {
|
|
|
|
const status = results.statuses[0];
|
|
|
|
location.hash = currentInstance
|
|
|
|
? `/${currentInstance}/s/${status.id}`
|
|
|
|
: `/s/${status.id}`;
|
|
|
|
} else {
|
|
|
|
throw new Error('No results');
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
setUIState('default');
|
|
|
|
alert('Error: ' + e);
|
|
|
|
console.error(e);
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon icon="transfer" /> Switch to my instance to
|
|
|
|
enable interactions
|
|
|
|
</button>
|
2023-04-08 11:52:34 +03:00
|
|
|
</div>
|
2023-04-14 10:30:04 +03:00
|
|
|
)
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<Link
|
|
|
|
class="status-link"
|
|
|
|
to={
|
|
|
|
instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
|
|
|
|
}
|
|
|
|
onClick={() => {
|
|
|
|
resetScrollPosition(statusID);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
|
|
|
instance={instance}
|
|
|
|
withinContext
|
|
|
|
size={thread || ancestor ? 'm' : 's'}
|
|
|
|
enableTranslate
|
2023-04-16 19:14:09 +03:00
|
|
|
onMediaClick={handleMediaClick}
|
2023-04-14 10:30:04 +03:00
|
|
|
/>
|
|
|
|
{ancestor && isThread && repliesCount > 1 && (
|
|
|
|
<div class="replies-link">
|
|
|
|
<Icon icon="comment" />{' '}
|
|
|
|
<span title={repliesCount}>
|
|
|
|
{shortenNumber(repliesCount)}
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
)}{' '}
|
|
|
|
{/* {replies?.length > LIMIT && (
|
2023-01-01 10:28:07 +03:00
|
|
|
<div class="replies-link">
|
|
|
|
<Icon icon="comment" />{' '}
|
|
|
|
<span title={replies.length}>
|
|
|
|
{shortenNumber(replies.length)}
|
|
|
|
</span>
|
|
|
|
</div>
|
2023-01-28 20:02:25 +03:00
|
|
|
)} */}
|
2023-04-14 10:30:04 +03:00
|
|
|
</Link>
|
|
|
|
)}
|
|
|
|
{descendant && replies?.length > 0 && (
|
|
|
|
<SubComments
|
|
|
|
instance={instance}
|
|
|
|
replies={replies}
|
|
|
|
hasParentThread={thread}
|
|
|
|
level={1}
|
2023-05-09 17:29:18 +03:00
|
|
|
accWeight={weight}
|
2023-05-11 08:56:21 +03:00
|
|
|
openAll={
|
|
|
|
totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT
|
|
|
|
}
|
2023-04-14 10:30:04 +03:00
|
|
|
/>
|
|
|
|
)}
|
|
|
|
{uiState === 'loading' &&
|
|
|
|
isHero &&
|
|
|
|
!!heroStatus?.repliesCount &&
|
|
|
|
!hasDescendants && (
|
|
|
|
<div class="status-loading">
|
|
|
|
<Loader />
|
|
|
|
</div>
|
2022-12-22 19:30:55 +03:00
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
{uiState === 'error' &&
|
|
|
|
isHero &&
|
|
|
|
!!heroStatus?.repliesCount &&
|
|
|
|
!hasDescendants && (
|
|
|
|
<div class="status-error">
|
|
|
|
Unable to load replies.
|
|
|
|
<br />
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="plain"
|
|
|
|
onClick={() => {
|
|
|
|
states.reloadStatusPage++;
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Try again
|
|
|
|
</button>
|
|
|
|
</div>
|
2023-01-28 20:02:25 +03:00
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
</li>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
{showMore > 0 && (
|
|
|
|
<li>
|
|
|
|
<button
|
|
|
|
type="button"
|
2023-05-24 12:16:45 +03:00
|
|
|
class="plain block show-more"
|
2023-04-14 10:30:04 +03:00
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
onClick={() => setLimit((l) => l + LIMIT)}
|
|
|
|
style={{ marginBlockEnd: '6em' }}
|
|
|
|
>
|
2023-05-24 12:16:45 +03:00
|
|
|
<div class="ib">
|
|
|
|
{/* show avatars for first 5 statuses */}
|
|
|
|
{statuses.slice(limit, limit + 5).map((status) => (
|
|
|
|
<Avatar
|
|
|
|
key={status.id}
|
|
|
|
url={status.account.avatarStatic}
|
|
|
|
// title={`${status.avatar.displayName} (@${status.avatar.acct})`}
|
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</div>{' '}
|
|
|
|
<div class="ib">
|
|
|
|
Show more…{' '}
|
|
|
|
<span class="tag">
|
|
|
|
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
|
|
|
</span>
|
|
|
|
</div>
|
2023-04-14 10:30:04 +03:00
|
|
|
</button>
|
|
|
|
</li>
|
|
|
|
)}
|
|
|
|
</ul>
|
|
|
|
) : (
|
|
|
|
<>
|
|
|
|
{uiState === 'loading' && (
|
|
|
|
<ul class="timeline flat contextual grow loading">
|
2023-01-01 10:28:07 +03:00
|
|
|
<li>
|
2023-04-14 10:30:04 +03:00
|
|
|
<Status skeleton size="l" />
|
2022-12-10 12:14:48 +03:00
|
|
|
</li>
|
2023-04-14 10:30:04 +03:00
|
|
|
</ul>
|
|
|
|
)}
|
|
|
|
{uiState === 'error' && (
|
|
|
|
<p class="ui-state">
|
2023-04-29 17:22:07 +03:00
|
|
|
Unable to load post
|
2023-04-14 10:30:04 +03:00
|
|
|
<br />
|
|
|
|
<br />
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
onClick={() => {
|
|
|
|
states.reloadStatusPage++;
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Try again
|
|
|
|
</button>
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
</div>
|
|
|
|
);
|
2022-12-16 08:27:04 +03:00
|
|
|
}
|
|
|
|
|
2023-05-11 08:56:21 +03:00
|
|
|
function SubComments({
|
|
|
|
replies,
|
|
|
|
instance,
|
|
|
|
hasParentThread,
|
|
|
|
level,
|
|
|
|
accWeight,
|
|
|
|
openAll,
|
|
|
|
}) {
|
2023-04-14 10:30:04 +03:00
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
2022-12-22 19:30:55 +03:00
|
|
|
|
2023-01-31 11:12:57 +03:00
|
|
|
// Total comments count, including sub-replies
|
|
|
|
const diveDeep = (replies) => {
|
|
|
|
return replies.reduce((acc, reply) => {
|
|
|
|
const { repliesCount, replies } = reply;
|
|
|
|
const count = replies?.length || repliesCount;
|
|
|
|
return acc + count + diveDeep(replies || []);
|
|
|
|
}, 0);
|
|
|
|
};
|
|
|
|
const totalComments = replies.length + diveDeep(replies);
|
|
|
|
const sameCount = replies.length === totalComments;
|
|
|
|
|
2023-01-28 20:02:25 +03:00
|
|
|
// Get the first 3 accounts, unique by id
|
|
|
|
const accounts = replies
|
|
|
|
.map((r) => r.account)
|
|
|
|
.filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i)
|
2023-01-31 11:12:57 +03:00
|
|
|
.slice(0, 3);
|
2023-01-28 20:02:25 +03:00
|
|
|
|
2023-05-09 17:29:18 +03:00
|
|
|
const totalWeight = useMemo(() => {
|
|
|
|
return replies?.reduce((acc, reply) => {
|
|
|
|
return acc + reply?.weight;
|
|
|
|
}, accWeight);
|
|
|
|
}, [accWeight, replies?.length]);
|
|
|
|
|
2023-05-08 07:50:00 +03:00
|
|
|
let open = false;
|
2023-05-11 08:56:21 +03:00
|
|
|
if (openAll) {
|
|
|
|
open = true;
|
|
|
|
} else if (totalWeight <= MAX_WEIGHT) {
|
2023-05-08 17:42:15 +03:00
|
|
|
open = true;
|
|
|
|
} else if (!hasParentThread && totalComments === 1) {
|
|
|
|
const shortReply = calcStatusWeight(replies[0]) < 2;
|
|
|
|
if (shortReply) open = true;
|
2023-05-08 07:50:00 +03:00
|
|
|
}
|
2023-03-28 15:33:00 +03:00
|
|
|
const openBefore = cachedRepliesToggle[replies[0].id];
|
2022-12-22 19:30:55 +03:00
|
|
|
|
2023-04-16 19:14:09 +03:00
|
|
|
const handleMediaClick = useCallback((e, i, media, status) => {
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
setSearchParams({
|
|
|
|
media: i + 1,
|
|
|
|
mediaStatusID: status.id,
|
|
|
|
});
|
|
|
|
}, []);
|
|
|
|
|
2023-05-07 13:20:25 +03:00
|
|
|
const detailsRef = useRef();
|
2023-06-14 12:37:54 +03:00
|
|
|
useLayoutEffect(() => {
|
2023-05-07 13:20:25 +03:00
|
|
|
function handleScroll(e) {
|
|
|
|
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
|
|
|
}
|
|
|
|
detailsRef.current?.addEventListener('scroll', handleScroll, {
|
|
|
|
passive: true,
|
|
|
|
});
|
|
|
|
return () => {
|
|
|
|
detailsRef.current?.removeEventListener('scroll', handleScroll);
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2022-12-22 19:30:55 +03:00
|
|
|
return (
|
2023-03-28 15:33:00 +03:00
|
|
|
<details
|
2023-05-07 13:20:25 +03:00
|
|
|
ref={detailsRef}
|
2023-03-28 15:33:00 +03:00
|
|
|
class="replies"
|
|
|
|
open={openBefore || open}
|
|
|
|
onToggle={(e) => {
|
|
|
|
const { open } = e.target;
|
|
|
|
// use first reply as ID
|
|
|
|
cachedRepliesToggle[replies[0].id] = open;
|
|
|
|
}}
|
2023-03-30 15:07:07 +03:00
|
|
|
style={{
|
|
|
|
'--comments-level': level,
|
|
|
|
}}
|
|
|
|
data-comments-level={level}
|
|
|
|
data-comments-level-overflow={level > 4}
|
2023-03-28 15:33:00 +03:00
|
|
|
>
|
2023-03-30 15:07:07 +03:00
|
|
|
<summary class="replies-summary" hidden={open}>
|
2023-01-28 20:02:25 +03:00
|
|
|
<span class="avatars">
|
|
|
|
{accounts.map((a) => (
|
|
|
|
<Avatar
|
|
|
|
key={a.id}
|
|
|
|
url={a.avatarStatic}
|
|
|
|
title={`${a.displayName} @${a.username}`}
|
2023-04-10 19:26:43 +03:00
|
|
|
squircle={a?.bot}
|
2023-01-28 20:02:25 +03:00
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</span>
|
|
|
|
<span>
|
|
|
|
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
|
|
|
|
repl
|
|
|
|
{replies.length === 1 ? 'y' : 'ies'}
|
|
|
|
</span>
|
2023-01-31 11:12:57 +03:00
|
|
|
{!sameCount && totalComments > 1 && (
|
|
|
|
<>
|
|
|
|
{' '}
|
|
|
|
·{' '}
|
|
|
|
<span>
|
|
|
|
<span title={totalComments}>{shortenNumber(totalComments)}</span>{' '}
|
|
|
|
comment
|
|
|
|
{totalComments === 1 ? '' : 's'}
|
|
|
|
</span>
|
|
|
|
</>
|
|
|
|
)}
|
2022-12-22 19:30:55 +03:00
|
|
|
</summary>
|
|
|
|
<ul>
|
2022-12-23 20:11:11 +03:00
|
|
|
{replies.map((r) => (
|
|
|
|
<li key={r.id}>
|
2022-12-22 19:30:55 +03:00
|
|
|
<Link
|
|
|
|
class="status-link"
|
2023-02-06 14:54:18 +03:00
|
|
|
to={instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`}
|
2023-01-21 14:52:51 +03:00
|
|
|
onClick={() => {
|
|
|
|
resetScrollPosition(r.id);
|
|
|
|
}}
|
2022-12-22 19:30:55 +03:00
|
|
|
>
|
2023-02-05 19:17:19 +03:00
|
|
|
<Status
|
|
|
|
statusID={r.id}
|
|
|
|
instance={instance}
|
|
|
|
withinContext
|
|
|
|
size="s"
|
2023-03-07 17:38:06 +03:00
|
|
|
enableTranslate
|
2023-04-16 19:14:09 +03:00
|
|
|
onMediaClick={handleMediaClick}
|
2023-02-05 19:17:19 +03:00
|
|
|
/>
|
2023-01-28 20:29:26 +03:00
|
|
|
{!r.replies?.length && r.repliesCount > 0 && (
|
2022-12-23 20:11:11 +03:00
|
|
|
<div class="replies-link">
|
|
|
|
<Icon icon="comment" />{' '}
|
|
|
|
<span title={r.repliesCount}>
|
|
|
|
{shortenNumber(r.repliesCount)}
|
|
|
|
</span>
|
|
|
|
</div>
|
2023-01-28 20:29:26 +03:00
|
|
|
)}
|
2022-12-22 19:30:55 +03:00
|
|
|
</Link>
|
2023-01-28 20:02:25 +03:00
|
|
|
{r.replies?.length && (
|
|
|
|
<SubComments
|
2023-02-05 19:17:19 +03:00
|
|
|
instance={instance}
|
2023-01-28 20:02:25 +03:00
|
|
|
replies={r.replies}
|
2023-03-30 15:07:07 +03:00
|
|
|
level={level + 1}
|
2023-05-09 17:29:18 +03:00
|
|
|
accWeight={!open ? r.weight : totalWeight}
|
2023-05-11 08:56:21 +03:00
|
|
|
openAll={openAll}
|
2023-01-28 20:02:25 +03:00
|
|
|
/>
|
|
|
|
)}
|
2022-12-22 19:30:55 +03:00
|
|
|
</li>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
</details>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-05-08 17:42:15 +03:00
|
|
|
const MEDIA_VIRTUAL_LENGTH = 140;
|
|
|
|
const POLL_VIRTUAL_LENGTH = 35;
|
|
|
|
const CARD_VIRTUAL_LENGTH = 70;
|
|
|
|
const WEIGHT_SEGMENT = 140;
|
|
|
|
const statusWeightCache = new Map();
|
|
|
|
function calcStatusWeight(status) {
|
|
|
|
const cachedWeight = statusWeightCache.get(status.id);
|
|
|
|
if (cachedWeight) return cachedWeight;
|
|
|
|
const { spoilerText, content, mediaAttachments, poll, card } = status;
|
|
|
|
const length = htmlContentLength(spoilerText + content);
|
|
|
|
const mediaLength = mediaAttachments?.length ? MEDIA_VIRTUAL_LENGTH : 0;
|
|
|
|
const pollLength = (poll?.options?.length || 0) * POLL_VIRTUAL_LENGTH;
|
|
|
|
const cardLength =
|
|
|
|
card && (mediaAttachments?.length || poll?.options?.length)
|
|
|
|
? 0
|
|
|
|
: CARD_VIRTUAL_LENGTH;
|
|
|
|
const totalLength = length + mediaLength + pollLength + cardLength;
|
|
|
|
const weight = totalLength / WEIGHT_SEGMENT;
|
|
|
|
statusWeightCache.set(status.id, weight);
|
|
|
|
return weight;
|
|
|
|
}
|
|
|
|
|
2023-04-18 14:47:21 +03:00
|
|
|
export default memo(StatusPage);
|