pl-fe: migrate account gallery to react query

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-05-05 18:08:49 +02:00
parent 0b0f161036
commit fa8ec1d62f
11 changed files with 125 additions and 167 deletions

View File

@ -11,7 +11,7 @@ import { setComposeToStatus } from './compose';
import { importEntities } from './importer';
import { deleteFromTimelines } from './timelines';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus } from 'pl-api';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus, StatusSource } from 'pl-api';
import type { Status } from 'pl-fe/normalizers/status';
import type { AppDispatch, RootState } from 'pl-fe/store';
import type { IntlShape } from 'react-intl';
@ -128,7 +128,7 @@ const deleteStatus = (statusId: string, groupId?: string, withRedraft = false) =
dispatch(deleteFromTimelines(statusId));
if (withRedraft) {
dispatch(setComposeToStatus(status, poll, response.text || '', response.spoiler_text, response.content_type, withRedraft));
dispatch(setComposeToStatus(status, poll, response.text || '', response.spoiler_text, (response as StatusSource).content_type, withRedraft));
useModalsStore.getState().openModal('COMPOSE');
}
})

View File

@ -2,13 +2,14 @@ import clsx from 'clsx';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAccount } from 'pl-fe/api/hooks/accounts/use-account';
import Blurhash from 'pl-fe/components/blurhash';
import Icon from 'pl-fe/components/icon';
import StillImage from 'pl-fe/components/still-image';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { isIOS } from 'pl-fe/is-mobile';
import type { AccountGalleryAttachment } from 'pl-fe/selectors';
import type { AccountGalleryAttachment } from 'pl-fe/hooks/use-account-gallery';
interface IMediaItem {
attachment: AccountGalleryAttachment;
@ -18,7 +19,8 @@ interface IMediaItem {
const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia, isLast }) => {
const { autoPlayGif, displayMedia } = useSettings();
const [visible, setVisible] = useState<boolean>(displayMedia !== 'hide_all' && !attachment.status?.sensitive || displayMedia === 'show_all');
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;
@ -49,8 +51,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia, isLast }) =>
}
};
const status = attachment.status;
const title = status.spoiler_text || attachment.description;
const title = attachment.description;
let thumbnail: React.ReactNode = '';
let icon;
@ -119,7 +120,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia, isLast }) =>
return (
<div className='col-span-1'>
<Link className='media-gallery__item-thumbnail aspect-1' to={`/@${status.account.acct}/posts/${status.id}`} onClick={handleClick} title={title}>
<Link className='media-gallery__item-thumbnail aspect-1' to={`/@${account?.acct}/posts/${attachment.status_id}`} onClick={handleClick} title={title}>
<Blurhash
hash={attachment.blurhash}
className={clsx('media-gallery__preview', {

View File

@ -1,22 +1,18 @@
import React, { useEffect } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { fetchAccountTimeline } from 'pl-fe/actions/timelines';
import { useAccountLookup } from 'pl-fe/api/hooks/accounts/use-account-lookup';
import LoadMore from 'pl-fe/components/load-more';
import MissingIndicator from 'pl-fe/components/missing-indicator';
import Column from 'pl-fe/components/ui/column';
import Spinner from 'pl-fe/components/ui/spinner';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { type AccountGalleryAttachment, getAccountGallery } from 'pl-fe/selectors';
import { type AccountGalleryAttachment, useAccountGallery } from 'pl-fe/hooks/use-account-gallery';
import { useModalsStore } from 'pl-fe/stores/modals';
import MediaItem from './components/media-item';
const AccountGallery = () => {
const dispatch = useAppDispatch();
const { username } = useParams<{ username: string }>();
const { openModal } = useModalsStore();
@ -26,9 +22,7 @@ const AccountGallery = () => {
isUnavailable,
} = useAccountLookup(username, { withRelationship: true });
const attachments: Array<AccountGalleryAttachment> = useAppSelector((state) => account ? getAccountGallery(state, account.id) : []);
const isLoading = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.isLoading);
const hasMore = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.hasMore);
const { data: attachments, isFetching, isLoading, hasNextPage: hasMore, fetchNextPage } = useAccountGallery(account?.id!);
const handleScrollToBottom = () => {
if (hasMore) {
@ -37,9 +31,7 @@ const AccountGallery = () => {
};
const handleLoadMore = () => {
if (account) {
dispatch(fetchAccountTimeline(account.id, { only_media: true }, true));
}
fetchNextPage({ cancelRefetch: false });
};
const handleLoadOlder: React.MouseEventHandler = e => {
@ -49,22 +41,13 @@ const AccountGallery = () => {
const handleOpenMedia = (attachment: AccountGalleryAttachment) => {
if (attachment.type === 'video') {
openModal('VIDEO', { media: attachment, statusId: attachment.status.id });
openModal('VIDEO', { media: attachment, statusId: attachment.status_id });
} else {
const media = attachment.status.media_attachments;
const index = media.findIndex((x) => x.id === attachment.id);
openModal('MEDIA', { media, index, statusId: attachment.status.id });
openModal('MEDIA', { index: attachment.index, statusId: attachment.status_id });
}
};
useEffect(() => {
if (account) {
dispatch(fetchAccountTimeline(account.id, { only_media: true, limit: 40 }));
}
}, [account?.id]);
if (accountLoading || (!attachments && isLoading)) {
if (accountLoading || isLoading) {
return (
<Column>
<Spinner />
@ -80,8 +63,8 @@ const AccountGallery = () => {
let loadOlder = null;
if (hasMore && !(isLoading && attachments.length === 0)) {
loadOlder = <LoadMore className='my-auto mt-4' visible={!isLoading} onClick={handleLoadOlder} />;
if (hasMore && !(isFetching && attachments.length === 0)) {
loadOlder = <LoadMore className='my-auto mt-4' visible={!isFetching} onClick={handleLoadOlder} />;
}
if (isUnavailable) {
@ -99,7 +82,7 @@ const AccountGallery = () => {
<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}`}
key={`${attachment.status_id}+${attachment.id}`}
attachment={attachment}
onOpenMedia={handleOpenMedia}
isLast={index === attachments.length - 1}
@ -115,7 +98,7 @@ const AccountGallery = () => {
{loadOlder}
{isLoading && attachments.length === 0 && (
{isFetching && attachments.length === 0 && (
<div className='relative flex-auto px-8 py-4'>
<Spinner />
</div>

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchGroupTimeline } from 'pl-fe/actions/timelines';
import Spinner from 'pl-fe/components/ui/spinner';
import Text from 'pl-fe/components/ui/text';
import Widget from 'pl-fe/components/ui/widget';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { type AccountGalleryAttachment, getGroupGallery } from 'pl-fe/selectors';
import { type AccountGalleryAttachment, useGroupGallery } from 'pl-fe/hooks/use-account-gallery';
import { useModalsStore } from 'pl-fe/stores/modals';
import MediaItem from '../../../account-gallery/components/media-item';
@ -15,42 +12,22 @@ import MediaItem from '../../../account-gallery/components/media-item';
import type { Group } from 'pl-fe/normalizers/group';
interface IGroupMediaPanel {
group?: Group;
group: Group;
}
const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
const dispatch = useAppDispatch();
const { openModal } = useModalsStore();
const [loading, setLoading] = useState(true);
const isMember = !!group?.relationship?.member;
const isPrivate = group?.locked;
const attachments: Array<AccountGalleryAttachment> = useAppSelector((state) => group ? getGroupGallery(state, group?.id) : []);
const { data: attachments, isLoading } = useGroupGallery(group.id);
const handleOpenMedia = (attachment: AccountGalleryAttachment): void => {
if (attachment.type === 'video') {
openModal('VIDEO', { media: attachment, statusId: attachment.status.id });
openModal('VIDEO', { media: attachment, statusId: attachment.status_id });
} else {
const media = attachment.status.media_attachments;
const index = media.findIndex(x => x.id === attachment.id);
openModal('MEDIA', { media, index, statusId: attachment.status.id });
openModal('MEDIA', { index: attachment.index, statusId: attachment.status_id });
}
};
useEffect(() => {
setLoading(true);
if (group && (isMember || !isPrivate)) {
dispatch(fetchGroupTimeline(group.id, { only_media: true, limit: 40 }))
// @ts-ignore
.then(() => setLoading(false))
.catch(() => {});
}
}, [group?.id, isMember, isPrivate]);
const renderAttachments = () => {
const nineAttachments = attachments.slice(0, 9);
@ -59,7 +36,7 @@ const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
<div className='grid grid-cols-3 gap-0.5 overflow-hidden rounded-md'>
{nineAttachments.map((attachment, index) => (
<MediaItem
key={`${attachment.status.id}+${attachment.id}`}
key={`${attachment.status_id}+${attachment.id}`}
attachment={attachment}
onOpenMedia={handleOpenMedia}
isLast={index === nineAttachments.length - 1}
@ -76,15 +53,11 @@ const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
}
};
if (isPrivate && !isMember) {
return null;
}
return (
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
{group && (
<div className='w-full'>
{loading ? (
{isLoading ? (
<Spinner />
) : (
renderAttachments()

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchAccountTimeline } from 'pl-fe/actions/timelines';
import Spinner from 'pl-fe/components/ui/spinner';
import Text from 'pl-fe/components/ui/text';
import Widget from 'pl-fe/components/ui/widget';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { type AccountGalleryAttachment, getAccountGallery } from 'pl-fe/selectors';
import { type AccountGalleryAttachment, useAccountGallery } from 'pl-fe/hooks/use-account-gallery';
import { useModalsStore } from 'pl-fe/stores/modals';
import MediaItem from '../../../account-gallery/components/media-item';
@ -19,37 +16,21 @@ interface IProfileMediaPanel {
}
const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
const dispatch = useAppDispatch();
const { openModal } = useModalsStore();
const [loading, setLoading] = useState(true);
const attachments: Array<AccountGalleryAttachment> = useAppSelector((state) => account ? getAccountGallery(state, account?.id) : []);
const { data: attachments, isLoading } = useAccountGallery(account?.id!);
const handleOpenMedia = (attachment: AccountGalleryAttachment): void => {
if (attachment.type === 'video') {
openModal('VIDEO', { media: attachment, statusId: attachment.status.id });
openModal('VIDEO', { media: attachment, statusId: attachment.status_id });
} else {
const media = attachment.status.media_attachments;
const index = media.findIndex(x => x.id === attachment.id);
openModal('MEDIA', { media, index, statusId: attachment.status.id });
openModal('MEDIA', { index: attachment.index, statusId: attachment.status_id });
}
};
useEffect(() => {
setLoading(true);
if (account) {
dispatch(fetchAccountTimeline(account.id, { only_media: true, limit: 40 }))
// @ts-ignore yes it does
.then(() => setLoading(false))
.catch(() => {});
}
}, [account?.id]);
const renderAttachments = () => {
const publicAttachments = attachments.filter(attachment => attachment.status.visibility === 'public');
const publicAttachments = attachments.filter(attachment => attachment.visibility === 'public');
const nineAttachments = publicAttachments.slice(0, 9);
if (nineAttachments.length) {
@ -57,7 +38,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
<div className='grid grid-cols-3 gap-0.5 overflow-hidden rounded-md'>
{nineAttachments.map((attachment, index) => (
<MediaItem
key={`${attachment.status.id}+${attachment.id}`}
key={`${attachment.status_id}+${attachment.id}`}
attachment={attachment}
onOpenMedia={handleOpenMedia}
isLast={index === nineAttachments.length - 1}
@ -76,15 +57,13 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
return (
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
{account && (
<div className='w-full'>
{loading ? (
<Spinner />
) : (
renderAttachments()
)}
</div>
)}
<div className='w-full'>
{isLoading || !account ? (
<Spinner />
) : (
renderAttachments()
)}
</div>
</Widget>
);
};

View File

@ -0,0 +1,58 @@
import { createSelector } from 'reselect';
import { useAccountMediaTimeline, useGroupMediaTimeline } from 'pl-fe/queries/timelines/use-account-media-timeline';
import { useAppSelector } from './use-app-selector';
import type { MediaAttachment } from 'pl-api';
import type { RootState } from 'pl-fe/store';
type AccountGalleryAttachment = MediaAttachment & {
index: number;
sensitive: boolean;
visibility: string;
status_id: string;
account_id: string;
}
const getGallery = createSelector([
(state: RootState, statusIds: string[]) => statusIds,
(state: RootState) => state.statuses,
], (statusIds, statuses) =>
statusIds.reduce((medias: Array<AccountGalleryAttachment>, statusId: string) => {
const status = statuses[statusId];
if (!status) return medias;
if (status.reblog_id) return medias;
return medias.concat(
status.media_attachments.map((media, index) => ({
...media,
index,
sensitive: status.sensitive,
visibility: status.visibility,
status_id: statusId,
account_id: status.account.id,
})));
}, []),
);
const useAccountGallery = (accountId: string) => {
const result = useAccountMediaTimeline(accountId);
return {
...result,
data: useAppSelector((state) => getGallery(state, result.data || [])),
};
};
const useGroupGallery = (groupId: string) => {
const result = useGroupMediaTimeline(groupId);
return {
...result,
data: useAppSelector((state) => getGallery(state, result.data || [])),
};
};
export { useAccountGallery, useGroupGallery, type AccountGalleryAttachment };

View File

@ -116,7 +116,9 @@ const GroupLayout: React.FC<IGroupLayout> = ({ params, children }) => {
{!me && (
<SignUpPanel />
)}
<GroupMediaPanel group={group} />
{group && (group.relationship?.member || !group.locked) && (
<GroupMediaPanel group={group} />
)}
<LinkFooter />
</Layout.Aside>
</>

View File

@ -47,7 +47,7 @@ const containerStyle: React.CSSProperties = {
};
interface MediaModalProps {
media: Array<MediaAttachment>;
media?: Array<MediaAttachment>;
statusId?: string;
index: number;
time?: number;
@ -55,7 +55,6 @@ interface MediaModalProps {
const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const {
media,
statusId,
onClose,
time = 0,
@ -66,6 +65,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector((state) => statusId ? getStatus(state, { id: statusId }) : undefined);
const media = status?.media_attachments || props.media || [];
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [index, setIndex] = useState<number | null>(null);

View File

@ -2,16 +2,14 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useGroup } from 'pl-fe/api/hooks/groups/use-group';
import { useGroupMedia } from 'pl-fe/api/hooks/groups/use-group-media';
import LoadMore from 'pl-fe/components/load-more';
import MissingIndicator from 'pl-fe/components/missing-indicator';
import Column from 'pl-fe/components/ui/column';
import Spinner from 'pl-fe/components/ui/spinner';
import MediaItem from 'pl-fe/features/account-gallery/components/media-item';
import { type AccountGalleryAttachment, useGroupGallery } from 'pl-fe/hooks/use-account-gallery';
import { useModalsStore } from 'pl-fe/stores/modals';
import type { Status } from 'pl-fe/normalizers/status';
import type { AccountGalleryAttachment } from 'pl-fe/selectors';
interface IGroupGallery {
params: { groupId: string };
@ -24,27 +22,13 @@ const GroupGallery: React.FC<IGroupGallery> = (props) => {
const { group, isLoading: groupIsLoading } = useGroup(groupId);
const {
entities: statuses,
fetchNextPage,
isLoading,
isFetching,
hasNextPage,
} = useGroupMedia(groupId);
const attachments = statuses.reduce<AccountGalleryAttachment[]>((result, status) => {
result.push(...status.media_attachments.map((a) => ({ ...a, status, account: status.account })));
return result;
}, []);
const { data: attachments, isFetching, isLoading, hasNextPage, fetchNextPage } = useGroupGallery(groupId);
const handleOpenMedia = (attachment: AccountGalleryAttachment) => {
if (attachment.type === 'video') {
openModal('VIDEO', { media: attachment, statusId: attachment.status.id });
openModal('VIDEO', { media: attachment, statusId: attachment.status_id });
} else {
const media = (attachment.status as Status).media_attachments;
const index = media.findIndex((x) => x.id === attachment.id);
openModal('MEDIA', { media, index, statusId: attachment.status.id });
openModal('MEDIA', { index: attachment.index, statusId: attachment.status_id });
}
};
@ -71,7 +55,7 @@ const GroupGallery: React.FC<IGroupGallery> = (props) => {
<div role='feed' className='mt-4 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}`}
key={`${attachment.status_id}+${attachment.id}`}
attachment={attachment}
onOpenMedia={handleOpenMedia}
isLast={index === attachments.length - 1}
@ -86,7 +70,7 @@ const GroupGallery: React.FC<IGroupGallery> = (props) => {
</div>
{hasNextPage && (
<LoadMore className='mt-4' disabled={isFetching} onClick={fetchNextPage} />
<LoadMore className='mt-4' disabled={isFetching} onClick={() => fetchNextPage({ cancelRefetch: false })} />
)}
</Column>
);

View File

@ -0,0 +1,14 @@
import { makePaginatedResponseQuery } from '../utils/make-paginated-response-query';
import { minifyStatusList } from '../utils/minify-list';
const useAccountMediaTimeline = makePaginatedResponseQuery(
(accountId: string) => ['timelineIds', `account:${accountId}:with_replies:media`],
(client, [accountId]) => client.accounts.getAccountStatuses(accountId!, { only_media: true }).then(minifyStatusList),
);
const useGroupMediaTimeline = makePaginatedResponseQuery(
(groupId: string) => ['timelineIds', `group:${groupId}:media`],
(client, [groupId]) => client.timelines.groupTimeline(groupId!, { only_media: true }).then(minifyStatusList),
);
export { useAccountMediaTimeline, useGroupMediaTimeline };

View File

@ -8,7 +8,7 @@ import { validId } from 'pl-fe/utils/auth';
import ConfigDB from 'pl-fe/utils/config-db';
import { shouldFilter } from 'pl-fe/utils/timelines';
import type { Account as BaseAccount, Filter, MediaAttachment, NotificationGroup, Relationship } from 'pl-api';
import type { Filter, NotificationGroup, Relationship } from 'pl-api';
import type { EntityStore } from 'pl-fe/entity-store/types';
import type { Account } from 'pl-fe/normalizers/account';
import type { Group } from 'pl-fe/normalizers/group';
@ -199,39 +199,6 @@ type SelectedNotification = NotificationGroup & {
target: Account;
})
type AccountGalleryAttachment = MediaAttachment & {
status: MinifiedStatus;
account: BaseAccount;
}
const getAccountGallery = createSelector([
(state: RootState, id: string) => state.timelines[`account:${id}:with_replies:media`]?.items || [],
(state: RootState) => state.statuses,
], (statusIds, statuses) =>
statusIds.reduce((medias: Array<AccountGalleryAttachment>, statusId: string) => {
const status = statuses[statusId];
if (!status) return medias;
if (status.reblog_id) return medias;
return medias.concat(
status.media_attachments.map(media => ({ ...media, status, account: status.account })));
}, []),
);
const getGroupGallery = createSelector([
(state: RootState, id: string) => state.timelines[`group:${id}:media`]?.items || [],
(state: RootState) => state.statuses,
], (statusIds, statuses) =>
statusIds.reduce((medias: Array<AccountGalleryAttachment>, statusId: string) => {
const status = statuses[statusId];
if (!status) return medias;
if (status.reblog_id) return medias;
return medias.concat(
status.media_attachments.map(media => ({ ...media, status, account: status.account })));
}, []),
);
const makeGetReport = () => {
const getStatus = makeGetStatus();
@ -354,9 +321,6 @@ export {
type SelectedStatus,
makeGetNotification,
type SelectedNotification,
type AccountGalleryAttachment,
getAccountGallery,
getGroupGallery,
makeGetReport,
makeGetOtherAccounts,
makeGetHosts,