mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-01-12 20:47:19 +03:00
1401 lines
44 KiB
JavaScript
1401 lines
44 KiB
JavaScript
import './status.css';
|
||
|
||
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
|
||
import debounce from 'just-debounce-it';
|
||
import pRetry from 'p-retry';
|
||
import { memo } from 'preact/compat';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'preact/hooks';
|
||
import { useHotkeys } from 'react-hotkeys-hook';
|
||
import { InView } from 'react-intersection-observer';
|
||
import { matchPath, useSearchParams } from 'react-router-dom';
|
||
import { useDebouncedCallback } from 'use-debounce';
|
||
import { useSnapshot } from 'valtio';
|
||
|
||
import Avatar from '../components/avatar';
|
||
import Icon from '../components/icon';
|
||
import Link from '../components/link';
|
||
import Loader from '../components/loader';
|
||
import MediaModal from '../components/media-modal';
|
||
import Menu2 from '../components/menu2';
|
||
import NameText from '../components/name-text';
|
||
import RelativeTime from '../components/relative-time';
|
||
import Status from '../components/status';
|
||
import { api } from '../utils/api';
|
||
import htmlContentLength from '../utils/html-content-length';
|
||
import shortenNumber from '../utils/shorten-number';
|
||
import states, {
|
||
getStatus,
|
||
saveStatus,
|
||
statusKey,
|
||
threadifyStatus,
|
||
} from '../utils/states';
|
||
import statusPeek from '../utils/status-peek';
|
||
import { getCurrentAccount } from '../utils/store-utils';
|
||
import useScroll from '../utils/useScroll';
|
||
import useTitle from '../utils/useTitle';
|
||
|
||
import getInstanceStatusURL from './../utils/get-instance-status-url';
|
||
|
||
const LIMIT = 40;
|
||
const SUBCOMMENTS_OPEN_ALL_LIMIT = 10;
|
||
const MAX_WEIGHT = 5;
|
||
|
||
let cachedRepliesToggle = {};
|
||
let cachedStatusesMap = {};
|
||
let scrollPositions = {};
|
||
function resetScrollPosition(id) {
|
||
delete cachedStatusesMap[id];
|
||
delete scrollPositions[id];
|
||
}
|
||
|
||
const scrollIntoViewOptions = {
|
||
block: 'nearest',
|
||
inline: 'center',
|
||
behavior: 'smooth',
|
||
};
|
||
|
||
// Select all statuses except those inside collapsed details/summary
|
||
// Hat-tip to @AmeliaBR@front-end.social
|
||
// https://front-end.social/@AmeliaBR/109784776146144471
|
||
const STATUSES_SELECTOR =
|
||
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)';
|
||
|
||
const STATUS_URL_REGEX = /\/s\//i;
|
||
|
||
function StatusPage(params) {
|
||
const { id } = params;
|
||
const { masto, instance } = api({ instance: params.instance });
|
||
const snapStates = useSnapshot(states);
|
||
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]);
|
||
useEffect(() => {
|
||
if (states.statuses[sKey]) {
|
||
setHeroStatus(states.statuses[sKey]);
|
||
}
|
||
}, [sKey]);
|
||
|
||
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.$select(id).fetch();
|
||
saveStatus(status, instance);
|
||
setHeroStatus(status);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Unable to load post.');
|
||
location.hash = closeLink;
|
||
}
|
||
})();
|
||
}
|
||
}, [showMedia]);
|
||
|
||
const mediaAttachments = mediaStatusID
|
||
? mediaStatus?.mediaAttachments
|
||
: heroStatus?.mediaAttachments;
|
||
|
||
const handleMediaClose = useCallback(() => {
|
||
if (
|
||
!window.matchMedia('(min-width: calc(40em + 350px))').matches &&
|
||
snapStates.prevLocation
|
||
) {
|
||
history.back();
|
||
} else {
|
||
if (showMediaOnly) {
|
||
location.hash = closeLink;
|
||
} else {
|
||
searchParams.delete('media');
|
||
searchParams.delete('mediaStatusID');
|
||
setSearchParams(searchParams);
|
||
}
|
||
}
|
||
}, [showMediaOnly, closeLink, snapStates.prevLocation]);
|
||
|
||
useEffect(() => {
|
||
let timer = setTimeout(() => {
|
||
// carouselRef.current?.focus?.();
|
||
const $carousel = document.querySelector('.carousel');
|
||
if ($carousel) {
|
||
$carousel.focus();
|
||
}
|
||
}, 100);
|
||
return () => clearTimeout(timer);
|
||
}, [showMediaOnly]);
|
||
|
||
return (
|
||
<div class="deck-backdrop">
|
||
{showMedia ? (
|
||
mediaAttachments?.length ? (
|
||
<MediaModal
|
||
mediaAttachments={mediaAttachments}
|
||
statusID={mediaStatusID || id}
|
||
instance={instance}
|
||
lang={heroStatus?.language}
|
||
index={mediaIndex - 1}
|
||
onClose={handleMediaClose}
|
||
/>
|
||
) : (
|
||
<div class="media-modal-container loading">
|
||
<Loader abrupt />
|
||
</div>
|
||
)
|
||
) : (
|
||
<Link to={closeLink} />
|
||
)}
|
||
{!showMediaOnly && (
|
||
<StatusThread
|
||
id={id}
|
||
instance={params.instance}
|
||
closeLink={closeLink}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const mediaParam = searchParams.get('media');
|
||
const mediaStatusID = searchParams.get('mediaStatusID');
|
||
const showMedia = parseInt(mediaParam, 10) > 0;
|
||
const firstLoad = useRef(
|
||
!states.prevLocation &&
|
||
(history.length === 1 ||
|
||
('navigation' in window && navigation?.entries?.()?.length === 1)),
|
||
);
|
||
const [viewMode, setViewMode] = useState(
|
||
searchParams.get('view') || firstLoad.current ? 'full' : null,
|
||
);
|
||
const translate = !!parseInt(searchParams.get('translate'));
|
||
const { masto, instance } = api({ instance: propInstance });
|
||
const {
|
||
masto: currentMasto,
|
||
instance: currentInstance,
|
||
authenticated,
|
||
} = api();
|
||
const sameInstance = instance === currentInstance;
|
||
const snapStates = useSnapshot(states);
|
||
const [statuses, setStatuses] = useState([]);
|
||
const [uiState, setUIState] = useState('default');
|
||
const heroStatusRef = useRef();
|
||
const sKey = statusKey(id, instance);
|
||
const totalDescendants = useRef(0);
|
||
|
||
const scrollableRef = useRef();
|
||
useEffect(() => {
|
||
scrollableRef.current?.focus();
|
||
}, []);
|
||
useEffect(() => {
|
||
const onScroll = debounce(() => {
|
||
// console.log('onScroll');
|
||
if (!scrollableRef.current) return;
|
||
const { scrollTop } = scrollableRef.current;
|
||
if (uiState !== 'loading') {
|
||
scrollPositions[id] = scrollTop;
|
||
}
|
||
}, 50);
|
||
scrollableRef.current?.addEventListener('scroll', onScroll, {
|
||
passive: true,
|
||
});
|
||
onScroll();
|
||
return () => {
|
||
onScroll.cancel();
|
||
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
||
};
|
||
}, [id, uiState !== 'loading']);
|
||
|
||
const scrollOffsets = useRef();
|
||
const initContext = ({ reloadHero } = {}) => {
|
||
console.debug('initContext', id);
|
||
setUIState('loading');
|
||
let heroTimer;
|
||
|
||
const cachedStatuses = cachedStatusesMap[id];
|
||
if (cachedStatuses) {
|
||
// Case 1: It's cached, let's restore them to make it snappy
|
||
const reallyCachedStatuses = cachedStatuses.filter(
|
||
(s) => states.statuses[sKey],
|
||
// Some are not cached in the global state, so we need to filter them out
|
||
);
|
||
setStatuses(reallyCachedStatuses);
|
||
} else {
|
||
// 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 }]);
|
||
// }
|
||
}
|
||
|
||
(async () => {
|
||
const heroFetch = () =>
|
||
pRetry(() => masto.v1.statuses.$select(id).fetch(), {
|
||
retries: 4,
|
||
});
|
||
const contextFetch = pRetry(
|
||
() => masto.v1.statuses.$select(id).context.fetch(),
|
||
{
|
||
retries: 8,
|
||
},
|
||
);
|
||
|
||
const hasStatus = !!snapStates.statuses[sKey];
|
||
let heroStatus = snapStates.statuses[sKey];
|
||
if (hasStatus && !reloadHero) {
|
||
console.debug('Hero status is cached');
|
||
} else {
|
||
try {
|
||
heroStatus = await heroFetch();
|
||
saveStatus(heroStatus, instance);
|
||
// Give time for context to appear
|
||
await new Promise((resolve) => {
|
||
setTimeout(resolve, 100);
|
||
});
|
||
} catch (e) {
|
||
console.error(e);
|
||
setUIState('error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const context = await contextFetch;
|
||
const { ancestors, descendants } = context;
|
||
|
||
totalDescendants.current = descendants?.length || 0;
|
||
|
||
const missingStatuses = new Set();
|
||
ancestors.forEach((status) => {
|
||
saveStatus(status, instance, {
|
||
skipThreading: true,
|
||
});
|
||
if (
|
||
status.inReplyToId &&
|
||
!ancestors.find((s) => s.id === status.inReplyToId)
|
||
) {
|
||
missingStatuses.add(status.inReplyToId);
|
||
}
|
||
});
|
||
const ancestorsIsThread = ancestors.every(
|
||
(s) => s.account.id === heroStatus.account.id,
|
||
);
|
||
const nestedDescendants = [];
|
||
descendants.forEach((status) => {
|
||
saveStatus(status, instance, {
|
||
skipThreading: true,
|
||
});
|
||
|
||
if (
|
||
status.inReplyToId &&
|
||
!descendants.find((s) => s.id === status.inReplyToId) &&
|
||
status.inReplyToId !== heroStatus.id
|
||
) {
|
||
missingStatuses.add(status.inReplyToId);
|
||
}
|
||
|
||
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);
|
||
} 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);
|
||
} else {
|
||
// 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, something is wrong
|
||
console.warn('No parent found for', status);
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log({ ancestors, descendants, nestedDescendants });
|
||
if (missingStatuses.size) {
|
||
console.error('Missing statuses', [...missingStatuses]);
|
||
}
|
||
|
||
function expandReplies(_replies) {
|
||
return _replies?.map((_r) => ({
|
||
id: _r.id,
|
||
account: _r.account,
|
||
repliesCount: _r.repliesCount,
|
||
content: _r.content,
|
||
weight: calcStatusWeight(_r),
|
||
replies: expandReplies(_r.__replies),
|
||
}));
|
||
}
|
||
|
||
const allStatuses = [
|
||
...ancestors.map((s) => ({
|
||
id: s.id,
|
||
ancestor: true,
|
||
isThread: ancestorsIsThread,
|
||
accountID: s.account.id,
|
||
account: s.account,
|
||
repliesCount: s.repliesCount,
|
||
weight: calcStatusWeight(s),
|
||
})),
|
||
{
|
||
id,
|
||
accountID: heroStatus.account.id,
|
||
weight: calcStatusWeight(heroStatus),
|
||
},
|
||
...nestedDescendants.map((s) => ({
|
||
id: s.id,
|
||
account: s.account,
|
||
accountID: s.account.id,
|
||
descendant: true,
|
||
thread: s.account.id === heroStatus.account.id,
|
||
weight: calcStatusWeight(s),
|
||
replies: expandReplies(s.__replies),
|
||
})),
|
||
];
|
||
|
||
setUIState('default');
|
||
scrollOffsets.current = {
|
||
offsetTop: heroStatusRef.current?.offsetTop,
|
||
scrollTop: scrollableRef.current?.scrollTop,
|
||
};
|
||
|
||
// Set limit to hero's index
|
||
const heroLimit = allStatuses.findIndex((s) => s.id === id);
|
||
if (heroLimit >= limit) {
|
||
setLimit(heroLimit + 1);
|
||
}
|
||
|
||
console.log({ allStatuses });
|
||
setStatuses(allStatuses);
|
||
cachedStatusesMap[id] = allStatuses;
|
||
|
||
// 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
|
||
threadifyStatus(heroStatus, instance);
|
||
} catch (e) {
|
||
console.error(e);
|
||
setUIState('error');
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
clearTimeout(heroTimer);
|
||
};
|
||
};
|
||
|
||
useEffect(initContext, [id, masto]);
|
||
useEffect(() => {
|
||
if (!statuses.length) return;
|
||
console.debug('STATUSES', statuses);
|
||
const scrollPosition = scrollPositions[id];
|
||
console.debug('scrollPosition', scrollPosition);
|
||
if (!!scrollPosition) {
|
||
console.debug('Case 1', {
|
||
id,
|
||
scrollPosition,
|
||
});
|
||
scrollableRef.current.scrollTop = scrollPosition;
|
||
} else if (scrollOffsets.current) {
|
||
const newScrollOffsets = {
|
||
offsetTop: heroStatusRef.current?.offsetTop,
|
||
scrollTop: scrollableRef.current?.scrollTop,
|
||
};
|
||
const newScrollTop =
|
||
newScrollOffsets.offsetTop -
|
||
scrollOffsets.current.offsetTop +
|
||
newScrollOffsets.scrollTop;
|
||
console.debug('Case 2', {
|
||
scrollOffsets: scrollOffsets.current,
|
||
newScrollOffsets,
|
||
newScrollTop,
|
||
statuses: [...statuses],
|
||
});
|
||
scrollableRef.current.scrollTop = newScrollTop;
|
||
} else if (statuses.length === 1) {
|
||
console.debug('Case 3', {
|
||
id,
|
||
});
|
||
scrollableRef.current.scrollTop = 0;
|
||
}
|
||
|
||
// RESET
|
||
scrollOffsets.current = null;
|
||
}, [statuses]);
|
||
|
||
useEffect(() => {
|
||
if (snapStates.reloadStatusPage <= 0) return;
|
||
// Delete the cache for the context
|
||
(async () => {
|
||
try {
|
||
const { instanceURL } = getCurrentAccount();
|
||
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 });
|
||
|
||
return initContext({
|
||
reloadHero: true,
|
||
});
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
})();
|
||
}, [snapStates.reloadStatusPage]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
// RESET
|
||
scrollPositions = {};
|
||
states.reloadStatusPage = 0;
|
||
cachedStatusesMap = {};
|
||
cachedRepliesToggle = {};
|
||
statusWeightCache.clear();
|
||
};
|
||
}, []);
|
||
|
||
const heroStatus = snapStates.statuses[sKey] || snapStates.statuses[id];
|
||
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 '';
|
||
let text = statusPeek(heroStatus);
|
||
if (text.length > 64) {
|
||
// "The title should ideally be less than 64 characters in length"
|
||
// https://www.w3.org/Provider/Style/TITLE.html
|
||
text = text.slice(0, 64) + '…';
|
||
}
|
||
return text;
|
||
}, [heroStatus]);
|
||
useTitle(
|
||
heroDisplayName && heroContentText
|
||
? `${heroDisplayName}: "${heroContentText}"`
|
||
: 'Status',
|
||
'/:instance?/s/:id',
|
||
);
|
||
|
||
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]);
|
||
|
||
const [limit, setLimit] = useState(LIMIT);
|
||
const showMore = useMemo(() => {
|
||
// return number of statuses to show
|
||
return statuses.length - limit;
|
||
}, [statuses.length, limit]);
|
||
|
||
const hasDescendants = statuses.some((s) => s.descendant);
|
||
const ancestors = statuses.filter((s) => s.ancestor);
|
||
|
||
const [heroInView, setHeroInView] = useState(true);
|
||
const onView = useDebouncedCallback(setHeroInView, 100);
|
||
const heroPointer = useMemo(() => {
|
||
// get top offset of heroStatus
|
||
if (!heroStatusRef.current || heroInView) return null;
|
||
const { top } = heroStatusRef.current.getBoundingClientRect();
|
||
return top > 0 ? 'down' : 'up';
|
||
}, [heroInView]);
|
||
|
||
useHotkeys(
|
||
'esc',
|
||
() => {
|
||
location.hash = closeLink;
|
||
},
|
||
{
|
||
// If media is open, esc to close media first
|
||
// Else close the status page
|
||
enabled: !showMedia,
|
||
ignoreEventWhen: (e) => {
|
||
const hasModal = !!document.querySelector('#modal-container > *');
|
||
return hasModal;
|
||
},
|
||
},
|
||
);
|
||
// For backspace, will always close both media and status page
|
||
useHotkeys('backspace', () => {
|
||
location.hash = closeLink;
|
||
});
|
||
|
||
useHotkeys('j', () => {
|
||
const activeStatus = document.activeElement.closest(
|
||
'.status-link, .status-focus',
|
||
);
|
||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||
const allStatusLinks = Array.from(
|
||
scrollableRef.current.querySelectorAll(STATUSES_SELECTOR),
|
||
);
|
||
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.scrollIntoView(scrollIntoViewOptions);
|
||
}
|
||
} 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.scrollIntoView(scrollIntoViewOptions);
|
||
}
|
||
}
|
||
});
|
||
|
||
useHotkeys('k', () => {
|
||
const activeStatus = document.activeElement.closest(
|
||
'.status-link, .status-focus',
|
||
);
|
||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||
const allStatusLinks = Array.from(
|
||
scrollableRef.current.querySelectorAll(STATUSES_SELECTOR),
|
||
);
|
||
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.scrollIntoView(scrollIntoViewOptions);
|
||
}
|
||
} 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.scrollIntoView(scrollIntoViewOptions);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
});
|
||
|
||
const { nearReachStart } = useScroll({
|
||
scrollableRef,
|
||
distanceFromStartPx: 16,
|
||
});
|
||
|
||
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
|
||
|
||
const handleMediaClick = useCallback(
|
||
(e, i, media, status) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setSearchParams({
|
||
media: i + 1,
|
||
mediaStatusID: status.id,
|
||
});
|
||
},
|
||
[id],
|
||
);
|
||
|
||
const handleStatusLinkClick = useCallback((e, status) => {
|
||
resetScrollPosition(status.id);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let timer;
|
||
if (mediaStatusID && showMedia) {
|
||
timer = setTimeout(() => {
|
||
const status = scrollableRef.current?.querySelector(
|
||
`.status-link[href*="/${mediaStatusID}"]`,
|
||
);
|
||
if (status) {
|
||
status.scrollIntoView(scrollIntoViewOptions);
|
||
}
|
||
}, 400); // After CSS transition
|
||
}
|
||
return () => {
|
||
clearTimeout(timer);
|
||
};
|
||
}, [mediaStatusID, showMedia]);
|
||
|
||
const renderStatus = useCallback(
|
||
(status) => {
|
||
const {
|
||
id: statusID,
|
||
ancestor,
|
||
isThread,
|
||
descendant,
|
||
thread,
|
||
replies,
|
||
repliesCount,
|
||
weight,
|
||
} = status;
|
||
const isHero = statusID === id;
|
||
// const StatusParent = useCallback(
|
||
// (props) =>
|
||
// isThread || thread || ancestor ? (
|
||
// <Link
|
||
// class="status-link"
|
||
// to={
|
||
// instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
|
||
// }
|
||
// onClick={() => {
|
||
// resetScrollPosition(statusID);
|
||
// }}
|
||
// {...props}
|
||
// />
|
||
// ) : (
|
||
// <div class="status-focus" tabIndex={0} {...props} />
|
||
// ),
|
||
// [isThread, thread],
|
||
// );
|
||
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}
|
||
>
|
||
<Status
|
||
statusID={statusID}
|
||
instance={instance}
|
||
withinContext
|
||
size="l"
|
||
enableTranslate
|
||
forceTranslate={translate}
|
||
/>
|
||
</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.fetch({
|
||
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>
|
||
</div>
|
||
)
|
||
)}
|
||
</>
|
||
) : (
|
||
// <StatusParent>
|
||
<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
|
||
onMediaClick={handleMediaClick}
|
||
onStatusLinkClick={handleStatusLinkClick}
|
||
/>
|
||
{ancestor && repliesCount > 1 && (
|
||
<div class="replies-link">
|
||
<Icon icon="comment2" />{' '}
|
||
<span title={repliesCount}>
|
||
{shortenNumber(repliesCount)}
|
||
</span>
|
||
</div>
|
||
)}{' '}
|
||
{/* {replies?.length > LIMIT && (
|
||
<div class="replies-link">
|
||
<Icon icon="comment" />{' '}
|
||
<span title={replies.length}>
|
||
{shortenNumber(replies.length)}
|
||
</span>
|
||
</div>
|
||
)} */}
|
||
{/* </StatusParent> */}
|
||
</Link>
|
||
)}
|
||
{descendant && replies?.length > 0 && (
|
||
<SubComments
|
||
instance={instance}
|
||
replies={replies}
|
||
hasParentThread={thread}
|
||
level={1}
|
||
accWeight={weight}
|
||
openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT}
|
||
/>
|
||
)}
|
||
{uiState === 'loading' &&
|
||
isHero &&
|
||
!!heroStatus?.repliesCount &&
|
||
!hasDescendants && (
|
||
<div class="status-loading">
|
||
<Loader />
|
||
</div>
|
||
)}
|
||
{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>
|
||
)}
|
||
</li>
|
||
);
|
||
},
|
||
[
|
||
id,
|
||
instance,
|
||
uiState,
|
||
authenticated,
|
||
sameInstance,
|
||
translate,
|
||
handleMediaClick,
|
||
handleStatusLinkClick,
|
||
hasDescendants,
|
||
],
|
||
);
|
||
|
||
const prevLocationIsStatusPage = useMemo(() => {
|
||
// Navigation API
|
||
if ('navigation' in window && navigation?.entries) {
|
||
const prevEntry = navigation.entries()[navigation.currentEntry.index - 1];
|
||
if (prevEntry?.url) {
|
||
return STATUS_URL_REGEX.test(prevEntry.url);
|
||
}
|
||
}
|
||
return STATUS_URL_REGEX.test(states.prevLocation?.pathname);
|
||
}, [sKey]);
|
||
|
||
const moreStatusesKeys = useMemo(() => {
|
||
if (!showMore) return [];
|
||
const ids = [];
|
||
function getIDs(status) {
|
||
ids.push(status.id);
|
||
if (status.replies) {
|
||
status.replies.forEach(getIDs);
|
||
}
|
||
}
|
||
statuses.slice(limit).forEach(getIDs);
|
||
return ids.map((id) => statusKey(id, instance));
|
||
}, [showMore, statuses, limit, instance]);
|
||
|
||
return (
|
||
<div
|
||
tabIndex="-1"
|
||
ref={scrollableRef}
|
||
class={`status-deck deck contained ${
|
||
statuses.length > 1 ? 'padded-bottom' : ''
|
||
} ${
|
||
initialPageState.current === 'status' && !firstLoad.current
|
||
? 'slide-in'
|
||
: ''
|
||
} ${viewMode ? `deck-view-${viewMode}` : ''}`}
|
||
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;
|
||
}
|
||
}}
|
||
>
|
||
<header
|
||
class={`${heroInView ? 'inview' : ''} ${
|
||
uiState === 'loading' ? 'loading' : ''
|
||
}`}
|
||
onDblClick={(e) => {
|
||
// reload statuses
|
||
states.reloadStatusPage++;
|
||
}}
|
||
>
|
||
{/* <div>
|
||
<Link class="button plain deck-close" href={closeLink}>
|
||
<Icon icon="chevron-left" size="xl" />
|
||
</Link>
|
||
</div> */}
|
||
<div class="header-grid header-grid-2">
|
||
<h1>
|
||
{prevLocationIsStatusPage && (
|
||
<button
|
||
type="button"
|
||
class="plain deck-back"
|
||
onClick={() => {
|
||
history.back();
|
||
}}
|
||
>
|
||
<Icon icon="chevron-left" size="xl" />
|
||
</button>
|
||
)}
|
||
{!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"
|
||
/>
|
||
</span>
|
||
</span>{' '}
|
||
<button
|
||
type="button"
|
||
class="ancestors-indicator light small"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
heroStatusRef.current.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start',
|
||
});
|
||
}}
|
||
title="Go to main post"
|
||
>
|
||
<Icon
|
||
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||
/>
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
Post{' '}
|
||
<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}
|
||
title={`${ancestors.length} posts above ‒ Go to top`}
|
||
>
|
||
<Icon icon="arrow-up" />
|
||
{ancestors
|
||
.filter(
|
||
(a, i, arr) =>
|
||
arr.findIndex((b) => b.accountID === a.accountID) === i,
|
||
)
|
||
.slice(0, 3)
|
||
.map((ancestor) => (
|
||
<Avatar
|
||
key={ancestor.account.id}
|
||
url={ancestor.account.avatar}
|
||
alt={ancestor.account.displayName}
|
||
/>
|
||
))}
|
||
{/* <Icon icon="comment" />{' '} */}
|
||
{ancestors.length > 3 && (
|
||
<>
|
||
{' '}
|
||
<span class="insignificant">
|
||
{shortenNumber(ancestors.length)}
|
||
</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</h1>
|
||
<div class="header-side">
|
||
<button
|
||
type="button"
|
||
class="plain4 button-switch-view"
|
||
style={{
|
||
display: viewMode === 'full' ? '' : 'none',
|
||
}}
|
||
onClick={() => {
|
||
setViewMode(null);
|
||
searchParams.delete('media');
|
||
searchParams.delete('media-only');
|
||
searchParams.delete('view');
|
||
setSearchParams(searchParams);
|
||
}}
|
||
title="Switch to Side Peek view"
|
||
>
|
||
<Icon icon="layout4" size="l" />
|
||
</button>
|
||
<Menu2
|
||
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++;
|
||
}}
|
||
>
|
||
<Icon icon="refresh" />
|
||
<span>Refresh</span>
|
||
</MenuItem>
|
||
<MenuItem
|
||
className="menu-switch-view"
|
||
onClick={() => {
|
||
setViewMode(viewMode === 'full' ? null : 'full');
|
||
searchParams.delete('media');
|
||
searchParams.delete('media-only');
|
||
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>
|
||
<MenuItem
|
||
onClick={() => {
|
||
// Click all buttons with class .spoiler but not .spoiling
|
||
const buttons = Array.from(
|
||
scrollableRef.current.querySelectorAll(
|
||
'.spoiler-button:not(.spoiling), .spoiler-media-button: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={!postInstance || 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
|
||
{postInstance ? (
|
||
<>
|
||
{' '}
|
||
(<b>{postInstance}</b>)
|
||
</>
|
||
) : (
|
||
''
|
||
)}
|
||
</small>
|
||
</MenuItem>
|
||
</Menu2>
|
||
<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(renderStatus)}
|
||
{showMore > 0 && (
|
||
<li>
|
||
<button
|
||
type="button"
|
||
class="plain block show-more"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => setLimit((l) => l + LIMIT)}
|
||
style={{ marginBlockEnd: '6em' }}
|
||
data-state-post-ids={moreStatusesKeys.join(' ')}
|
||
>
|
||
<div class="ib avatars-bunch">
|
||
{/* 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>
|
||
</button>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
) : (
|
||
<>
|
||
{uiState === 'loading' && (
|
||
<ul class="timeline flat contextual grow loading">
|
||
<li>
|
||
<Status skeleton size="l" />
|
||
</li>
|
||
</ul>
|
||
)}
|
||
{uiState === 'error' && (
|
||
<p class="ui-state">
|
||
Unable to load post
|
||
<br />
|
||
<br />
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
states.reloadStatusPage++;
|
||
}}
|
||
>
|
||
Try again
|
||
</button>
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SubComments({
|
||
replies,
|
||
instance,
|
||
hasParentThread,
|
||
level,
|
||
accWeight,
|
||
openAll,
|
||
}) {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
// 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;
|
||
|
||
// 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)
|
||
.slice(0, 3);
|
||
|
||
const totalWeight = useMemo(() => {
|
||
return replies?.reduce((acc, reply) => {
|
||
return acc + reply?.weight;
|
||
}, accWeight);
|
||
}, [accWeight, replies?.length]);
|
||
|
||
let open = false;
|
||
if (openAll) {
|
||
open = true;
|
||
} else if (totalWeight <= MAX_WEIGHT) {
|
||
open = true;
|
||
} else if (!hasParentThread && totalComments === 1) {
|
||
const shortReply = calcStatusWeight(replies[0]) < 2;
|
||
if (shortReply) open = true;
|
||
}
|
||
const openBefore = cachedRepliesToggle[replies[0].id];
|
||
|
||
const handleMediaClick = useCallback((e, i, media, status) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setSearchParams({
|
||
media: i + 1,
|
||
mediaStatusID: status.id,
|
||
});
|
||
}, []);
|
||
|
||
const detailsRef = useRef();
|
||
useLayoutEffect(() => {
|
||
function handleScroll(e) {
|
||
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
||
}
|
||
detailsRef.current?.addEventListener('scroll', handleScroll, {
|
||
passive: true,
|
||
});
|
||
return () => {
|
||
detailsRef.current?.removeEventListener('scroll', handleScroll);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<details
|
||
ref={detailsRef}
|
||
class="replies"
|
||
open={openBefore || open}
|
||
onToggle={(e) => {
|
||
const { open } = e.target;
|
||
// use first reply as ID
|
||
cachedRepliesToggle[replies[0].id] = open;
|
||
}}
|
||
style={{
|
||
'--comments-level': level,
|
||
}}
|
||
data-comments-level={level}
|
||
data-comments-level-overflow={level > 4}
|
||
>
|
||
<summary class="replies-summary" hidden={open}>
|
||
<span class="avatars">
|
||
{accounts.map((a) => (
|
||
<Avatar
|
||
key={a.id}
|
||
url={a.avatarStatic}
|
||
title={`${a.displayName} @${a.username}`}
|
||
squircle={a?.bot}
|
||
/>
|
||
))}
|
||
</span>
|
||
<b>
|
||
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
|
||
repl
|
||
{replies.length === 1 ? 'y' : 'ies'}
|
||
</b>
|
||
{!sameCount && totalComments > 1 && (
|
||
<>
|
||
{' '}
|
||
·{' '}
|
||
<span>
|
||
<span title={totalComments}>{shortenNumber(totalComments)}</span>{' '}
|
||
comment
|
||
{totalComments === 1 ? '' : 's'}
|
||
</span>
|
||
</>
|
||
)}
|
||
</summary>
|
||
<ul>
|
||
{replies.map((r) => (
|
||
<li key={r.id}>
|
||
<Link
|
||
class="status-link"
|
||
to={instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`}
|
||
onClick={() => {
|
||
resetScrollPosition(r.id);
|
||
}}
|
||
>
|
||
{/* <div class="status-focus" tabIndex={0}> */}
|
||
<Status
|
||
statusID={r.id}
|
||
instance={instance}
|
||
withinContext
|
||
size="s"
|
||
enableTranslate
|
||
onMediaClick={handleMediaClick}
|
||
/>
|
||
{!r.replies?.length && r.repliesCount > 0 && (
|
||
<div class="replies-link">
|
||
<Icon icon="comment2" />{' '}
|
||
<span title={r.repliesCount}>
|
||
{shortenNumber(r.repliesCount)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* </div> */}
|
||
</Link>
|
||
{r.replies?.length && (
|
||
<SubComments
|
||
instance={instance}
|
||
replies={r.replies}
|
||
level={level + 1}
|
||
accWeight={!open ? r.weight : totalWeight}
|
||
openAll={openAll}
|
||
/>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export default memo(StatusPage);
|