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 { StatusesAction } from './statuses';
|
||||||
import type { TimelineAction } from './timelines';
|
|
||||||
|
|
||||||
type ActionType =
|
type ActionType = StatusesAction;
|
||||||
| StatusesAction
|
|
||||||
| TimelineAction;
|
|
||||||
|
|
||||||
export type { ActionType };
|
export type { ActionType };
|
||||||
|
|
|
@ -5,14 +5,7 @@ import client from 'bigbuffet/client';
|
||||||
import type { AppDispatch } from 'bigbuffet/store';
|
import type { AppDispatch } from 'bigbuffet/store';
|
||||||
import type { Status as BaseStatus } from 'pl-api';
|
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_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 {
|
interface FetchContextSuccessAction {
|
||||||
type: typeof CONTEXT_FETCH_SUCCESS;
|
type: typeof CONTEXT_FETCH_SUCCESS;
|
||||||
|
@ -21,40 +14,21 @@ interface FetchContextSuccessAction {
|
||||||
descendants: Array<BaseStatus>;
|
descendants: Array<BaseStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchContextFailAction {
|
|
||||||
type: typeof CONTEXT_FETCH_FAIL;
|
|
||||||
statusId: string;
|
|
||||||
error: unknown;
|
|
||||||
skipAlert: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchContext = (statusId: string) =>
|
const fetchContext = (statusId: string) =>
|
||||||
(dispatch: AppDispatch) => {
|
(dispatch: AppDispatch) => client.statuses.getContext(statusId).then(context => {
|
||||||
const action: FetchContextRequestAction = { type: CONTEXT_FETCH_REQUEST, statusId };
|
const { ancestors, descendants } = context;
|
||||||
|
const statuses = ancestors.concat(descendants);
|
||||||
|
importEntities({ statuses });
|
||||||
|
const action: FetchContextSuccessAction = { type: CONTEXT_FETCH_SUCCESS, statusId, ancestors, descendants };
|
||||||
dispatch(action);
|
dispatch(action);
|
||||||
|
return context;
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatusesAction =
|
type StatusesAction =
|
||||||
| FetchContextRequestAction
|
|
||||||
| FetchContextSuccessAction
|
| FetchContextSuccessAction
|
||||||
| FetchContextFailAction;
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CONTEXT_FETCH_REQUEST,
|
|
||||||
CONTEXT_FETCH_SUCCESS,
|
CONTEXT_FETCH_SUCCESS,
|
||||||
CONTEXT_FETCH_FAIL,
|
|
||||||
fetchContext,
|
fetchContext,
|
||||||
type StatusesAction,
|
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 Icon from 'bigbuffet/components/icon';
|
||||||
import Input from 'bigbuffet/components/ui/input';
|
import Input from 'bigbuffet/components/ui/input';
|
||||||
|
import { useShadowRoot } from 'bigbuffet/shadow-root';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||||
|
@ -21,6 +22,7 @@ const SearchBar = ({
|
||||||
}: ISearchBar) => {
|
}: ISearchBar) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const shadowRoot = useShadowRoot();
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
@ -48,7 +50,7 @@ const SearchBar = ({
|
||||||
|
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
} else if (event.key === 'Escape') {
|
} 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 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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
import { useIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||||
import { NavLink, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import Emojify from 'bigbuffet/components/emojify';
|
import Emojify from 'bigbuffet/components/emojify';
|
||||||
import Icon from 'bigbuffet/components/icon';
|
import Icon from 'bigbuffet/components/icon';
|
||||||
|
@ -25,13 +27,62 @@ import StatusReplyMentions from './status-reply-mentions';
|
||||||
|
|
||||||
import type { UseStatusData as StatusEntity } from 'pl-hooks';
|
import type { UseStatusData as StatusEntity } from 'pl-hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
interface IRebloggedBy {
|
||||||
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
|
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 {
|
export interface IStatus {
|
||||||
id?: string;
|
id?: string;
|
||||||
status: StatusEntity;
|
status: StatusEntity;
|
||||||
|
rebloggedBy?: Array<string>;
|
||||||
onMoveUp?: (statusId: string, featured?: boolean) => void;
|
onMoveUp?: (statusId: string, featured?: boolean) => void;
|
||||||
onMoveDown?: (statusId: string, featured?: boolean) => void;
|
onMoveDown?: (statusId: string, featured?: boolean) => void;
|
||||||
focusable?: boolean;
|
focusable?: boolean;
|
||||||
|
@ -45,12 +96,13 @@ export interface IStatus {
|
||||||
const Status: React.FC<IStatus> = (props) => {
|
const Status: React.FC<IStatus> = (props) => {
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
|
rebloggedBy,
|
||||||
focusable = true,
|
focusable = true,
|
||||||
hoverable = true,
|
hoverable = true,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
featured,
|
featured,
|
||||||
variant = 'rounded',
|
variant = 'default',
|
||||||
thread,
|
thread,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
@ -137,66 +189,14 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
let rebloggedByText, reblogElement, reblogElementMobile;
|
let rebloggedByText, reblogElement;
|
||||||
|
|
||||||
if (status.reblog && typeof status.reblog === 'object') {
|
if (rebloggedBy?.length) {
|
||||||
reblogElement = (
|
reblogElement = <RebloggedBy rebloggedBy={rebloggedBy} />;
|
||||||
<NavLink
|
// rebloggedByText = intl.formatMessage(
|
||||||
to={`/@${status.account.acct}`}
|
// messages.reblogged_by,
|
||||||
onClick={(event) => event.stopPropagation()}
|
// { name: String(status.account.acct) },
|
||||||
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) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let quote;
|
let quote;
|
||||||
|
@ -222,8 +222,6 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
openMedia: handleHotkeyOpenMedia,
|
openMedia: handleHotkeyOpenMedia,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountAction = props.accountAction || reblogElement;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers} data-testid='status'>
|
<HotKeys handlers={handlers} data-testid='status'>
|
||||||
<div
|
<div
|
||||||
|
@ -253,7 +251,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
})}
|
})}
|
||||||
data-id={status.id}
|
data-id={status.id}
|
||||||
>
|
>
|
||||||
{reblogElementMobile}
|
{reblogElement}
|
||||||
|
|
||||||
<div className='status__account'>
|
<div className='status__account'>
|
||||||
<AccountContainer
|
<AccountContainer
|
||||||
|
@ -261,8 +259,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
id={String(actualStatus.account.id)}
|
id={String(actualStatus.account.id)}
|
||||||
timestamp={actualStatus.created_at}
|
timestamp={actualStatus.created_at}
|
||||||
timestampUrl={actualStatus.url}
|
timestampUrl={actualStatus.url}
|
||||||
action={accountAction}
|
hideActions
|
||||||
hideActions={!accountAction}
|
|
||||||
showEdit={actualStatus.edited_at ? actualStatus.id : false}
|
showEdit={actualStatus.edited_at ? actualStatus.id : false}
|
||||||
showAccountHoverCard={hoverable}
|
showAccountHoverCard={hoverable}
|
||||||
withLinkToProfile={hoverable}
|
withLinkToProfile={hoverable}
|
||||||
|
|
|
@ -18,8 +18,8 @@ interface IThreadStatus {
|
||||||
const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
||||||
const { id, focusedStatusId, variant = 'default' } = props;
|
const { id, focusedStatusId, variant = 'default' } = props;
|
||||||
|
|
||||||
const replyToId = useAppSelector(state => state.contexts.inReplyTos[id]);
|
const replyToId = useAppSelector(state => state.inReplyTos[id]);
|
||||||
const replyCount = useAppSelector(state => state.contexts.replies[id]?.length || 0);
|
const replyCount = useAppSelector(state => state.replies[id]?.length || 0);
|
||||||
const isLoaded = useStatus(id).isSuccess;
|
const isLoaded = useStatus(id).isSuccess;
|
||||||
|
|
||||||
const renderConnector = (): JSX.Element | null => {
|
const renderConnector = (): JSX.Element | null => {
|
||||||
|
|
|
@ -23,7 +23,7 @@ import type { UseStatusData as Status } from 'pl-hooks';
|
||||||
|
|
||||||
export const getAncestorsIds = createSelector([
|
export const getAncestorsIds = createSelector([
|
||||||
(_: RootState, statusId: string | undefined) => statusId,
|
(_: RootState, statusId: string | undefined) => statusId,
|
||||||
(state: RootState) => state.contexts.inReplyTos,
|
(state: RootState) => state.inReplyTos,
|
||||||
], (statusId, inReplyTos) => {
|
], (statusId, inReplyTos) => {
|
||||||
let ancestorsIds = new Set<string>();
|
let ancestorsIds = new Set<string>();
|
||||||
let id: string | undefined = statusId;
|
let id: string | undefined = statusId;
|
||||||
|
@ -38,7 +38,7 @@ export const getAncestorsIds = createSelector([
|
||||||
|
|
||||||
export const getDescendantsIds = createSelector([
|
export const getDescendantsIds = createSelector([
|
||||||
(_: RootState, statusId: string) => statusId,
|
(_: RootState, statusId: string) => statusId,
|
||||||
(state: RootState) => state.contexts.replies,
|
(state: RootState) => state.replies,
|
||||||
], (statusId, contextReplies) => {
|
], (statusId, contextReplies) => {
|
||||||
let descendantsIds = new Set<string>();
|
let descendantsIds = new Set<string>();
|
||||||
const ids = [statusId];
|
const ids = [statusId];
|
||||||
|
@ -95,7 +95,7 @@ const Thread: React.FC<IThread> = ({
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const statusId = status.id;
|
const statusId = status.id;
|
||||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos[statusId]);
|
ancestorsIds = getAncestorsIds(state, state.inReplyTos[statusId]);
|
||||||
descendantsIds = getDescendantsIds(state, statusId);
|
descendantsIds = getDescendantsIds(state, statusId);
|
||||||
|
|
||||||
ancestorsIds.delete(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 { useInfiniteQuery, useQueries, type UseQueryOptions } from '@tanstack/react-query';
|
||||||
import { useAccountLookup, usePlHooksQueryClient } from 'pl-hooks';
|
import { importEntities, useAccountLookup, usePlHooksApiClient, usePlHooksQueryClient } from 'pl-hooks';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
|
||||||
import LoadMore from 'bigbuffet/components/load-more';
|
import LoadMore from 'bigbuffet/components/load-more';
|
||||||
import MediaItem from 'bigbuffet/components/media/media-item';
|
import MediaItem from 'bigbuffet/components/media/media-item';
|
||||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
||||||
import Column from 'bigbuffet/components/ui/column';
|
import Column from 'bigbuffet/components/ui/column';
|
||||||
import Spinner from 'bigbuffet/components/ui/spinner';
|
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 { 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';
|
import type { NormalizedStatus } from 'pl-hooks';
|
||||||
|
|
||||||
type AccountGalleryAttachment = MediaAttachment & {
|
type AccountGalleryAttachment = MediaAttachment & {
|
||||||
|
@ -37,8 +34,31 @@ const LoadMoreMedia: React.FC<ILoadMoreMedia> = ({ maxId, onLoadMore }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAccountGallery = (accountId?: string) => {
|
const minifyStatusList = ({ previous, next, items, ...response }: PaginatedResponse<Status>): PaginatedResponse<string> => {
|
||||||
const statusIds = useAppSelector((state) => state.timelines[`account:${accountId}:with_replies:media`]?.items);
|
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();
|
const queryClient = usePlHooksQueryClient();
|
||||||
|
|
||||||
return useQueries<UseQueryOptions<NormalizedStatus>[]>({
|
return useQueries<UseQueryOptions<NormalizedStatus>[]>({
|
||||||
|
@ -58,7 +78,6 @@ const useAccountGallery = (accountId?: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountGallery = () => {
|
const AccountGallery = () => {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { openModal } = useModalsStore();
|
const { openModal } = useModalsStore();
|
||||||
const { username } = useParams<{ username: string }>();
|
const { username } = useParams<{ username: string }>();
|
||||||
|
|
||||||
|
@ -67,27 +86,20 @@ const AccountGallery = () => {
|
||||||
isLoading: accountLoading,
|
isLoading: accountLoading,
|
||||||
} = useAccountLookup(username);
|
} = useAccountLookup(username);
|
||||||
|
|
||||||
const attachments = useAccountGallery(account?.id);
|
const { data: statusIds, isLoading, hasNextPage, fetchNextPage } = useAccountMediaTimeline(account?.id);
|
||||||
const isLoading = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.isLoading);
|
const attachments = useAccountGallery(statusIds);
|
||||||
const hasMore = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.hasMore);
|
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleScrollToBottom = () => {
|
|
||||||
if (hasMore) {
|
|
||||||
handleLoadMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
if (account) {
|
if (hasNextPage) {
|
||||||
dispatch(fetchAccountTimeline(account.id, { only_media: true }, true));
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadOlder: React.MouseEventHandler = e => {
|
const handleLoadOlder: React.MouseEventHandler = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleScrollToBottom();
|
handleLoadMore();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenMedia = (attachment: AccountGalleryAttachment) => {
|
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)) {
|
if (accountLoading || (!attachments && isLoading)) {
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
|
@ -123,7 +129,7 @@ const AccountGallery = () => {
|
||||||
|
|
||||||
let loadOlder: JSX.Element | null = null;
|
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} />;
|
loadOlder = <LoadMore className='account-gallery__load-more' visible={!isLoading} onClick={handleLoadOlder} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import { useAccountLookup } from 'pl-hooks';
|
import { useAccountLookup } from 'pl-hooks';
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
|
||||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
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 Spinner from 'bigbuffet/components/ui/spinner';
|
||||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
import { useAccountTimeline } from 'bigbuffet/queries/use-timeline';
|
||||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
|
||||||
|
|
||||||
interface IHashtagTimeline {
|
interface IHashtagTimeline {
|
||||||
params: {
|
params: {
|
||||||
|
@ -19,28 +17,9 @@ interface IHashtagTimeline {
|
||||||
const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
const id = params?.id || '';
|
const id = params?.id || '';
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { data: account, isLoading: accountLoading } = useAccountLookup(params.username);
|
const { data: account, isLoading: accountLoading } = useAccountLookup(params.username);
|
||||||
|
|
||||||
const path = `account:${account?.id}:hashtag:${id}`;
|
const { data, handleLoadMore, isLoading } = useAccountTimeline(account?.id!, { tagged: id, exclude_replies: true });
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!account && accountLoading) {
|
if (!account && accountLoading) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
|
@ -49,13 +28,11 @@ const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusList
|
<Timeline
|
||||||
statusIds={statusIds}
|
entries={data}
|
||||||
|
handleLoadMore={handleLoadMore}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
hasMore={hasMore}
|
|
||||||
onLoadMore={handleLoadMore}
|
|
||||||
emptyMessage={<FormattedMessage id='empty_column.account_hashtag' defaultMessage='There is nothing in this hashtag for this user yet.' />}
|
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 { useAccountLookup } from 'pl-hooks';
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { fetchAccountTimeline } from 'bigbuffet/actions/timelines';
|
|
||||||
import MissingIndicator from 'bigbuffet/components/missing-indicator';
|
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 Spinner from 'bigbuffet/components/ui/spinner';
|
||||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
import { useAccountTimeline } from 'bigbuffet/queries/use-timeline';
|
||||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
|
||||||
|
|
||||||
interface IAccountTimeline {
|
interface IAccountTimeline {
|
||||||
params: {
|
params: {
|
||||||
|
@ -17,35 +16,11 @@ interface IAccountTimeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = false }) => {
|
const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = false }) => {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { data: account, isFetching } = useAccountLookup(params.username);
|
const { data: account, isFetching } = useAccountLookup(params.username);
|
||||||
|
|
||||||
const path = withReplies ? `${account?.id}:with_replies` : account?.id;
|
|
||||||
const showPins = !withReplies;
|
const showPins = !withReplies;
|
||||||
const statusIds = useAppSelector(state => state.timelines[`account:${path}`]?.items || []);
|
const { data, isLoading, handleLoadMore } = useAccountTimeline(account?.id!, { exclude_replies: !withReplies }, !!account);
|
||||||
const featuredStatusIds = useAppSelector(state => state.timelines[`account:${account?.id}:pinned`]?.items || []);
|
const { data: pinnedData } = useAccountTimeline(account?.id!, { pinned: true }, account && showPins);
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!account && isFetching) {
|
if (!account && isFetching) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
|
@ -54,15 +29,15 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusList
|
<Column withHeader={false}>
|
||||||
statusIds={statusIds}
|
<Timeline
|
||||||
featuredStatusIds={showPins ? featuredStatusIds : undefined}
|
entries={data}
|
||||||
isLoading={isLoading}
|
featuredEntries={pinnedData}
|
||||||
hasMore={hasMore}
|
isLoading={isLoading}
|
||||||
onLoadMore={handleLoadMore}
|
handleLoadMore={handleLoadMore}
|
||||||
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />}
|
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />}
|
||||||
divideType='space'
|
/>
|
||||||
/>
|
</Column>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
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 Column from 'bigbuffet/components/ui/column';
|
||||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
import { usePublicTimeline } from 'bigbuffet/queries/use-timeline';
|
||||||
|
|
||||||
import Timeline from '../features/ui/components/timeline';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.community', defaultMessage: 'Recent posts' },
|
title: { id: 'column.community', defaultMessage: 'Recent posts' },
|
||||||
|
@ -13,23 +11,15 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const CommunityTimeline = () => {
|
const CommunityTimeline = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const { data, handleLoadMore, isLoading } = usePublicTimeline({ local: true });
|
||||||
dispatch(fetchPublicTimeline({ local: true }, true));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchPublicTimeline({ local: true }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.title)} transparent>
|
<Column label={intl.formatMessage(messages.title)}>
|
||||||
<Timeline
|
<Timeline
|
||||||
timelineId={'public:local'}
|
entries={data}
|
||||||
onLoadMore={handleLoadMore}
|
handleLoadMore={handleLoadMore}
|
||||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
isLoading={isLoading}
|
||||||
divideType='space'
|
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import ThreadStatus from 'bigbuffet/components/statuses/thread-status';
|
||||||
import Tombstone from 'bigbuffet/components/statuses/tombstone';
|
import Tombstone from 'bigbuffet/components/statuses/tombstone';
|
||||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
||||||
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
import { useAppSelector } from 'bigbuffet/hooks/use-app-selector';
|
||||||
|
import { useShadowRoot } from 'bigbuffet/shadow-root';
|
||||||
|
|
||||||
import type { MediaAttachment } from 'pl-api';
|
import type { MediaAttachment } from 'pl-api';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ interface IEventDiscussion {
|
||||||
|
|
||||||
const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const shadowRoot = useShadowRoot();
|
||||||
|
|
||||||
const { data: status } = useStatus(props.params.statusId);
|
const { data: status } = useStatus(props.params.statusId);
|
||||||
|
|
||||||
|
@ -69,7 +71,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||||
|
|
||||||
const _selectChild = (index: number) => {
|
const _selectChild = (index: number) => {
|
||||||
const selector = `#thread [data-index="${index}"] .focusable`;
|
const selector = `#thread [data-index="${index}"] .focusable`;
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = shadowRoot.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { clearTimeline, fetchHashtagTimeline } from 'bigbuffet/actions/timelines';
|
|
||||||
import Icon from 'bigbuffet/components/icon';
|
import Icon from 'bigbuffet/components/icon';
|
||||||
|
import Timeline from 'bigbuffet/components/statuses/timeline';
|
||||||
import Column from 'bigbuffet/components/ui/column';
|
import Column from 'bigbuffet/components/ui/column';
|
||||||
import bigBuffetConfig from 'bigbuffet/config';
|
import bigBuffetConfig from 'bigbuffet/config';
|
||||||
import Timeline from 'bigbuffet/features/ui/components/timeline';
|
import { useHashtagTimeline } from 'bigbuffet/queries/use-timeline';
|
||||||
import { useAppDispatch } from 'bigbuffet/hooks/use-app-dispatch';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' },
|
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 rssUrl = `${bigBuffetConfig.baseUrl}/tags/${tagId}`;
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const { data, handleLoadMore, isLoading } = useHashtagTimeline(tagId);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
dispatch(fetchHashtagTimeline(tagId, { }, true));
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(clearTimeline(`hashtag:${tagId}`));
|
|
||||||
dispatch(fetchHashtagTimeline(tagId));
|
|
||||||
}, [tagId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={`#${tagId}`} transparent withHeader={!hideHeader}>
|
<Column label={`#${tagId}`} withHeader={!hideHeader}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<link rel='alternate' type='application/rss+xml' title={intl.formatMessage(messages.rssTitle, { hashtag: tagId })} href={rssUrl} />
|
<link rel='alternate' type='application/rss+xml' title={intl.formatMessage(messages.rssTitle, { hashtag: tagId })} href={rssUrl} />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
@ -55,10 +46,10 @@ const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params, hideHeader }) =>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Timeline
|
<Timeline
|
||||||
timelineId={`hashtag:${tagId}`}
|
entries={data}
|
||||||
onLoadMore={handleLoadMore}
|
handleLoadMore={handleLoadMore}
|
||||||
|
isLoading={isLoading}
|
||||||
emptyMessage={intl.formatMessage(messages.empty)}
|
emptyMessage={intl.formatMessage(messages.empty)}
|
||||||
divideType='space'
|
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { useStatusQuotes } from 'pl-hooks';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useParams } from 'react-router-dom';
|
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 Column from 'bigbuffet/components/ui/column';
|
||||||
|
import { useStatusQuotesTimeline } from 'bigbuffet/queries/use-timeline';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
|
heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
|
||||||
|
@ -14,19 +14,17 @@ const Quotes: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { statusId } = useParams<{ statusId: string }>();
|
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.' />;
|
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<StatusList
|
<Timeline
|
||||||
statusIds={statusIds}
|
entries={data}
|
||||||
hasMore={hasNextPage}
|
handleLoadMore={handleLoadMore}
|
||||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
isLoading={isLoading}
|
||||||
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
divideType='space'
|
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -37,7 +37,7 @@ const StatusDetails: React.FC<IStatusDetails> = (props) => {
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const statusId = status.id;
|
const statusId = status.id;
|
||||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos[statusId]);
|
ancestorsIds = getAncestorsIds(state, state.inReplyTos[statusId]);
|
||||||
descendantsIds = getDescendantsIds(state, statusId);
|
descendantsIds = getDescendantsIds(state, statusId);
|
||||||
ancestorsIds.delete(statusId);
|
ancestorsIds.delete(statusId);
|
||||||
descendantsIds.forEach((descendantId) => ancestorsIds.delete(descendantId));
|
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 { create } from 'mutative';
|
||||||
|
|
||||||
import { CONTEXT_FETCH_SUCCESS, type StatusesAction } from 'bigbuffet/actions/statuses';
|
import { CONTEXT_FETCH_SUCCESS, type StatusesAction } from 'bigbuffet/actions/statuses';
|
||||||
import { type TimelineAction } from 'bigbuffet/actions/timelines';
|
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
inReplyTos: Record<string, string>;
|
inReplyTos: Record<string, string>;
|
||||||
|
@ -101,14 +100,10 @@ const normalizeContext = (
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Contexts reducer. Used for building a nested tree structure for threads. */
|
/** 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) {
|
switch (action.type) {
|
||||||
case CONTEXT_FETCH_SUCCESS:
|
case CONTEXT_FETCH_SUCCESS:
|
||||||
return create(state, draft => normalizeContext(draft, action.statusId, action.ancestors, action.descendants));
|
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:
|
default:
|
||||||
return state;
|
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 { configureStore, Tuple } from '@reduxjs/toolkit';
|
||||||
import { thunk, type ThunkDispatch } from 'redux-thunk';
|
import { thunk, type ThunkDispatch } from 'redux-thunk';
|
||||||
|
|
||||||
import errorsMiddleware from './middleware/errors';
|
import contexts from './reducers/contexts';
|
||||||
import appReducer from './reducers';
|
|
||||||
|
|
||||||
import type { ActionType } from './actions';
|
import type { ActionType } from './actions';
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: appReducer,
|
reducer: contexts,
|
||||||
middleware: () => new Tuple(
|
middleware: () => new Tuple(thunk),
|
||||||
thunk,
|
|
||||||
errorsMiddleware(),
|
|
||||||
),
|
|
||||||
devTools: true,
|
devTools: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -36,10 +36,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__reblog-element {
|
&__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 {
|
&__icon {
|
||||||
@apply text-green-600;
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
width: 42px;
|
||||||
|
|
||||||
|
.svg-icon {
|
||||||
|
@apply text-green-600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__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 {
|
&__wrapper {
|
||||||
&--rounded {
|
&--rounded {
|
||||||
@apply py-6 @sm:p-5;
|
@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 {
|
[column-type='filled'] .status-placeholder {
|
||||||
@apply rounded-none shadow-none p-4;
|
@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 {
|
.thread {
|
||||||
@apply bg-white dark:bg-primary-900 @sm:rounded-xl;
|
@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;
|
@apply absolute left-5 z-[1] hidden w-0.5 bg-gray-200 rtl:left-auto rtl:right-5 dark:bg-primary-800;
|
||||||
|
|
||||||
&--bottom {
|
&--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;
|
display: block !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -398,6 +381,10 @@ a.status-card {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-status--connected .thread__connector--bottom {
|
||||||
|
top: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
.detailed-status {
|
.detailed-status {
|
||||||
@apply box-border;
|
@apply box-border;
|
||||||
|
|
||||||
|
@ -543,3 +530,9 @@ a.status-card {
|
||||||
.status-replies {
|
.status-replies {
|
||||||
@apply text-xl text-gray-900 dark:text-gray-100 font-bold tracking-normal font-sans normal-case my-2;
|
@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 {
|
.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 {
|
.svg-icon {
|
||||||
@apply mx-auto;
|
@apply mx-auto;
|
||||||
|
|
Loading…
Reference in a new issue