Files
ncd-fe/packages/pl-fe/src/pages/accounts/account-gallery.tsx
matty 1926a1d6bb
Some checks failed
pl-api CI / Test for pl-api formatting (22.x) (push) Has been cancelled
pl-fe CI / Test and upload artifacts (22.x) (push) Has been cancelled
pl-hooks CI / Test for a successful build (22.x) (push) Has been cancelled
pl-fe CI / deploy (push) Has been cancelled
add better loading states to media
2026-02-14 20:15:46 +00:00

240 lines
7.5 KiB
TypeScript

import { Link } from '@tanstack/react-router';
import clsx from 'clsx';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAccount } from '@/api/hooks/accounts/use-account';
import { useAccountLookup } from '@/api/hooks/accounts/use-account-lookup';
import Blurhash from '@/components/blurhash';
import Icon from '@/components/icon';
import LoadMore from '@/components/load-more';
import MissingIndicator from '@/components/missing-indicator';
import StillImage from '@/components/still-image';
import Column from '@/components/ui/column';
import Spinner from '@/components/ui/spinner';
import { profileMediaRoute } from '@/features/ui/router';
import { type AccountGalleryAttachment, useAccountGallery } from '@/hooks/use-account-gallery';
import { isIOS } from '@/is-mobile';
import { useModalsActions } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
interface IMediaItem {
attachment: AccountGalleryAttachment;
onOpenMedia: (attachment: AccountGalleryAttachment) => void;
isLast?: boolean;
}
const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia, isLast }) => {
const { autoPlayGif, displayMedia } = useSettings();
const { account } = useAccount(attachment.account_id);
const [visible, setVisible] = useState<boolean>(displayMedia !== 'hide_all' && !attachment.sensitive || displayMedia === 'show_all');
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = e => {
const video = e.target as HTMLVideoElement;
if (hoverToPlay()) {
video.play();
}
};
const handleMouseLeave: React.MouseEventHandler<HTMLVideoElement> = e => {
const video = e.target as HTMLVideoElement;
if (hoverToPlay()) {
video.pause();
video.currentTime = 0;
}
};
const hoverToPlay = () => !autoPlayGif && ['gifv', 'video'].includes(attachment.type);
const handleClick: React.MouseEventHandler = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (visible) {
onOpenMedia(attachment);
} else {
setVisible(true);
}
}
};
const title = attachment.description;
let thumbnail: React.ReactNode = '';
let icon;
if (attachment.type === 'unknown') {
// Skip
} else if (attachment.type === 'image') {
const focusX = Number(attachment.meta?.focus?.x) || 0;
const focusY = Number(attachment.meta?.focus?.y) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<StillImage
src={attachment.preview_url}
alt={attachment.description}
style={{ objectPosition: `${x}% ${y}%` }}
className={clsx('size-full overflow-hidden', { 'rounded-br-md': isLast })}
blurhash={attachment.blurhash}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.type) !== -1) {
const conditionalAttributes: React.VideoHTMLAttributes<HTMLVideoElement> = {};
if (isIOS()) {
conditionalAttributes.playsInline = true;
}
if (autoPlayGif) {
conditionalAttributes.autoPlay = true;
}
thumbnail = (
<div className={clsx('⁂-media-gallery__gifv', { autoplay: autoPlayGif })}>
<video
className={clsx('⁂-media-gallery__item-gifv-thumbnail overflow-hidden', { 'rounded-br-md': isLast })}
aria-label={attachment.description}
title={attachment.description}
role='application'
src={attachment.url}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
loop
muted
{...conditionalAttributes}
/>
<span className='⁂-media-gallery__gifv__label'>GIF</span>
</div>
);
} else if (attachment.type === 'audio') {
const remoteURL = attachment.remote_url || '';
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
const fileExtension = remoteURL.slice(fileExtensionLastIndex + 1).toUpperCase();
thumbnail = (
<div className={clsx('⁂-media-gallery__item-thumbnail', { 'rounded-br-md': isLast })}>
<span className='⁂-media-gallery__item__icons'><Icon src={require('@phosphor-icons/core/regular/speaker-high.svg')} /></span>
<span className='⁂-media-gallery__file-extension__label'>{fileExtension}</span>
</div>
);
}
if (!visible) {
icon = (
<span className='⁂-media-gallery__item__icons'>
<Icon src={require('@phosphor-icons/core/regular/eye-slash.svg')} />
</span>
);
}
return (
<div className='col-span-1'>
<Link className='⁂-media-gallery__item-thumbnail aspect-1' to='/@{$username}/posts/$statusId' params={{ username: account?.acct || 'undefined', statusId: attachment.status_id }} onClick={handleClick} title={title}>
<Blurhash
hash={attachment.blurhash}
className={clsx('⁂-media-gallery__preview', {
'hidden': visible,
'rounded-br-md': isLast,
})}
aria-label={!visible ? attachment.description : undefined}
aria-hidden={visible}
/>
{visible && thumbnail}
{!visible && icon}
</Link>
</div>
);
};
const AccountGalleryPage = () => {
const { username } = profileMediaRoute.useParams();
const { openModal } = useModalsActions();
const {
account,
isLoading: accountLoading,
isUnavailable,
} = useAccountLookup(username, { withRelationship: true });
const { data: attachments, isFetching, isLoading, hasNextPage: hasMore, fetchNextPage } = useAccountGallery(account?.id!);
const handleScrollToBottom = () => {
if (hasMore) {
handleLoadMore();
}
};
const handleLoadMore = () => {
fetchNextPage({ cancelRefetch: false });
};
const handleLoadOlder: React.MouseEventHandler = e => {
e.preventDefault();
handleScrollToBottom();
};
const handleOpenMedia = (attachment: AccountGalleryAttachment) => {
openModal('MEDIA', { index: attachment.index, statusId: attachment.status_id });
};
if (accountLoading || isLoading) {
return (
<Column>
<Spinner />
</Column>
);
}
if (!account) {
return (
<MissingIndicator />
);
}
let loadOlder = null;
if (hasMore && !(isFetching && attachments.length === 0)) {
loadOlder = <LoadMore className='my-auto mt-4' visible={!isFetching} onClick={handleLoadOlder} />;
}
if (isUnavailable) {
return (
<Column>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
</div>
</Column>
);
}
return (
<Column label={`@${account.acct}`} transparent withHeader={false}>
<div role='feed' className='grid grid-cols-2 gap-1 overflow-hidden rounded-md sm:grid-cols-3'>
{attachments.map((attachment, index) => (
<MediaItem
key={`${attachment.status_id}+${attachment.id}`}
attachment={attachment}
onOpenMedia={handleOpenMedia}
isLast={index === attachments.length - 1}
/>
))}
{!isLoading && attachments.length === 0 && (
<div className='empty-column-indicator col-span-2 sm:col-span-3'>
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
</div>
)}
</div>
{loadOlder}
{isFetching && attachments.length === 0 && (
<div className='relative flex-auto px-8 py-4'>
<Spinner />
</div>
)}
</Column>
);
};
export { AccountGalleryPage as default, MediaItem };