mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Profile redesign: Timeline filters (#37626)
This commit is contained in:
60
app/javascript/mastodon/actions/timelines.test.ts
Normal file
60
app/javascript/mastodon/actions/timelines.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { parseTimelineKey, timelineKey } from './timelines_typed';
|
||||
|
||||
describe('timelineKey', () => {
|
||||
test('returns expected key for account timeline with filters', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '123',
|
||||
replies: true,
|
||||
boosts: false,
|
||||
media: true,
|
||||
});
|
||||
expect(key).toBe('account:123:0110');
|
||||
});
|
||||
|
||||
test('returns expected key for account timeline with tag', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '456',
|
||||
tagged: 'nature',
|
||||
replies: true,
|
||||
});
|
||||
expect(key).toBe('account:456:0100:nature');
|
||||
});
|
||||
|
||||
test('returns expected key for account timeline with pins', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '789',
|
||||
pinned: true,
|
||||
});
|
||||
expect(key).toBe('account:789:0001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimelineKey', () => {
|
||||
test('parses account timeline key with filters correctly', () => {
|
||||
const params = parseTimelineKey('account:123:1010');
|
||||
expect(params).toEqual({
|
||||
type: 'account',
|
||||
userId: '123',
|
||||
boosts: true,
|
||||
replies: false,
|
||||
media: true,
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses account timeline key with tag correctly', () => {
|
||||
const params = parseTimelineKey('account:456:0100:nature');
|
||||
expect(params).toEqual({
|
||||
type: 'account',
|
||||
userId: '456',
|
||||
replies: true,
|
||||
boosts: false,
|
||||
media: false,
|
||||
pinned: false,
|
||||
tagged: 'nature',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
|
||||
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
|
||||
import { createAppThunk } from '../store/typed_functions';
|
||||
|
||||
import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines';
|
||||
|
||||
export const expandTimelineByKey = createAppThunk(
|
||||
(args: { key: string; maxId?: number }, { dispatch }) => {
|
||||
const params = parseTimelineKey(args.key);
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
|
||||
void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId }));
|
||||
},
|
||||
);
|
||||
|
||||
export const expandTimelineByParams = createAppThunk(
|
||||
(params: TimelineParams & { maxId?: number }, { dispatch }) => {
|
||||
let url = '';
|
||||
const extra: Record<string, string | boolean> = {};
|
||||
|
||||
if (params.type === 'account') {
|
||||
url = `/api/v1/accounts/${params.userId}/statuses`;
|
||||
|
||||
if (!params.replies) {
|
||||
extra.exclude_replies = true;
|
||||
}
|
||||
if (!params.boosts) {
|
||||
extra.exclude_reblogs = true;
|
||||
}
|
||||
if (params.pinned) {
|
||||
extra.pinned = true;
|
||||
}
|
||||
if (params.media) {
|
||||
extra.only_media = true;
|
||||
}
|
||||
if (params.tagged) {
|
||||
extra.tagged = params.tagged;
|
||||
}
|
||||
} else if (params.type === 'public') {
|
||||
url = '/api/v1/timelines/public';
|
||||
}
|
||||
|
||||
if (params.maxId) {
|
||||
extra.max_id = params.maxId.toString();
|
||||
}
|
||||
|
||||
return dispatch(expandTimeline(timelineKey(params), url, extra));
|
||||
},
|
||||
);
|
||||
|
||||
export interface AccountTimelineParams {
|
||||
type: 'account';
|
||||
userId: string;
|
||||
tagged?: string;
|
||||
media?: boolean;
|
||||
pinned?: boolean;
|
||||
boosts?: boolean;
|
||||
replies?: boolean;
|
||||
}
|
||||
export type PublicTimelineServer = 'local' | 'remote' | 'all';
|
||||
export interface PublicTimelineParams {
|
||||
type: 'public';
|
||||
tagged?: string;
|
||||
server?: PublicTimelineServer; // Defaults to 'all'
|
||||
media?: boolean;
|
||||
}
|
||||
export interface HomeTimelineParams {
|
||||
type: 'home';
|
||||
}
|
||||
export type TimelineParams =
|
||||
| AccountTimelineParams
|
||||
| PublicTimelineParams
|
||||
| HomeTimelineParams;
|
||||
|
||||
const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const;
|
||||
|
||||
export function timelineKey(params: TimelineParams): string {
|
||||
const { type } = params;
|
||||
const key: string[] = [type];
|
||||
|
||||
if (type === 'account') {
|
||||
key.push(params.userId);
|
||||
|
||||
const view = ACCOUNT_FILTERS.reduce(
|
||||
(prev, curr) => prev + (params[curr] ? '1' : '0'),
|
||||
'',
|
||||
);
|
||||
|
||||
key.push(view);
|
||||
} else if (type === 'public') {
|
||||
key.push(params.server ?? 'all');
|
||||
if (params.media) {
|
||||
key.push('media');
|
||||
}
|
||||
}
|
||||
|
||||
if (type !== 'home' && params.tagged) {
|
||||
key.push(params.tagged);
|
||||
}
|
||||
|
||||
return key.filter(Boolean).join(':');
|
||||
}
|
||||
|
||||
export function parseTimelineKey(key: string): TimelineParams | null {
|
||||
const segments = key.split(':');
|
||||
const type = segments[0];
|
||||
|
||||
if (type === 'account') {
|
||||
const userId = segments[1];
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: TimelineParams = {
|
||||
type: 'account',
|
||||
userId,
|
||||
tagged: segments[3],
|
||||
};
|
||||
|
||||
const view = segments[2]?.split('') ?? [];
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
const flagName = ACCOUNT_FILTERS[i];
|
||||
if (flagName) {
|
||||
parsed[flagName] = view[i] === '1';
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (type === 'public') {
|
||||
return {
|
||||
type: 'public',
|
||||
server:
|
||||
segments[1] === 'remote' || segments[1] === 'local'
|
||||
? segments[1]
|
||||
: 'all',
|
||||
tagged: segments[2],
|
||||
media: segments[3] === 'media',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'home') {
|
||||
return { type: 'home' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isNonStatusId(value: unknown) {
|
||||
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
||||
|
||||
@@ -68,3 +68,8 @@
|
||||
:global([dir='rtl']) .input:checked + .toggle::before {
|
||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ ToggleField.displayName = 'ToggleField';
|
||||
|
||||
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
||||
({ className, size, ...otherProps }, ref) => (
|
||||
<>
|
||||
<span className={classes.wrapper}>
|
||||
<input
|
||||
{...otherProps}
|
||||
type='checkbox'
|
||||
@@ -46,7 +46,7 @@ export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
||||
}
|
||||
hidden
|
||||
/>
|
||||
</>
|
||||
</span>
|
||||
),
|
||||
);
|
||||
PlainToggleField.displayName = 'PlainToggleField';
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
|
||||
> span {
|
||||
font-weight: unset;
|
||||
@@ -145,7 +145,7 @@ svg.badgeIcon {
|
||||
}
|
||||
|
||||
dd {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -154,3 +154,26 @@ svg.badgeIcon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 18px 4px;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(.active) {
|
||||
color: var(--color-text-brand);
|
||||
border-bottom: 4px solid var(--color-text-brand);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,26 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
if (isRedesignEnabled()) {
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||
|
||||
export function useFilters() {
|
||||
const [boosts, setBoosts] = useSearchParam('boosts');
|
||||
const [replies, setReplies] = useSearchParam('replies');
|
||||
|
||||
const handleSetBoosts = useCallback(
|
||||
(value: boolean) => {
|
||||
setBoosts(value ? '1' : null);
|
||||
},
|
||||
[setBoosts],
|
||||
);
|
||||
const handleSetReplies = useCallback(
|
||||
(value: boolean) => {
|
||||
setReplies(value ? '1' : null);
|
||||
},
|
||||
[setReplies],
|
||||
);
|
||||
|
||||
return {
|
||||
boosts: boosts === '1',
|
||||
replies: replies === '1',
|
||||
setBoosts: handleSetBoosts,
|
||||
setReplies: handleSetReplies,
|
||||
};
|
||||
}
|
||||
146
app/javascript/mastodon/features/account_timeline/v2/filters.tsx
Normal file
146
app/javascript/mastodon/features/account_timeline/v2/filters.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import Overlay from 'react-overlays/esm/Overlay';
|
||||
|
||||
import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
|
||||
import { AccountTabs } from '../components/tabs';
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountFilters: FC = () => {
|
||||
const { acct } = useParams<{ acct: string }>();
|
||||
if (!acct) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AccountTabs acct={acct} />
|
||||
<div className={classes.filtersWrapper}>
|
||||
<FilterDropdown />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterDropdown: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const handleHide = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const { boosts, replies, setBoosts, setReplies } = useFilters();
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { name, checked } = event.target;
|
||||
if (name === 'boosts') {
|
||||
setBoosts(checked);
|
||||
} else if (name === 'replies') {
|
||||
setReplies(checked);
|
||||
}
|
||||
},
|
||||
[setBoosts, setReplies],
|
||||
);
|
||||
|
||||
const accessibleId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<button
|
||||
type='button'
|
||||
className={classes.filterSelectButton}
|
||||
ref={buttonRef}
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={`${accessibleId}-wrapper`}
|
||||
>
|
||||
{boosts && replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.all'
|
||||
defaultMessage='All activity'
|
||||
/>
|
||||
)}
|
||||
{!boosts && replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
)}
|
||||
{boosts && !replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_boosts'
|
||||
defaultMessage='Posts and boosts'
|
||||
/>
|
||||
)}
|
||||
{!boosts && !replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_only'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
id='unfold_more'
|
||||
icon={KeyboardArrowDownIcon}
|
||||
className={classes.filterSelectIcon}
|
||||
/>
|
||||
</button>
|
||||
<Overlay
|
||||
show={open}
|
||||
target={buttonRef}
|
||||
flip
|
||||
placement='bottom-start'
|
||||
rootClose
|
||||
onHide={handleHide}
|
||||
container={containerRef}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div
|
||||
{...props}
|
||||
id={`${accessibleId}-wrapper`}
|
||||
className={classes.filterOverlay}
|
||||
>
|
||||
<label htmlFor={`${accessibleId}-replies`}>
|
||||
<FormattedMessage
|
||||
id='account.filters.replies_toggle'
|
||||
defaultMessage='Show replies'
|
||||
/>
|
||||
</label>
|
||||
<PlainToggleField
|
||||
name='replies'
|
||||
checked={replies}
|
||||
onChange={handleChange}
|
||||
id={`${accessibleId}-replies`}
|
||||
/>
|
||||
|
||||
<label htmlFor={`${accessibleId}-boosts`}>
|
||||
<FormattedMessage
|
||||
id='account.filters.boosts_toggle'
|
||||
defaultMessage='Show boosts'
|
||||
/>
|
||||
</label>
|
||||
<PlainToggleField
|
||||
name='boosts'
|
||||
checked={boosts}
|
||||
onChange={handleChange}
|
||||
id={`${accessibleId}-boosts`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
172
app/javascript/mastodon/features/account_timeline/v2/index.tsx
Normal file
172
app/javascript/mastodon/features/account_timeline/v2/index.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import {
|
||||
expandTimelineByKey,
|
||||
timelineKey,
|
||||
} from '@/mastodon/actions/timelines_typed';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import StatusList from '@/mastodon/components/status_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../components/account_header';
|
||||
import { LimitedAccountHint } from '../components/limited_account_hint';
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import { AccountFilters } from './filters';
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
|
||||
const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useAccountId();
|
||||
|
||||
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
if (!accountId) {
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
// Add this key to remount the timeline when accountId changes.
|
||||
return (
|
||||
<InnerTimeline
|
||||
accountId={accountId}
|
||||
key={accountId}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
||||
accountId,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const { tagged } = useParams<{ tagged?: string }>();
|
||||
const { boosts, replies } = useFilters();
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: accountId,
|
||||
tagged,
|
||||
boosts,
|
||||
replies,
|
||||
});
|
||||
|
||||
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!timeline && !!accountId) {
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
}, [accountId, dispatch, key, timeline]);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(maxId: number) => {
|
||||
if (accountId) {
|
||||
dispatch(expandTimelineByKey({ key, maxId }));
|
||||
}
|
||||
},
|
||||
[accountId, dispatch, key],
|
||||
);
|
||||
|
||||
const forceEmptyState = blockedBy || hidden || suspended;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnBackButton />
|
||||
|
||||
<StatusList
|
||||
alwaysPrepend
|
||||
prepend={
|
||||
<Prepend
|
||||
accountId={accountId}
|
||||
tagged={tagged}
|
||||
forceEmpty={forceEmptyState}
|
||||
/>
|
||||
}
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
// We want to have this component when timeline is undefined (loading),
|
||||
// because if we don't the prepended component will re-render with every filter change.
|
||||
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
|
||||
isLoading={!!timeline?.isLoading}
|
||||
hasMore={!forceEmptyState && !!timeline?.hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<EmptyMessage accountId={accountId} />}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='account'
|
||||
withCounters
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const Prepend: FC<{
|
||||
accountId: string;
|
||||
tagged?: string;
|
||||
forceEmpty: boolean;
|
||||
}> = ({ forceEmpty, accountId, tagged }) => {
|
||||
if (forceEmpty) {
|
||||
return <AccountHeader accountId={accountId} hideTabs />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccountHeader accountId={accountId} hideTabs />
|
||||
<AccountFilters />
|
||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
if (suspended) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
);
|
||||
} else if (hidden) {
|
||||
return <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_unavailable'
|
||||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_timeline'
|
||||
defaultMessage='No posts found'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AccountTimelineV2;
|
||||
@@ -0,0 +1,37 @@
|
||||
.filtersWrapper {
|
||||
padding: 16px 24px 8px;
|
||||
}
|
||||
|
||||
.filterSelectButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 8px 0;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterSelectIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.filterOverlay {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
min-width: 230px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: stretch;
|
||||
row-gap: 16px;
|
||||
padding: 8px 12px;
|
||||
z-index: 1;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function EmojiPicker () {
|
||||
return import('../../emoji/emoji_picker');
|
||||
}
|
||||
@@ -65,6 +67,9 @@ export function PinnedStatuses () {
|
||||
}
|
||||
|
||||
export function AccountTimeline () {
|
||||
if (isClientFeatureEnabled('profile_redesign')) {
|
||||
return import('../../account_timeline/v2');
|
||||
}
|
||||
return import('../../account_timeline');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"about.powered_by": "Decentralized social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
"account.account_note_header": "Personal note",
|
||||
"account.activity": "Activity",
|
||||
"account.add_note": "Add a personal note",
|
||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||
"account.badges.bot": "Automated",
|
||||
@@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.filters.all": "All activity",
|
||||
"account.filters.boosts_toggle": "Show boosts",
|
||||
"account.filters.posts_boosts": "Posts and boosts",
|
||||
"account.filters.posts_only": "Posts",
|
||||
"account.filters.posts_replies": "Posts and replies",
|
||||
"account.filters.replies_toggle": "Show replies",
|
||||
"account.follow": "Follow",
|
||||
"account.follow_back": "Follow back",
|
||||
"account.follow_back_short": "Follow back",
|
||||
|
||||
51
app/javascript/mastodon/selectors/timelines.ts
Normal file
51
app/javascript/mastodon/selectors/timelines.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { TimelineParams } from '../actions/timelines_typed';
|
||||
import { timelineKey } from '../actions/timelines_typed';
|
||||
import { createAppSelector } from '../store';
|
||||
|
||||
interface TimelineShape {
|
||||
unread: number;
|
||||
online: boolean;
|
||||
top: boolean;
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
pendingItems: ImmutableList<string>;
|
||||
items: ImmutableList<string>;
|
||||
}
|
||||
|
||||
type TimelinesState = ImmutableMap<string, ImmutableMap<string, unknown>>;
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
|
||||
export const selectTimelineByKey = createAppSelector(
|
||||
[(_, key: string) => key, (state) => state.timelines as TimelinesState],
|
||||
(key, timelines) => toTypedTimeline(timelines.get(key)),
|
||||
);
|
||||
|
||||
export const selectTimelineByParams = createAppSelector(
|
||||
[
|
||||
(_, params: TimelineParams) => timelineKey(params),
|
||||
(state) => state.timelines as TimelinesState,
|
||||
],
|
||||
(key, timelines) => toTypedTimeline(timelines.get(key)),
|
||||
);
|
||||
|
||||
export function toTypedTimeline(timeline?: ImmutableMap<string, unknown>) {
|
||||
if (!timeline) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
unread: timeline.get('unread', 0) as number,
|
||||
online: !!timeline.get('online', false),
|
||||
top: !!timeline.get('top', false),
|
||||
isLoading: !!timeline.get('isLoading', true),
|
||||
hasMore: !!timeline.get('hasMore', false),
|
||||
pendingItems: timeline.get(
|
||||
'pendingItems',
|
||||
emptyList,
|
||||
) as ImmutableList<string>,
|
||||
items: timeline.get('items', emptyList) as ImmutableList<string>,
|
||||
} as TimelineShape;
|
||||
}
|
||||
Reference in New Issue
Block a user