Compare commits

...

4 commits

Author SHA1 Message Date
2e91787986 fix for querySelector
Signed-off-by: mkljczk <git@mkljczk.pl>
2024-12-12 00:23:50 +01:00
c979cf45dc do not combine a single reducer
Signed-off-by: mkljczk <git@mkljczk.pl>
2024-12-12 00:23:02 +01:00
428e116fc3 remove unused
Signed-off-by: mkljczk <git@mkljczk.pl>
2024-12-12 00:13:56 +01:00
ee444a746f refactor timelines
Signed-off-by: mkljczk <git@mkljczk.pl>
2024-12-12 00:12:00 +01:00
26 changed files with 558 additions and 807 deletions

View file

@ -1,8 +1,5 @@
import type { StatusesAction } from './statuses';
import type { TimelineAction } from './timelines';
type ActionType =
| StatusesAction
| TimelineAction;
type ActionType = StatusesAction;
export type { ActionType };

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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();
}
};

View file

@ -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 };

View file

@ -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}

View file

@ -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 => {

View file

@ -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);

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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} />;
}

View file

@ -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'
/>
);
};

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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();
};

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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
View 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,
};

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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,
});

View file

@ -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)];
}
}

View file

@ -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;