Compare commits
4 commits
085ca57070
...
2e91787986
Author | SHA1 | Date | |
---|---|---|---|
2e91787986 | |||
c979cf45dc | |||
428e116fc3 | |||
ee444a746f |
26 changed files with 558 additions and 807 deletions
|
@ -1,8 +1,5 @@
|
|||
import type { StatusesAction } from './statuses';
|
||||
import type { TimelineAction } from './timelines';
|
||||
|
||||
type ActionType =
|
||||
| StatusesAction
|
||||
| TimelineAction;
|
||||
type ActionType = StatusesAction;
|
||||
|
||||
export type { ActionType };
|
||||
|
|
|
@ -5,14 +5,7 @@ import client from 'bigbuffet/client';
|
|||
import type { AppDispatch } from 'bigbuffet/store';
|
||||
import type { Status as BaseStatus } from 'pl-api';
|
||||
|
||||
const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST' as const;
|
||||
const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS' as const;
|
||||
const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL' as const;
|
||||
|
||||
interface FetchContextRequestAction {
|
||||
type: typeof CONTEXT_FETCH_REQUEST;
|
||||
statusId: string;
|
||||
}
|
||||
|
||||
interface FetchContextSuccessAction {
|
||||
type: typeof CONTEXT_FETCH_SUCCESS;
|
||||
|
@ -21,40 +14,21 @@ interface FetchContextSuccessAction {
|
|||
descendants: Array<BaseStatus>;
|
||||
}
|
||||
|
||||
interface FetchContextFailAction {
|
||||
type: typeof CONTEXT_FETCH_FAIL;
|
||||
statusId: string;
|
||||
error: unknown;
|
||||
skipAlert: true;
|
||||
}
|
||||
|
||||
const fetchContext = (statusId: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
const action: FetchContextRequestAction = { type: CONTEXT_FETCH_REQUEST, statusId };
|
||||
(dispatch: AppDispatch) => client.statuses.getContext(statusId).then(context => {
|
||||
const { ancestors, descendants } = context;
|
||||
const statuses = ancestors.concat(descendants);
|
||||
importEntities({ statuses });
|
||||
const action: FetchContextSuccessAction = { type: CONTEXT_FETCH_SUCCESS, statusId, ancestors, descendants };
|
||||
dispatch(action);
|
||||
|
||||
return client.statuses.getContext(statusId).then(context => {
|
||||
const { ancestors, descendants } = context;
|
||||
const statuses = ancestors.concat(descendants);
|
||||
importEntities({ statuses });
|
||||
const action: FetchContextSuccessAction = { type: CONTEXT_FETCH_SUCCESS, statusId, ancestors, descendants };
|
||||
dispatch(action);
|
||||
return context;
|
||||
}).catch(error => {
|
||||
const action: FetchContextFailAction = { type: CONTEXT_FETCH_FAIL, statusId, error, skipAlert: true };
|
||||
dispatch(action);
|
||||
});
|
||||
};
|
||||
return context;
|
||||
});
|
||||
|
||||
type StatusesAction =
|
||||
| FetchContextRequestAction
|
||||
| FetchContextSuccessAction
|
||||
| FetchContextFailAction;
|
||||
|
||||
export {
|
||||
CONTEXT_FETCH_REQUEST,
|
||||
CONTEXT_FETCH_SUCCESS,
|
||||
CONTEXT_FETCH_FAIL,
|
||||
fetchContext,
|
||||
type StatusesAction,
|
||||
};
|
||||
|
|
|
@ -1,148 +0,0 @@
|
|||
import { importEntities } from 'pl-hooks';
|
||||
|
||||
import client from 'bigbuffet/client';
|
||||
|
||||
import type { AppDispatch, RootState } from 'bigbuffet/store';
|
||||
import type {
|
||||
Account as BaseAccount,
|
||||
GetAccountStatusesParams,
|
||||
PaginatedResponse,
|
||||
PublicTimelineParams,
|
||||
Status as BaseStatus,
|
||||
} from 'pl-api';
|
||||
|
||||
const TIMELINE_CLEAR = 'TIMELINE_CLEAR' as const;
|
||||
|
||||
const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST' as const;
|
||||
const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS' as const;
|
||||
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const;
|
||||
|
||||
const clearTimeline = (timeline: string) => ({ type: TIMELINE_CLEAR, timeline });
|
||||
|
||||
const noOp = () => { };
|
||||
|
||||
const deduplicateStatuses = (statuses: Array<BaseStatus>) => {
|
||||
const deduplicatedStatuses: Array<BaseStatus & { accounts?: Array<BaseAccount>}> = [];
|
||||
|
||||
for (const status of statuses) {
|
||||
const reblogged = status.reblog && deduplicatedStatuses.find((deduplicatedStatuses) => deduplicatedStatuses.reblog?.id === status.reblog?.id);
|
||||
|
||||
if (reblogged) {
|
||||
if (reblogged.accounts) {
|
||||
reblogged.accounts.push(status.account);
|
||||
} else {
|
||||
reblogged.accounts = [reblogged.account, status.account];
|
||||
}
|
||||
reblogged.id += ':' + status.id;
|
||||
} else if (!deduplicatedStatuses.find((deduplicatedStatus) => deduplicatedStatus.reblog?.id === status.id)) {
|
||||
deduplicatedStatuses.push(status);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicatedStatuses;
|
||||
};
|
||||
|
||||
const handleTimelineExpand = (timelineId: string, fn: Promise<PaginatedResponse<BaseStatus>>, isLoadingRecent: boolean, done = noOp) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch(expandTimelineRequest(timelineId));
|
||||
|
||||
return fn.then(response => {
|
||||
const statuses = deduplicateStatuses(response.items);
|
||||
|
||||
importEntities({ statuses: [...response.items, ...statuses.filter(status => status.accounts)] });
|
||||
|
||||
dispatch(expandTimelineSuccess(
|
||||
timelineId,
|
||||
statuses,
|
||||
response.next,
|
||||
response.previous,
|
||||
response.partial,
|
||||
isLoadingRecent,
|
||||
));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error));
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPublicTimeline = ({ onlyMedia, local, instance }: Record<string, any> = {}, expand = false, done = noOp) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const timelineId = `${instance ? 'remote' : 'public'}${local ? ':local' : ''}${onlyMedia ? ':media' : ''}${instance ? `:${instance}` : ''}`;
|
||||
|
||||
const params: PublicTimelineParams = { only_media: onlyMedia, local: instance ? false : local, instance };
|
||||
|
||||
const fn = (expand && state.timelines[timelineId]?.next?.()) || client.timelines.publicTimeline(params);
|
||||
|
||||
return dispatch(handleTimelineExpand(timelineId, fn, false, done));
|
||||
};
|
||||
|
||||
const fetchAccountTimeline = (accountId: string, { exclude_replies, pinned, only_media, limit, tagged }: GetAccountStatusesParams = {}, expand = false, done = noOp) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const timelineId = `account:${accountId}${!exclude_replies ? ':with_replies' : ''}${pinned ? ':pinned' : only_media ? ':media' : ''}${tagged ? `:hashtag:${tagged}` : ''}`;
|
||||
|
||||
const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit, tagged };
|
||||
if (pinned || only_media) params.with_muted = true;
|
||||
|
||||
const fn = (expand && state.timelines[timelineId]?.next?.()) || client.accounts.getAccountStatuses(accountId, params);
|
||||
|
||||
return dispatch(handleTimelineExpand(timelineId, fn, false, done));
|
||||
};
|
||||
|
||||
const fetchHashtagTimeline = (hashtag: string, { tags }: Record<string, any> = {}, expand = false, done = noOp) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const timelineId = `hashtag:${hashtag}`;
|
||||
|
||||
const fn = (expand && state.timelines[timelineId]?.next?.()) || client.timelines.hashtagTimeline(hashtag);
|
||||
|
||||
return dispatch(handleTimelineExpand(timelineId, fn, false, done));
|
||||
};
|
||||
|
||||
const expandTimelineRequest = (timeline: string) => ({
|
||||
type: TIMELINE_EXPAND_REQUEST,
|
||||
timeline,
|
||||
});
|
||||
|
||||
const expandTimelineSuccess = (
|
||||
timeline: string,
|
||||
statuses: Array<BaseStatus>,
|
||||
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
|
||||
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
|
||||
partial: boolean,
|
||||
isLoadingRecent: boolean,
|
||||
) => ({
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline,
|
||||
statuses,
|
||||
next,
|
||||
prev,
|
||||
partial,
|
||||
isLoadingRecent,
|
||||
});
|
||||
|
||||
const expandTimelineFail = (timeline: string, error: unknown) => ({
|
||||
type: TIMELINE_EXPAND_FAIL,
|
||||
timeline,
|
||||
error,
|
||||
});
|
||||
|
||||
type TimelineAction =
|
||||
| ReturnType<typeof clearTimeline>
|
||||
| ReturnType<typeof expandTimelineRequest>
|
||||
| ReturnType<typeof expandTimelineSuccess>
|
||||
| ReturnType<typeof expandTimelineFail>
|
||||
|
||||
export {
|
||||
TIMELINE_CLEAR,
|
||||
TIMELINE_EXPAND_REQUEST,
|
||||
TIMELINE_EXPAND_SUCCESS,
|
||||
TIMELINE_EXPAND_FAIL,
|
||||
clearTimeline,
|
||||
fetchPublicTimeline,
|
||||
fetchAccountTimeline,
|
||||
fetchHashtagTimeline,
|
||||
type TimelineAction,
|
||||
};
|
|
@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom-v5-compat';
|
|||
|
||||
import Icon from 'bigbuffet/components/icon';
|
||||
import Input from 'bigbuffet/components/ui/input';
|
||||
import { useShadowRoot } from 'bigbuffet/shadow-root';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
|
@ -21,6 +22,7 @@ const SearchBar = ({
|
|||
}: ISearchBar) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const shadowRoot = useShadowRoot();
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
|
@ -48,7 +50,7 @@ const SearchBar = ({
|
|||
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
shadowRoot.querySelector('.ui')?.parentElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,163 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import LoadGap from 'bigbuffet/components/load-gap';
|
||||
import PlaceholderStatus from 'bigbuffet/components/placeholders/placeholder-status';
|
||||
import ScrollableList from 'bigbuffet/components/scrollable-list';
|
||||
import StatusContainer from 'bigbuffet/containers/status-container';
|
||||
|
||||
import type { IScrollableList } from 'bigbuffet/components/scrollable-list';
|
||||
|
||||
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||
/** List of status IDs to display. */
|
||||
statusIds: Array<string>;
|
||||
/** Last _unfiltered_ status ID (maxId) for pagination. */
|
||||
lastStatusId?: string;
|
||||
/** Pinned statuses to show at the top of the feed. */
|
||||
featuredStatusIds?: Array<string>;
|
||||
/** Pagination callback when the end of the list is reached. */
|
||||
onLoadMore?: (lastStatusId: string) => void;
|
||||
/** Whether the data is currently being fetched. */
|
||||
isLoading: boolean;
|
||||
/** ID of the timeline in Redux. */
|
||||
timelineId?: string;
|
||||
/** Whether to display a gap or border between statuses in the list. */
|
||||
divideType?: 'space' | 'border';
|
||||
}
|
||||
|
||||
/** Feed of statuses, built atop ScrollableList. */
|
||||
const StatusList: React.FC<IStatusList> = ({
|
||||
statusIds,
|
||||
lastStatusId,
|
||||
featuredStatusIds,
|
||||
divideType = 'border',
|
||||
onLoadMore,
|
||||
timelineId,
|
||||
isLoading,
|
||||
...other
|
||||
}) => {
|
||||
const getFeaturedStatusCount = () => featuredStatusIds?.length || 0;
|
||||
|
||||
const getCurrentStatusIndex = (id: string, featured: boolean): number => {
|
||||
if (featured) {
|
||||
return featuredStatusIds?.findIndex(key => key === id) || 0;
|
||||
} else {
|
||||
return statusIds.findIndex(key => key === id) + getFeaturedStatusCount();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = (id: string, featured: boolean = false) => {
|
||||
const elementIndex = getCurrentStatusIndex(id, featured) - 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string, featured: boolean = false) => {
|
||||
const elementIndex = getCurrentStatusIndex(id, featured) + 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const handleLoadOlder = useCallback(debounce(() => {
|
||||
const maxId = statusIds.at(-1);
|
||||
if (onLoadMore && maxId) {
|
||||
onLoadMore(maxId.replace('末suggestions-', ''));
|
||||
}
|
||||
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds]);
|
||||
|
||||
const selectChild = (index: number) => {
|
||||
const selector = `#status-list [data-index="${index}"] .focusable`;
|
||||
const element = document.querySelector<HTMLDivElement>(selector);
|
||||
|
||||
if (element) element.focus();
|
||||
};
|
||||
|
||||
const renderLoadGap = (index: number) => {
|
||||
const ids = statusIds;
|
||||
const nextId = ids[index + 1];
|
||||
const prevId = ids[index - 1];
|
||||
|
||||
if (index < 1 || !nextId || !prevId || !onLoadMore) return null;
|
||||
|
||||
return (
|
||||
<LoadGap
|
||||
key={'gap:' + nextId}
|
||||
disabled={isLoading}
|
||||
maxId={prevId!}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (statusId: string) => (
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFeaturedStatuses = (): React.ReactNode[] => {
|
||||
if (!featuredStatusIds) return [];
|
||||
|
||||
return featuredStatusIds.map(statusId => (
|
||||
<StatusContainer
|
||||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const renderStatuses = (): React.ReactNode[] => {
|
||||
if (isLoading || statusIds.length > 0) {
|
||||
return statusIds.reduce((acc, statusId, index) => {
|
||||
if (statusId === null) {
|
||||
const gap = renderLoadGap(index);
|
||||
if (gap) {
|
||||
acc.push(gap);
|
||||
}
|
||||
} else {
|
||||
acc.push(renderStatus(statusId));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as React.ReactNode[]);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const renderScrollableContent = () => {
|
||||
const featuredStatuses = renderFeaturedStatuses();
|
||||
const statuses = renderStatuses();
|
||||
|
||||
if (featuredStatuses && statuses) {
|
||||
return featuredStatuses.concat(statuses);
|
||||
} else {
|
||||
return statuses;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
id='status-list'
|
||||
key='scrollable-list'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && statusIds.length === 0}
|
||||
onLoadMore={handleLoadOlder}
|
||||
placeholderComponent={PlaceholderStatus}
|
||||
placeholderCount={20}
|
||||
className={clsx(divideType === 'border' && 'status-list')}
|
||||
itemClassName={clsx(divideType !== 'border' && 'status-list__item')}
|
||||
{...other}
|
||||
>
|
||||
{renderScrollableContent()}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusList;
|
||||
export type { IStatusList };
|
|
@ -1,8 +1,10 @@
|
|||
import { useQueries, UseQueryOptions } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { useStatus } from 'pl-hooks';
|
||||
import { Account } from 'pl-api';
|
||||
import { usePlHooksQueryClient, useStatus } from 'pl-hooks';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { NavLink, useHistory } from 'react-router-dom';
|
||||
import { useIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import Emojify from 'bigbuffet/components/emojify';
|
||||
import Icon from 'bigbuffet/components/icon';
|
||||
|
@ -25,13 +27,62 @@ import StatusReplyMentions from './status-reply-mentions';
|
|||
|
||||
import type { UseStatusData as StatusEntity } from 'pl-hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
|
||||
});
|
||||
interface IRebloggedBy {
|
||||
rebloggedBy: Array<string>;
|
||||
}
|
||||
|
||||
const RebloggedBy: React.FC<IRebloggedBy> = ({ rebloggedBy }) => {
|
||||
const queryClient = usePlHooksQueryClient();
|
||||
|
||||
const accounts = useQueries<UseQueryOptions<Account>[]>({
|
||||
queries: rebloggedBy.map(accountId => ({
|
||||
queryKey: ['accounts', 'entities', accountId],
|
||||
})),
|
||||
}, queryClient);
|
||||
|
||||
const renderedAccounts = accounts.slice(0, 2).map(({ data: account }) => !!account && (
|
||||
<Link key={account.acct} to={`/@${account.acct}`}>
|
||||
<bdi>
|
||||
<strong>
|
||||
<Emojify text={account.display_name} emojis={account.emojis} />
|
||||
</strong>
|
||||
</bdi>
|
||||
</Link>
|
||||
));
|
||||
|
||||
if (accounts.length > 2) {
|
||||
renderedAccounts.push(
|
||||
<FormattedMessage
|
||||
id='notification.more'
|
||||
defaultMessage='{count, plural, one {# other} other {# others}}'
|
||||
values={{ count: accounts.length - renderedAccounts.length }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='status__reblog-element'>
|
||||
<div className='status__reblog-element__icon'>
|
||||
<Icon icon='reblog' />
|
||||
</div>
|
||||
|
||||
<div className='status__reblog-element__text'>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: <FormattedList type='conjunction' value={renderedAccounts} />,
|
||||
count: accounts.length,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IStatus {
|
||||
id?: string;
|
||||
status: StatusEntity;
|
||||
rebloggedBy?: Array<string>;
|
||||
onMoveUp?: (statusId: string, featured?: boolean) => void;
|
||||
onMoveDown?: (statusId: string, featured?: boolean) => void;
|
||||
focusable?: boolean;
|
||||
|
@ -45,12 +96,13 @@ export interface IStatus {
|
|||
const Status: React.FC<IStatus> = (props) => {
|
||||
const {
|
||||
status,
|
||||
rebloggedBy,
|
||||
focusable = true,
|
||||
hoverable = true,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
featured,
|
||||
variant = 'rounded',
|
||||
variant = 'default',
|
||||
thread,
|
||||
} = props;
|
||||
|
||||
|
@ -137,66 +189,14 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
};
|
||||
|
||||
if (!status) return null;
|
||||
let rebloggedByText, reblogElement, reblogElementMobile;
|
||||
let rebloggedByText, reblogElement;
|
||||
|
||||
if (status.reblog && typeof status.reblog === 'object') {
|
||||
reblogElement = (
|
||||
<NavLink
|
||||
to={`/@${status.account.acct}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='status__reblog-element'
|
||||
>
|
||||
<Icon icon='reblog' />
|
||||
|
||||
<div className='status__reblog-element__text'>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: (
|
||||
<bdi>
|
||||
<strong>
|
||||
<Emojify text={status.account.display_name} emojis={status.account.emojis} />
|
||||
</strong>
|
||||
</bdi>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
reblogElementMobile = (
|
||||
<div className='status__reblog-element-mobile'>
|
||||
<NavLink
|
||||
to={`/@${status.account.acct}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon icon='reblog' />
|
||||
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: (
|
||||
<bdi>
|
||||
<strong>
|
||||
<Emojify text={status.account.display_name} emojis={status.account.emojis} />
|
||||
</strong>
|
||||
</bdi>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
rebloggedByText = intl.formatMessage(
|
||||
messages.reblogged_by,
|
||||
{ name: String(status.account.acct) },
|
||||
);
|
||||
if (rebloggedBy?.length) {
|
||||
reblogElement = <RebloggedBy rebloggedBy={rebloggedBy} />;
|
||||
// rebloggedByText = intl.formatMessage(
|
||||
// messages.reblogged_by,
|
||||
// { name: String(status.account.acct) },
|
||||
// );
|
||||
}
|
||||
|
||||
let quote;
|
||||
|
@ -222,8 +222,6 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
openMedia: handleHotkeyOpenMedia,
|
||||
};
|
||||
|
||||
const accountAction = props.accountAction || reblogElement;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
<div
|
||||
|
@ -253,7 +251,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
})}
|
||||
data-id={status.id}
|
||||
>
|
||||
{reblogElementMobile}
|
||||
{reblogElement}
|
||||
|
||||
<div className='status__account'>
|
||||
<AccountContainer
|
||||
|
@ -261,8 +259,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
id={String(actualStatus.account.id)}
|
||||
timestamp={actualStatus.created_at}
|
||||
timestampUrl={actualStatus.url}
|
||||
action={accountAction}
|
||||
hideActions={!accountAction}
|
||||
hideActions
|
||||
showEdit={actualStatus.edited_at ? actualStatus.id : false}
|
||||
showAccountHoverCard={hoverable}
|
||||
withLinkToProfile={hoverable}
|
||||
|
|
|
@ -18,8 +18,8 @@ interface IThreadStatus {
|
|||
const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
||||
const { id, focusedStatusId, variant = 'default' } = props;
|
||||
|
||||
const replyToId = useAppSelector(state => state.contexts.inReplyTos[id]);
|
||||
const replyCount = useAppSelector(state => state.contexts.replies[id]?.length || 0);
|
||||
const replyToId = useAppSelector(state => state.inReplyTos[id]);
|
||||
const replyCount = useAppSelector(state => state.replies[id]?.length || 0);
|
||||
const isLoaded = useStatus(id).isSuccess;
|
||||
|
||||
const renderConnector = (): JSX.Element | null => {
|
||||
|
|
|
@ -23,7 +23,7 @@ import type { UseStatusData as Status } from 'pl-hooks';
|
|||
|
||||
export const getAncestorsIds = createSelector([
|
||||
(_: RootState, statusId: string | undefined) => statusId,
|
||||
(state: RootState) => state.contexts.inReplyTos,
|
||||
(state: RootState) => state.inReplyTos,
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = new Set<string>();
|
||||
let id: string | undefined = statusId;
|
||||
|
@ -38,7 +38,7 @@ export const getAncestorsIds = createSelector([
|
|||
|
||||
export const getDescendantsIds = createSelector([
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.replies,
|
||||
(state: RootState) => state.replies,
|
||||
], (statusId, contextReplies) => {
|
||||
let descendantsIds = new Set<string>();
|
||||
const ids = [statusId];
|
||||
|
@ -95,7 +95,7 @@ const Thread: React.FC<IThread> = ({
|
|||
|
||||
if (status) {
|
||||
const statusId = status.id;
|
||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos[statusId]);
|
||||
ancestorsIds = getAncestorsIds(state, state.inReplyTos[statusId]);
|
||||
descendantsIds = getDescendantsIds(state, statusId);
|
||||
|
||||
ancestorsIds.delete(statusId);
|
||||
|
|
159
src/components/statuses/timeline.tsx
Normal file
159
src/components/statuses/timeline.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import clsx from 'clsx';
|
||||
import { useStatus } from 'pl-hooks';
|
||||
import React from 'react';
|
||||
|
||||
import PlaceholderStatus from 'bigbuffet/components/placeholders/placeholder-status';
|
||||
import ScrollableList from 'bigbuffet/components/scrollable-list';
|
||||
import StatusContainer from 'bigbuffet/containers/status-container';
|
||||
import { useShadowRoot } from 'bigbuffet/shadow-root';
|
||||
|
||||
import LoadMore from '../load-more';
|
||||
|
||||
import type { IScrollableList } from 'bigbuffet/components/scrollable-list';
|
||||
import type { TimelineEntry } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
interface ITimelineStatus {
|
||||
id: string;
|
||||
rebloggedBy?: Array<string>;
|
||||
isConnectedTop?: boolean;
|
||||
isConnectedBottom?: boolean;
|
||||
onMoveUp: (id: string) => void;
|
||||
onMoveDown: (id: string) => void;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
/** Status with reply-connector in timelines. */
|
||||
const TimelineStatus: React.FC<ITimelineStatus> = (props): JSX.Element => {
|
||||
const { id, rebloggedBy, isConnectedTop, isConnectedBottom } = props;
|
||||
|
||||
const { isFetched } = useStatus(id);
|
||||
|
||||
const renderConnector = (): JSX.Element | null => {
|
||||
const isConnected = isConnectedTop || isConnectedBottom;
|
||||
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('thread__connector', {
|
||||
'thread__connector--top': isConnectedTop,
|
||||
'thread__connector--bottom': isConnectedBottom,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('timeline-status', {
|
||||
'timeline-status--connected': isConnectedBottom,
|
||||
'border-b border-solid border-gray-200 dark:border-gray-800': !isConnectedBottom,
|
||||
})}
|
||||
>
|
||||
{renderConnector()}
|
||||
{isFetched ? (
|
||||
<StatusContainer {...props} variant='slim' rebloggedBy={rebloggedBy} />
|
||||
) : (
|
||||
<PlaceholderStatus thread />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITimeline extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||
/** List of status IDs to display. */
|
||||
entries?: Array<TimelineEntry>;
|
||||
/** Pinned statuses to show at the top of the feed. */
|
||||
featuredEntries?: Array<TimelineEntry>;
|
||||
// /** Pagination callback when the end of the list is reached. */
|
||||
handleLoadMore: (entry: TimelineEntry) => Promise<void> | undefined;
|
||||
/** Whether the data is currently being fetched. */
|
||||
isLoading: boolean;
|
||||
/** Whether to display a gap or border between statuses in the list. */
|
||||
divideType?: 'space' | 'border';
|
||||
}
|
||||
|
||||
/** Feed of statuses, built atop ScrollableList. */
|
||||
const Timeline: React.FC<ITimeline> = ({
|
||||
entries = [],
|
||||
featuredEntries,
|
||||
divideType = 'border',
|
||||
handleLoadMore,
|
||||
isLoading,
|
||||
...other
|
||||
}) => {
|
||||
const shadowRoot = useShadowRoot();
|
||||
|
||||
const getFeaturedStatusCount = () => featuredEntries?.length || 0;
|
||||
|
||||
const getCurrentStatusIndex = (id: string, featured: boolean): number => {
|
||||
if (featured) {
|
||||
return featuredEntries?.findIndex(entry => entry.type === 'status' && entry.id === id) || 0;
|
||||
} else {
|
||||
return entries.findIndex(entry => entry.type === 'status' && entry.id === id) + getFeaturedStatusCount();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = (id: string, featured: boolean = false) => {
|
||||
const elementIndex = getCurrentStatusIndex(id, featured) - 1;
|
||||
if (!selectChild(elementIndex)) selectChild(elementIndex - 1);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string, featured: boolean = false) => {
|
||||
const elementIndex = getCurrentStatusIndex(id, featured) + 1;
|
||||
if (!selectChild(elementIndex)) selectChild(elementIndex + 1);
|
||||
};
|
||||
|
||||
const selectChild = (index: number) => {
|
||||
const selector = `#status-list [data-index="${index}"] .focusable`;
|
||||
const element = shadowRoot.querySelector<HTMLDivElement>(selector);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderTimelineEntry = (entry: TimelineEntry, featured?: boolean) => {
|
||||
if (entry.type === 'status') {
|
||||
return (
|
||||
<TimelineStatus
|
||||
key={`${featured ? 'f-' : ''}${entry.id}`}
|
||||
id={entry.id}
|
||||
isConnectedTop={entry.isConnectedTop}
|
||||
isConnectedBottom={entry.isConnectedBottom}
|
||||
featured={featured}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
rebloggedBy={entry.rebloggedBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.type === 'page-end' || entry.type === 'page-start') {
|
||||
return (
|
||||
<LoadMore className='load-more' key='load-more' onClick={() => handleLoadMore(entry)} disabled={isLoading} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderScrollableContent = () =>
|
||||
(featuredEntries || [])
|
||||
.map(entry => renderTimelineEntry(entry, true))
|
||||
.concat(entries.map(entry => renderTimelineEntry(entry)));
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
id='status-list'
|
||||
key='scrollable-list'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && entries.length === 0}
|
||||
placeholderComponent={PlaceholderStatus}
|
||||
placeholderCount={20}
|
||||
{...other}
|
||||
>
|
||||
{renderScrollableContent()}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
|
@ -1,33 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import StatusList, { IStatusList } from 'bigbuffet/components/statuses/status-list';
|
||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||
|
||||
interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
|
||||
/** ID of the timeline in Redux. */
|
||||
timelineId: string;
|
||||
}
|
||||
|
||||
/** Scrollable list of statuses from a timeline in the Redux store. */
|
||||
const Timeline: React.FC<ITimeline> = ({
|
||||
timelineId,
|
||||
onLoadMore,
|
||||
...rest
|
||||
}) => {
|
||||
const statusIds = useAppSelector(state => state.timelines[timelineId]?.items || []);
|
||||
const isLoading = useAppSelector(state => (state.timelines[timelineId] || { isLoading: true }).isLoading === true);
|
||||
const hasMore = useAppSelector(state => state.timelines[timelineId]?.hasMore === true);
|
||||
|
||||
return (
|
||||
<StatusList
|
||||
timelineId={timelineId}
|
||||
statusIds={statusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={onLoadMore}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
|
@ -1,29 +0,0 @@
|
|||
import toast from 'bigbuffet/toast';
|
||||
|
||||
import type { AnyAction, Middleware } from '@reduxjs/toolkit';
|
||||
|
||||
/** Whether the action is considered a failure. */
|
||||
const isFailType = (type: string): boolean => type.endsWith('_FAIL');
|
||||
|
||||
/** Whether the error contains an Axios response. */
|
||||
const hasResponse = (error: any): boolean => Boolean(error && error.response);
|
||||
|
||||
/** Don't show 401's. */
|
||||
const authorized = (error: any): boolean => error?.response?.status !== 401;
|
||||
|
||||
/** Whether the error should be shown to the user. */
|
||||
const shouldShowError = ({ type, skipAlert, error }: AnyAction): boolean =>
|
||||
!skipAlert && hasResponse(error) && authorized(error) && isFailType(type);
|
||||
|
||||
/** Middleware to display Redux errors to the user. */
|
||||
const errors = (): Middleware =>
|
||||
() => next => anyAction => {
|
||||
const action = anyAction as AnyAction;
|
||||
if (shouldShowError(action)) {
|
||||
toast.showAlertForError(action.error);
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
|
||||
export default errors;
|
|
@ -1,20 +1,17 @@
|
|||
import { useQueries, type UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useAccountLookup, usePlHooksQueryClient } from 'pl-hooks';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInfiniteQuery, useQueries, type UseQueryOptions } from '@tanstack/react-query';
|
||||
import { importEntities, useAccountLookup, usePlHooksApiClient, usePlHooksQueryClient } from 'pl-hooks';
|
||||
import React, { useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
||||
import LoadMore from 'bigbuffet/components/load-more';
|
||||
import MediaItem from 'bigbuffet/components/media/media-item';
|
||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
||||
import Column from 'bigbuffet/components/ui/column';
|
||||
import Spinner from 'bigbuffet/components/ui/spinner';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||
import { useModalsStore } from 'bigbuffet/stores/modals';
|
||||
|
||||
import type { MediaAttachment } from 'pl-api';
|
||||
import type { MediaAttachment, PaginatedResponse, Status } from 'pl-api';
|
||||
import type { NormalizedStatus } from 'pl-hooks';
|
||||
|
||||
type AccountGalleryAttachment = MediaAttachment & {
|
||||
|
@ -37,8 +34,31 @@ const LoadMoreMedia: React.FC<ILoadMoreMedia> = ({ maxId, onLoadMore }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const useAccountGallery = (accountId?: string) => {
|
||||
const statusIds = useAppSelector((state) => state.timelines[`account:${accountId}:with_replies:media`]?.items);
|
||||
const minifyStatusList = ({ previous, next, items, ...response }: PaginatedResponse<Status>): PaginatedResponse<string> => {
|
||||
importEntities({ statuses: items });
|
||||
return {
|
||||
...response,
|
||||
previous: previous ? () => previous().then(minifyStatusList) : null,
|
||||
next: next ? () => next().then(minifyStatusList) : null,
|
||||
items: items.map(status => status.id),
|
||||
};
|
||||
};
|
||||
|
||||
const useAccountMediaTimeline = (accountId?: string) => {
|
||||
const { client } = usePlHooksApiClient();
|
||||
const queryClient = usePlHooksQueryClient();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['timelineIds', `account:${accountId}:with_replies:media`],
|
||||
queryFn: ({ pageParam }) => pageParam.next?.() || client.accounts.getAccountStatuses(accountId!, { only_media: true }).then(minifyStatusList),
|
||||
initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse<string>,
|
||||
getNextPageParam: (page) => page.next ? page : undefined,
|
||||
select: (data) => data.pages.map(page => page.items).flat(),
|
||||
enabled: !!accountId,
|
||||
}, queryClient);
|
||||
};
|
||||
|
||||
const useAccountGallery = (statusIds: Array<string> = []) => {
|
||||
const queryClient = usePlHooksQueryClient();
|
||||
|
||||
return useQueries<UseQueryOptions<NormalizedStatus>[]>({
|
||||
|
@ -58,7 +78,6 @@ const useAccountGallery = (accountId?: string) => {
|
|||
};
|
||||
|
||||
const AccountGallery = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { openModal } = useModalsStore();
|
||||
const { username } = useParams<{ username: string }>();
|
||||
|
||||
|
@ -67,27 +86,20 @@ const AccountGallery = () => {
|
|||
isLoading: accountLoading,
|
||||
} = useAccountLookup(username);
|
||||
|
||||
const attachments = useAccountGallery(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: statusIds, isLoading, hasNextPage, fetchNextPage } = useAccountMediaTimeline(account?.id);
|
||||
const attachments = useAccountGallery(statusIds);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
if (hasMore) {
|
||||
handleLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { only_media: true }, true));
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadOlder: React.MouseEventHandler = e => {
|
||||
e.preventDefault();
|
||||
handleScrollToBottom();
|
||||
handleLoadMore();
|
||||
};
|
||||
|
||||
const handleOpenMedia = (attachment: AccountGalleryAttachment) => {
|
||||
|
@ -101,12 +113,6 @@ const AccountGallery = () => {
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { only_media: true, limit: 40 }));
|
||||
}
|
||||
}, [account?.id]);
|
||||
|
||||
if (accountLoading || (!attachments && isLoading)) {
|
||||
return (
|
||||
<Column>
|
||||
|
@ -123,7 +129,7 @@ const AccountGallery = () => {
|
|||
|
||||
let loadOlder: JSX.Element | null = null;
|
||||
|
||||
if (hasMore && !(isLoading && attachments.length === 0)) {
|
||||
if (hasNextPage && !(isLoading && attachments.length === 0)) {
|
||||
loadOlder = <LoadMore className='account-gallery__load-more' visible={!isLoading} onClick={handleLoadOlder} />;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { useAccountLookup } from 'pl-hooks';
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
||||
import StatusList from 'bigbuffet/components/statuses/status-list';
|
||||
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||
import Spinner from 'bigbuffet/components/ui/spinner';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||
import { useAccountTimeline } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
interface IHashtagTimeline {
|
||||
params: {
|
||||
|
@ -19,28 +17,9 @@ interface IHashtagTimeline {
|
|||
const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||
const id = params?.id || '';
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: account, isLoading: accountLoading } = useAccountLookup(params.username);
|
||||
|
||||
const path = `account:${account?.id}:hashtag:${id}`;
|
||||
|
||||
const statusIds = useAppSelector(state => state.timelines[path]?.items) || [];
|
||||
|
||||
const isLoading = useAppSelector(state => state.timelines[path]?.isLoading === true);
|
||||
const hasMore = useAppSelector(state => state.timelines[path]?.hasMore === true);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { tagged: id, exclude_replies: true }));
|
||||
}
|
||||
}, [account?.id]);
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { tagged: id, exclude_replies: true }, true));
|
||||
}
|
||||
};
|
||||
const { data, handleLoadMore, isLoading } = useAccountTimeline(account?.id!, { tagged: id, exclude_replies: true });
|
||||
|
||||
if (!account && accountLoading) {
|
||||
return <Spinner />;
|
||||
|
@ -49,13 +28,11 @@ const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
<Timeline
|
||||
entries={data}
|
||||
handleLoadMore={handleLoadMore}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.account_hashtag' defaultMessage='There is nothing in this hashtag for this user yet.' />}
|
||||
divideType='space'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { useAccountLookup } from 'pl-hooks';
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
||||
import StatusList from 'bigbuffet/components/statuses/status-list';
|
||||
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||
import Column from 'bigbuffet/components/ui/column';
|
||||
import Spinner from 'bigbuffet/components/ui/spinner';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||
import { useAccountTimeline } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
interface IAccountTimeline {
|
||||
params: {
|
||||
|
@ -17,35 +16,11 @@ interface IAccountTimeline {
|
|||
}
|
||||
|
||||
const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = false }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: account, isFetching } = useAccountLookup(params.username);
|
||||
|
||||
const path = withReplies ? `${account?.id}:with_replies` : account?.id;
|
||||
const showPins = !withReplies;
|
||||
const statusIds = useAppSelector(state => state.timelines[`account:${path}`]?.items || []);
|
||||
const featuredStatusIds = useAppSelector(state => state.timelines[`account:${account?.id}:pinned`]?.items || []);
|
||||
|
||||
const isLoading = useAppSelector(state => state.timelines[`account:${path}`]?.isLoading === true);
|
||||
const hasMore = useAppSelector(state => state.timelines[`account:${path}`]?.hasMore === true);
|
||||
|
||||
useEffect(() => {
|
||||
if (account && !withReplies) {
|
||||
dispatch(fetchAccountTimeline(account.id, { pinned: true }));
|
||||
}
|
||||
}, [account?.id, withReplies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { exclude_replies: !withReplies }));
|
||||
}
|
||||
}, [account?.id, withReplies]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (account) {
|
||||
dispatch(fetchAccountTimeline(account.id, { exclude_replies: !withReplies }, true));
|
||||
}
|
||||
};
|
||||
const { data, isLoading, handleLoadMore } = useAccountTimeline(account?.id!, { exclude_replies: !withReplies }, !!account);
|
||||
const { data: pinnedData } = useAccountTimeline(account?.id!, { pinned: true }, account && showPins);
|
||||
|
||||
if (!account && isFetching) {
|
||||
return <Spinner />;
|
||||
|
@ -54,15 +29,15 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
|
|||
}
|
||||
|
||||
return (
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
featuredStatusIds={showPins ? featuredStatusIds : undefined}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />}
|
||||
divideType='space'
|
||||
/>
|
||||
<Column withHeader={false}>
|
||||
<Timeline
|
||||
entries={data}
|
||||
featuredEntries={pinnedData}
|
||||
isLoading={isLoading}
|
||||
handleLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchPublicTimeline } from 'bigbuffet/actions/timelines';
|
||||
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||
import Column from 'bigbuffet/components/ui/column';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
|
||||
import Timeline from '../features/ui/components/timeline';
|
||||
import { usePublicTimeline } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.community', defaultMessage: 'Recent posts' },
|
||||
|
@ -13,23 +11,15 @@ const messages = defineMessages({
|
|||
|
||||
const CommunityTimeline = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLoadMore = () => {
|
||||
dispatch(fetchPublicTimeline({ local: true }, true));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPublicTimeline({ local: true }));
|
||||
}, []);
|
||||
const { data, handleLoadMore, isLoading } = usePublicTimeline({ local: true });
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<Timeline
|
||||
timelineId={'public:local'}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
divideType='space'
|
||||
entries={data}
|
||||
handleLoadMore={handleLoadMore}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -11,6 +11,7 @@ import ThreadStatus from 'bigbuffet/components/statuses/thread-status';
|
|||
import Tombstone from 'bigbuffet/components/statuses/tombstone';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||
import { useShadowRoot } from 'bigbuffet/shadow-root';
|
||||
|
||||
import type { MediaAttachment } from 'pl-api';
|
||||
|
||||
|
@ -24,6 +25,7 @@ interface IEventDiscussion {
|
|||
|
||||
const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const shadowRoot = useShadowRoot();
|
||||
|
||||
const { data: status } = useStatus(props.params.statusId);
|
||||
|
||||
|
@ -69,7 +71,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
|||
|
||||
const _selectChild = (index: number) => {
|
||||
const selector = `#thread [data-index="${index}"] .focusable`;
|
||||
const element = document.querySelector<HTMLDivElement>(selector);
|
||||
const element = shadowRoot.querySelector<HTMLDivElement>(selector);
|
||||
|
||||
if (element) element.focus();
|
||||
};
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { clearTimeline, fetchHashtagTimeline } from 'bigbuffet/actions/timelines';
|
||||
import Icon from 'bigbuffet/components/icon';
|
||||
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||
import Column from 'bigbuffet/components/ui/column';
|
||||
import bigBuffetConfig from 'bigbuffet/config';
|
||||
import Timeline from 'bigbuffet/features/ui/components/timeline';
|
||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||
import { useHashtagTimeline } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
const messages = defineMessages({
|
||||
empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' },
|
||||
|
@ -28,18 +27,10 @@ const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params, hideHeader }) =>
|
|||
|
||||
const rssUrl = `${bigBuffetConfig.baseUrl}/tags/${tagId}`;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLoadMore = () => {
|
||||
dispatch(fetchHashtagTimeline(tagId, { }, true));
|
||||
};
|
||||
useEffect(() => {
|
||||
dispatch(clearTimeline(`hashtag:${tagId}`));
|
||||
dispatch(fetchHashtagTimeline(tagId));
|
||||
}, [tagId]);
|
||||
const { data, handleLoadMore, isLoading } = useHashtagTimeline(tagId);
|
||||
|
||||
return (
|
||||
<Column label={`#${tagId}`} transparent withHeader={!hideHeader}>
|
||||
<Column label={`#${tagId}`} withHeader={!hideHeader}>
|
||||
<Helmet>
|
||||
<link rel='alternate' type='application/rss+xml' title={intl.formatMessage(messages.rssTitle, { hashtag: tagId })} href={rssUrl} />
|
||||
</Helmet>
|
||||
|
@ -55,10 +46,10 @@ const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params, hideHeader }) =>
|
|||
</a>
|
||||
</div>
|
||||
<Timeline
|
||||
timelineId={`hashtag:${tagId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
entries={data}
|
||||
handleLoadMore={handleLoadMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
divideType='space'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useStatusQuotes } from 'pl-hooks';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import StatusList from 'bigbuffet/components/statuses/status-list';
|
||||
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||
import Column from 'bigbuffet/components/ui/column';
|
||||
import { useStatusQuotesTimeline } from 'bigbuffet/queries/use-timeline';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
|
||||
|
@ -14,19 +14,17 @@ const Quotes: React.FC = () => {
|
|||
const intl = useIntl();
|
||||
const { statusId } = useParams<{ statusId: string }>();
|
||||
|
||||
const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId);
|
||||
const { data = [], isLoading, handleLoadMore } = useStatusQuotesTimeline(statusId);
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
hasMore={hasNextPage}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Timeline
|
||||
entries={data}
|
||||
handleLoadMore={handleLoadMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType='space'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -37,7 +37,7 @@ const StatusDetails: React.FC<IStatusDetails> = (props) => {
|
|||
|
||||
if (status) {
|
||||
const statusId = status.id;
|
||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos[statusId]);
|
||||
ancestorsIds = getAncestorsIds(state, state.inReplyTos[statusId]);
|
||||
descendantsIds = getDescendantsIds(state, statusId);
|
||||
ancestorsIds.delete(statusId);
|
||||
descendantsIds.forEach((descendantId) => ancestorsIds.delete(descendantId));
|
||||
|
|
202
src/queries/use-timeline.ts
Normal file
202
src/queries/use-timeline.ts
Normal file
|
@ -0,0 +1,202 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PaginationParams } from 'pl-api/dist/params/common';
|
||||
import { importEntities, usePlHooksApiClient, usePlHooksQueryClient } from 'pl-hooks';
|
||||
import { useState } from 'react';
|
||||
|
||||
// import { useTimelineStream } from 'pl-fe/api/hooks/streaming/use-timeline-stream';
|
||||
// import { useClient } from 'pl-fe/hooks/use-client';
|
||||
|
||||
import type { GetAccountStatusesParams, PaginatedResponse, PublicTimelineParams, Status } from 'pl-api';
|
||||
|
||||
type TimelineEntry = {
|
||||
type: 'status';
|
||||
id: string;
|
||||
rebloggedBy: Array<string>;
|
||||
isConnectedTop?: boolean;
|
||||
isConnectedBottom?: boolean;
|
||||
} | {
|
||||
type: 'pending-status';
|
||||
id: string;
|
||||
} | {
|
||||
type: 'gap';
|
||||
} | {
|
||||
type: 'page-start';
|
||||
maxId?: string;
|
||||
} | {
|
||||
type: 'page-end';
|
||||
minId?: string;
|
||||
};
|
||||
|
||||
const processPage = ({ items: statuses, next }: PaginatedResponse<Status>) => {
|
||||
const timelinePage: Array<TimelineEntry> = [];
|
||||
|
||||
// if (previous) timelinePage.push({
|
||||
// type: 'page-start',
|
||||
// maxId: statuses.at(0)?.id,
|
||||
// });
|
||||
|
||||
const processStatus = (status: Status) => {
|
||||
if (timelinePage.some((entry) => entry.type === 'status' && entry.id === status.id)) return false;
|
||||
|
||||
let isConnectedTop = false;
|
||||
const inReplyToId = (status.reblog || status).in_reply_to_id;
|
||||
|
||||
if (inReplyToId) {
|
||||
const foundStatus = statuses.find((status) => (status.reblog || status).id === inReplyToId);
|
||||
|
||||
if (foundStatus) {
|
||||
if (processStatus(foundStatus)) {
|
||||
const timelineEntry = timelinePage.at(-1);
|
||||
if (timelineEntry?.type === 'status') {
|
||||
timelineEntry.isConnectedBottom = true;
|
||||
}
|
||||
isConnectedTop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status.reblog) {
|
||||
const existingEntry = timelinePage.find((entry) => entry.type === 'status' && entry.id === status.reblog!.id);
|
||||
|
||||
if (existingEntry?.type === 'status') {
|
||||
existingEntry.rebloggedBy.push(status.account.id);
|
||||
} else {
|
||||
timelinePage.push({
|
||||
type: 'status',
|
||||
id: status.reblog.id,
|
||||
rebloggedBy: [status.account.id],
|
||||
isConnectedTop,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
timelinePage.push({
|
||||
type: 'status',
|
||||
id: status.id,
|
||||
rebloggedBy: [],
|
||||
isConnectedTop,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const status of statuses) {
|
||||
processStatus(status);
|
||||
}
|
||||
|
||||
if (next) timelinePage.push({
|
||||
type: 'page-end',
|
||||
minId: statuses.at(-1)?.id,
|
||||
});
|
||||
|
||||
return timelinePage;
|
||||
};
|
||||
|
||||
const useTimeline = (key: string, fn: (params?: PaginationParams) => Promise<PaginatedResponse<Status, true>>, enabled = true) => {
|
||||
const queryClient = usePlHooksQueryClient();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const queryKey = ['timelines', key];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
setIsLoading(true);
|
||||
|
||||
return fn()
|
||||
.then((response) => {
|
||||
importEntities({ statuses: response.items });
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
return processPage(response);
|
||||
});
|
||||
// .catch(() => {setIsLoading(false));
|
||||
},
|
||||
enabled,
|
||||
}, queryClient);
|
||||
|
||||
const handleLoadMore = (entry: TimelineEntry) => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
if (entry.type !== 'page-end' && entry.type !== 'page-start') return;
|
||||
|
||||
return fn(
|
||||
entry.type === 'page-end' ? { max_id: entry.minId } : { min_id: entry.maxId },
|
||||
).then((response) => {
|
||||
importEntities({ statuses: response.items });
|
||||
|
||||
const timelinePage = processPage(response);
|
||||
|
||||
queryClient.setQueryData<Array<TimelineEntry>>(queryKey, (oldData) => {
|
||||
if (!oldData) return timelinePage;
|
||||
|
||||
const index = oldData.indexOf(entry);
|
||||
return oldData.toSpliced(index, 1, ...timelinePage);
|
||||
});
|
||||
setIsLoading(false);
|
||||
}).catch(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return {
|
||||
...query,
|
||||
isLoading,
|
||||
handleLoadMore,
|
||||
};
|
||||
};
|
||||
|
||||
const usePublicTimeline = ({ onlyMedia, local, instance }: Record<string, any> = {}) => {
|
||||
const { client } = usePlHooksApiClient();
|
||||
|
||||
const timelineId = `${instance ? 'remote' : 'public'}${local ? ':local' : ''}${onlyMedia ? ':media' : ''}${instance ? `:${instance}` : ''}`;
|
||||
|
||||
const params: PublicTimelineParams = { only_media: onlyMedia, local: instance ? false : local, instance };
|
||||
|
||||
const fn = (paginationParams?: PaginationParams) => client.timelines.publicTimeline({ ...params, ...paginationParams });
|
||||
|
||||
return useTimeline(timelineId, fn);
|
||||
};
|
||||
|
||||
const useAccountTimeline = (accountId: string, { exclude_replies, pinned, only_media, limit, tagged }: GetAccountStatusesParams = {}, enabled = true) => {
|
||||
const { client } = usePlHooksApiClient();
|
||||
|
||||
const timelineId = `account:${accountId}${!exclude_replies ? ':with_replies' : ''}${pinned ? ':pinned' : only_media ? ':media' : ''}${tagged ? `:hashtag:${tagged}` : ''}`;
|
||||
|
||||
const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit, tagged };
|
||||
if (pinned || only_media) params.with_muted = true;
|
||||
|
||||
const fn = (paginationParams?: PaginationParams) => client.accounts.getAccountStatuses(accountId, { ...params, ...paginationParams });
|
||||
|
||||
return useTimeline(timelineId, fn, enabled);
|
||||
};
|
||||
|
||||
const useHashtagTimeline = (hashtag: string) => {
|
||||
const { client } = usePlHooksApiClient();
|
||||
|
||||
const timelineId = `hashtag:${hashtag}`;
|
||||
|
||||
const fn = (paginationParams?: PaginationParams) => client.timelines.hashtagTimeline(hashtag, paginationParams);
|
||||
|
||||
return useTimeline(timelineId, fn);
|
||||
};
|
||||
|
||||
const useStatusQuotesTimeline = (statusId: string) => {
|
||||
const { client } = usePlHooksApiClient();
|
||||
|
||||
const timelineId = `status_quotes:${statusId}`;
|
||||
|
||||
const fn = (paginationParams?: PaginationParams) => client.statuses.getStatusQuotes(statusId, paginationParams);
|
||||
|
||||
return useTimeline(timelineId, fn);
|
||||
};
|
||||
|
||||
export {
|
||||
usePublicTimeline,
|
||||
useAccountTimeline,
|
||||
useHashtagTimeline,
|
||||
useStatusQuotesTimeline,
|
||||
type TimelineEntry,
|
||||
};
|
|
@ -1,7 +1,6 @@
|
|||
import { create } from 'mutative';
|
||||
|
||||
import { CONTEXT_FETCH_SUCCESS, type StatusesAction } from 'bigbuffet/actions/statuses';
|
||||
import { type TimelineAction } from 'bigbuffet/actions/timelines';
|
||||
|
||||
interface State {
|
||||
inReplyTos: Record<string, string>;
|
||||
|
@ -101,14 +100,10 @@ const normalizeContext = (
|
|||
};
|
||||
|
||||
/** Contexts reducer. Used for building a nested tree structure for threads. */
|
||||
const contexts = (state = initialState, action: StatusesAction | TimelineAction) => {
|
||||
const contexts = (state = initialState, action: StatusesAction) => {
|
||||
switch (action.type) {
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return create(state, draft => normalizeContext(draft, action.statusId, action.ancestors, action.descendants));
|
||||
// case STATUS_IMPORT:
|
||||
// return importStatus(state, action.status, action.idempotencyKey);
|
||||
// case STATUSES_IMPORT:
|
||||
// return importStatuses(state, action.statuses);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
|
||||
import contexts from './contexts';
|
||||
import timelines from './timelines';
|
||||
|
||||
const reducers = {
|
||||
contexts,
|
||||
timelines,
|
||||
};
|
||||
|
||||
const appReducer = combineReducers(reducers);
|
||||
|
||||
export default appReducer;
|
|
@ -1,117 +0,0 @@
|
|||
import { create } from 'mutative';
|
||||
|
||||
import {
|
||||
TIMELINE_CLEAR,
|
||||
TIMELINE_EXPAND_SUCCESS,
|
||||
TIMELINE_EXPAND_REQUEST,
|
||||
TIMELINE_EXPAND_FAIL,
|
||||
type TimelineAction,
|
||||
} from '../actions/timelines';
|
||||
|
||||
import type { PaginatedResponse, Status as BaseStatus } from 'pl-api';
|
||||
|
||||
interface Timeline {
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
|
||||
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
|
||||
items: Array<string>;
|
||||
}
|
||||
|
||||
const emptyTimeline: Timeline = {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
next: null,
|
||||
prev: null,
|
||||
items: [],
|
||||
};
|
||||
|
||||
const initialState: State = {};
|
||||
|
||||
type State = Record<string, Timeline>;
|
||||
|
||||
const getStatusIds = (statuses: Array<Pick<BaseStatus, 'id'>> = []) => (
|
||||
Array.from(new Set(statuses.map(status => status.id)))
|
||||
);
|
||||
|
||||
const mergeStatusIds = (oldIds = new Array<string>(), newIds = new Array<string>()) => (
|
||||
Array.from(new Set(newIds).union(new Set(oldIds)))
|
||||
);
|
||||
|
||||
const setLoading = (state: State, timelineId: string, loading: boolean) => {
|
||||
if (!state[timelineId]) state[timelineId] = emptyTimeline;
|
||||
state[timelineId].isLoading = loading;
|
||||
};
|
||||
|
||||
const expandNormalizedTimeline = (
|
||||
state: State,
|
||||
timelineId: string,
|
||||
statuses: Array<BaseStatus>,
|
||||
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
|
||||
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
|
||||
isLoadingRecent: boolean,
|
||||
pos: 'start' | 'end' = 'end',
|
||||
) => {
|
||||
const newIds = getStatusIds(statuses);
|
||||
|
||||
if (!state[timelineId]) state[timelineId] = emptyTimeline;
|
||||
|
||||
const timeline = state[timelineId];
|
||||
|
||||
timeline.isLoading = false;
|
||||
timeline.next = next;
|
||||
timeline.prev = prev;
|
||||
|
||||
if (!next && !isLoadingRecent) timeline.hasMore = false;
|
||||
|
||||
// Pinned timelines can be replaced entirely
|
||||
if (timelineId.endsWith(':pinned')) {
|
||||
timeline.items = newIds;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newIds.length) {
|
||||
let newItems: Array<string>;
|
||||
if (pos === 'end') {
|
||||
newItems = mergeStatusIds(newIds, timeline.items);
|
||||
} else {
|
||||
newItems = mergeStatusIds(timeline.items, newIds);
|
||||
}
|
||||
|
||||
timeline.items = newItems;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTimeline = (state: State, timelineId: string) => {
|
||||
state[timelineId] = emptyTimeline;
|
||||
return state;
|
||||
};
|
||||
|
||||
const handleExpandFail = (state: State, timelineId: string) => {
|
||||
setLoading(state, timelineId, false);
|
||||
return state;
|
||||
};
|
||||
|
||||
const timelines = (state: State = initialState, action: TimelineAction) => {
|
||||
switch (action.type) {
|
||||
case TIMELINE_EXPAND_REQUEST:
|
||||
return create(state, (draft) => setLoading(draft, action.timeline, true));
|
||||
case TIMELINE_EXPAND_FAIL:
|
||||
return create(state, (draft) => handleExpandFail(draft, action.timeline));
|
||||
case TIMELINE_EXPAND_SUCCESS:
|
||||
return create(state, (draft) => expandNormalizedTimeline(
|
||||
draft,
|
||||
action.timeline,
|
||||
action.statuses,
|
||||
action.next,
|
||||
action.prev,
|
||||
action.isLoadingRecent,
|
||||
));
|
||||
case TIMELINE_CLEAR:
|
||||
return create(state, (draft) => clearTimeline(draft, action.timeline));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default timelines;
|
10
src/store.ts
10
src/store.ts
|
@ -1,17 +1,13 @@
|
|||
import { configureStore, Tuple } from '@reduxjs/toolkit';
|
||||
import { thunk, type ThunkDispatch } from 'redux-thunk';
|
||||
|
||||
import errorsMiddleware from './middleware/errors';
|
||||
import appReducer from './reducers';
|
||||
import contexts from './reducers/contexts';
|
||||
|
||||
import type { ActionType } from './actions';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: appReducer,
|
||||
middleware: () => new Tuple(
|
||||
thunk,
|
||||
errorsMiddleware(),
|
||||
),
|
||||
reducer: contexts,
|
||||
middleware: () => new Tuple(thunk),
|
||||
devTools: true,
|
||||
});
|
||||
|
||||
|
|
|
@ -36,10 +36,17 @@
|
|||
}
|
||||
|
||||
&__reblog-element {
|
||||
@apply hidden @sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 rtl:space-x-reverse hover:underline;
|
||||
gap: 0.75rem;
|
||||
@apply mb-2 flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium hover:underline;
|
||||
|
||||
.svg-icon {
|
||||
@apply text-green-600;
|
||||
&__icon {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
width: 42px;
|
||||
|
||||
.svg-icon {
|
||||
@apply text-green-600;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
|
@ -55,22 +62,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__reblog-element-mobile {
|
||||
@apply pb-5 -mt-2 @sm:hidden truncate;
|
||||
|
||||
a {
|
||||
@apply flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 rtl:space-x-reverse hover:underline;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
@apply text-green-600;
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
&--rounded {
|
||||
@apply py-6 @sm:p-5;
|
||||
|
@ -126,7 +117,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
[column-type='filled'] .status__wrapper,
|
||||
[column-type='filled'] .status__wrapper:not(.card--slim),
|
||||
[column-type='filled'] .status-placeholder {
|
||||
@apply rounded-none shadow-none p-4;
|
||||
}
|
||||
|
@ -331,14 +322,6 @@ a.status-card {
|
|||
}
|
||||
}
|
||||
|
||||
.status-list {
|
||||
@apply divide-y divide-solid divide-gray-200 dark:divide-gray-800;
|
||||
|
||||
&__item {
|
||||
@apply pb-3;
|
||||
}
|
||||
}
|
||||
|
||||
.thread {
|
||||
@apply bg-white dark:bg-primary-900 @sm:rounded-xl;
|
||||
|
||||
|
@ -388,7 +371,7 @@ a.status-card {
|
|||
@apply absolute left-5 z-[1] hidden w-0.5 bg-gray-200 rtl:left-auto rtl:right-5 dark:bg-primary-800;
|
||||
|
||||
&--bottom {
|
||||
@apply top-[calc(12px+42px)] h-[calc(100%-42px-8px-1rem)];
|
||||
@apply top-[calc(54px)] h-[calc(100%-42px-8px-1rem)];
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
@ -398,6 +381,10 @@ a.status-card {
|
|||
}
|
||||
}
|
||||
|
||||
.timeline-status--connected .thread__connector--bottom {
|
||||
top: 70px;
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
@apply box-border;
|
||||
|
||||
|
@ -543,3 +530,9 @@ a.status-card {
|
|||
.status-replies {
|
||||
@apply text-xl text-gray-900 dark:text-gray-100 font-bold tracking-normal font-sans normal-case my-2;
|
||||
}
|
||||
|
||||
.timeline-status--connected {
|
||||
.status__content-wrapper, .status-interaction-bar {
|
||||
@apply pl-[54px] rtl:pl-0 rtl:pr-[calc(54px)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
}
|
||||
|
||||
.load-more {
|
||||
@apply block w-full m-0 p-4 border-0 box-border text-gray-900 bg-transparent;
|
||||
@apply block w-full my-4 last:mb-0 p-4 border-0 box-border text-gray-900 bg-transparent;
|
||||
|
||||
.svg-icon {
|
||||
@apply mx-auto;
|
||||
|
|
Loading…
Reference in a new issue