Merge pull request #3367 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 1ae6b52c471d82d3682a732680549fd849c91957
This commit is contained in:
commit
7d096dbf63
75
.github/workflows/bundlesize-compare.yml
vendored
Normal file
75
.github/workflows/bundlesize-compare.yml
vendored
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
name: Compare JS bundle size
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'app/javascript/**'
|
||||||
|
- 'vite.config.mts'
|
||||||
|
- 'package.json'
|
||||||
|
- 'yarn.lock'
|
||||||
|
- .github/workflows/bundlesize-compare.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-head:
|
||||||
|
name: 'Build head'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
ANALYZE_BUNDLE_SIZE: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{github.event.pull_request.head.ref}}
|
||||||
|
|
||||||
|
- name: Set up Javascript environment
|
||||||
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: yarn run build:production
|
||||||
|
|
||||||
|
- name: Upload stats.json
|
||||||
|
uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: head-stats
|
||||||
|
path: ./stats.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-base:
|
||||||
|
name: 'Build base'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
ANALYZE_BUNDLE_SIZE: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ github.base_ref }}
|
||||||
|
|
||||||
|
- name: Set up Javascript environment
|
||||||
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: yarn run build:production
|
||||||
|
|
||||||
|
- name: Upload stats.json
|
||||||
|
uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: base-stats
|
||||||
|
path: ./stats.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
compare:
|
||||||
|
name: 'Compare base & head bundle sizes'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-base, build-head]
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v5
|
||||||
|
|
||||||
|
- uses: twk3/rollup-size-compare-action@v1.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
current-stats-json-path: ./head-stats/stats.json
|
||||||
|
base-stats-json-path: ./base-stats/stats.json
|
||||||
@ -67,3 +67,4 @@ The following table summarizes those limits.
|
|||||||
| Account `attributionDomains` | 256 | List will be truncated |
|
| Account `attributionDomains` | 256 | List will be truncated |
|
||||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||||
|
| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated |
|
||||||
|
|||||||
@ -4,10 +4,8 @@ module AccountableConcern
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def log_action(action, target)
|
def log_action(action, target)
|
||||||
Admin::ActionLog.create(
|
current_account
|
||||||
account: current_account,
|
.action_logs
|
||||||
action: action,
|
.create(action:, target:)
|
||||||
target: target
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController
|
|||||||
before_action :authenticate_user!, if: :limited_federation_mode?
|
before_action :authenticate_user!, if: :limited_federation_mode?
|
||||||
before_action :set_media_attachment
|
before_action :set_media_attachment
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found
|
||||||
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
|
||||||
rescue_from Mastodon::NotPermittedError, with: :not_found
|
|
||||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|||||||
60
app/javascript/flavours/glitch/actions/timelines.test.ts
Normal file
60
app/javascript/flavours/glitch/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 'flavours/glitch/initial_state';
|
import { usePendingItems as preferPendingItems } from 'flavours/glitch/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) {
|
export function isNonStatusId(value: unknown) {
|
||||||
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
||||||
|
|||||||
@ -68,3 +68,8 @@
|
|||||||
:global([dir='rtl']) .input:checked + .toggle::before {
|
:global([dir='rtl']) .input:checked + .toggle::before {
|
||||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
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>(
|
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
||||||
({ className, size, ...otherProps }, ref) => (
|
({ className, size, ...otherProps }, ref) => (
|
||||||
<>
|
<span className={classes.wrapper}>
|
||||||
<input
|
<input
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
@ -46,7 +46,7 @@ export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
|||||||
}
|
}
|
||||||
hidden
|
hidden
|
||||||
/>
|
/>
|
||||||
</>
|
</span>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
PlainToggleField.displayName = 'PlainToggleField';
|
PlainToggleField.displayName = 'PlainToggleField';
|
||||||
|
|||||||
@ -160,6 +160,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
'expanded',
|
'expanded',
|
||||||
'unread',
|
'unread',
|
||||||
'pictureInPicture',
|
'pictureInPicture',
|
||||||
|
'onQuoteCancel',
|
||||||
'previousId',
|
'previousId',
|
||||||
'nextInReplyToId',
|
'nextInReplyToId',
|
||||||
'rootId',
|
'rootId',
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
.tag {
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
padding: 8px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.tag:hover,
|
||||||
|
button.tag:focus {
|
||||||
|
border-color: var(--color-bg-brand-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.tag:focus-visible {
|
||||||
|
outline: var(--outline-focus-default);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: var(--color-text-brand);
|
||||||
|
background: var(--color-bg-brand-softer);
|
||||||
|
color: var(--color-text-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
svg {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .closeButton {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
|
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||||
|
|
||||||
|
import { EditableTag, Tag } from './tag';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Tag,
|
||||||
|
title: 'Components/Tags/Single Tag',
|
||||||
|
args: {
|
||||||
|
name: 'example-tag',
|
||||||
|
active: false,
|
||||||
|
onClick: action('Click'),
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Tag>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
args: {
|
||||||
|
icon: MusicNoteIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Editable: Story = {
|
||||||
|
render(args) {
|
||||||
|
return <EditableTag {...args} onRemove={action('Remove')} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditableWithIcon: Story = {
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<EditableTag
|
||||||
|
{...args}
|
||||||
|
removeIcon={MusicNoteIcon}
|
||||||
|
onRemove={action('Remove')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
94
app/javascript/flavours/glitch/components/tags/tag.tsx
Normal file
94
app/javascript/flavours/glitch/components/tags/tag.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
|
import type { IconProp } from '../icon';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
import { IconButton } from '../icon_button';
|
||||||
|
|
||||||
|
import classes from './style.module.css';
|
||||||
|
|
||||||
|
export interface TagProps {
|
||||||
|
name: ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
icon?: IconProp;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tag = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
TagProps & ComponentPropsWithoutRef<'button'>
|
||||||
|
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
type='button'
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
>
|
||||||
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Tag.displayName = 'Tag';
|
||||||
|
|
||||||
|
export const EditableTag = forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
TagProps & {
|
||||||
|
onRemove: () => void;
|
||||||
|
removeIcon?: IconProp;
|
||||||
|
} & ComponentPropsWithoutRef<'span'>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
removeIcon = CloseIcon,
|
||||||
|
onRemove,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
>
|
||||||
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
|
{children}
|
||||||
|
<IconButton
|
||||||
|
className={classes.closeButton}
|
||||||
|
iconComponent={removeIcon}
|
||||||
|
onClick={onRemove}
|
||||||
|
icon='remove'
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'tag.remove',
|
||||||
|
defaultMessage: 'Remove',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
EditableTag.displayName = 'EditableTag';
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
|
import { Tags } from './tags';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Tags,
|
||||||
|
title: 'Components/Tags/List',
|
||||||
|
args: {
|
||||||
|
tags: [{ name: 'tag-one' }, { name: 'tag-two' }],
|
||||||
|
active: 'tag-one',
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Tags>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render(args) {
|
||||||
|
return <Tags {...args} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Editable: Story = {
|
||||||
|
args: {
|
||||||
|
onRemove: action('Remove'),
|
||||||
|
},
|
||||||
|
};
|
||||||
54
app/javascript/flavours/glitch/components/tags/tags.tsx
Normal file
54
app/javascript/flavours/glitch/components/tags/tags.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
|
import classes from './style.module.css';
|
||||||
|
import { EditableTag, Tag } from './tag';
|
||||||
|
import type { TagProps } from './tag';
|
||||||
|
|
||||||
|
type Tag = TagProps & { name: string };
|
||||||
|
|
||||||
|
export type TagsProps = {
|
||||||
|
tags: Tag[];
|
||||||
|
active?: string;
|
||||||
|
} & (
|
||||||
|
| ({
|
||||||
|
onRemove?: never;
|
||||||
|
} & ComponentPropsWithoutRef<'button'>)
|
||||||
|
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
||||||
|
if (onRemove) {
|
||||||
|
return (
|
||||||
|
<div className={classes.tagsWrapper}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<MappedTag
|
||||||
|
key={tag.name}
|
||||||
|
active={tag.name === active}
|
||||||
|
onRemove={onRemove}
|
||||||
|
{...tag}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.tagsWrapper}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||||
|
onRemove,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove?.(props.name);
|
||||||
|
}, [onRemove, props.name]);
|
||||||
|
return <EditableTag {...props} onRemove={handleRemove} />;
|
||||||
|
};
|
||||||
@ -49,7 +49,7 @@
|
|||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
font-weight: unset;
|
font-weight: unset;
|
||||||
@ -145,7 +145,7 @@ svg.badgeIcon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dd {
|
dd {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,3 +154,26 @@ svg.badgeIcon {
|
|||||||
margin-left: 4px;
|
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 { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isRedesignEnabled } from '../common';
|
||||||
|
|
||||||
|
import classes from './redesign.module.scss';
|
||||||
|
|
||||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
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 (
|
return (
|
||||||
<div className='account__section-headline'>
|
<div className='account__section-headline'>
|
||||||
<NavLink exact to={`/@${acct}/featured`}>
|
<NavLink exact to={`/@${acct}/featured`}>
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useSearchParam } from '@/flavours/glitch/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 '@/flavours/glitch/components/form_fields/toggle_field';
|
||||||
|
import { Icon } from '@/flavours/glitch/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 '@/flavours/glitch/actions/timelines_typed';
|
||||||
|
import { Column } from '@/flavours/glitch/components/column';
|
||||||
|
import { ColumnBackButton } from '@/flavours/glitch/components/column_back_button';
|
||||||
|
import { FeaturedCarousel } from '@/flavours/glitch/components/featured_carousel';
|
||||||
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
|
import { RemoteHint } from '@/flavours/glitch/components/remote_hint';
|
||||||
|
import StatusList from '@/flavours/glitch/components/status_list';
|
||||||
|
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
|
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
|
||||||
|
import { selectTimelineByKey } from '@/flavours/glitch/selectors/timelines';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 '@/flavours/glitch/utils/environment';
|
||||||
|
|
||||||
export function EmojiPicker () {
|
export function EmojiPicker () {
|
||||||
return import('../../emoji/emoji_picker');
|
return import('../../emoji/emoji_picker');
|
||||||
}
|
}
|
||||||
@ -65,6 +67,9 @@ export function PinnedStatuses () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccountTimeline () {
|
export function AccountTimeline () {
|
||||||
|
if (isClientFeatureEnabled('profile_redesign')) {
|
||||||
|
return import('../../account_timeline/v2');
|
||||||
|
}
|
||||||
return import('../../account_timeline');
|
return import('../../account_timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
app/javascript/flavours/glitch/selectors/timelines.ts
Normal file
51
app/javascript/flavours/glitch/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;
|
||||||
|
}
|
||||||
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 { 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) {
|
export function isNonStatusId(value: unknown) {
|
||||||
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
||||||
|
|||||||
@ -68,3 +68,8 @@
|
|||||||
:global([dir='rtl']) .input:checked + .toggle::before {
|
:global([dir='rtl']) .input:checked + .toggle::before {
|
||||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
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>(
|
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
||||||
({ className, size, ...otherProps }, ref) => (
|
({ className, size, ...otherProps }, ref) => (
|
||||||
<>
|
<span className={classes.wrapper}>
|
||||||
<input
|
<input
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
@ -46,7 +46,7 @@ export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
|||||||
}
|
}
|
||||||
hidden
|
hidden
|
||||||
/>
|
/>
|
||||||
</>
|
</span>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
PlainToggleField.displayName = 'PlainToggleField';
|
PlainToggleField.displayName = 'PlainToggleField';
|
||||||
|
|||||||
@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
'hidden',
|
'hidden',
|
||||||
'unread',
|
'unread',
|
||||||
'pictureInPicture',
|
'pictureInPicture',
|
||||||
|
'onQuoteCancel',
|
||||||
];
|
];
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
|||||||
50
app/javascript/mastodon/components/tags/style.module.css
Normal file
50
app/javascript/mastodon/components/tags/style.module.css
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
.tag {
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
padding: 8px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.tag:hover,
|
||||||
|
button.tag:focus {
|
||||||
|
border-color: var(--color-bg-brand-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.tag:focus-visible {
|
||||||
|
outline: var(--outline-focus-default);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: var(--color-text-brand);
|
||||||
|
background: var(--color-bg-brand-softer);
|
||||||
|
color: var(--color-text-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
svg {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .closeButton {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
46
app/javascript/mastodon/components/tags/tag.stories.tsx
Normal file
46
app/javascript/mastodon/components/tags/tag.stories.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
|
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||||
|
|
||||||
|
import { EditableTag, Tag } from './tag';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Tag,
|
||||||
|
title: 'Components/Tags/Single Tag',
|
||||||
|
args: {
|
||||||
|
name: 'example-tag',
|
||||||
|
active: false,
|
||||||
|
onClick: action('Click'),
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Tag>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
args: {
|
||||||
|
icon: MusicNoteIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Editable: Story = {
|
||||||
|
render(args) {
|
||||||
|
return <EditableTag {...args} onRemove={action('Remove')} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditableWithIcon: Story = {
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<EditableTag
|
||||||
|
{...args}
|
||||||
|
removeIcon={MusicNoteIcon}
|
||||||
|
onRemove={action('Remove')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
94
app/javascript/mastodon/components/tags/tag.tsx
Normal file
94
app/javascript/mastodon/components/tags/tag.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
|
import type { IconProp } from '../icon';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
import { IconButton } from '../icon_button';
|
||||||
|
|
||||||
|
import classes from './style.module.css';
|
||||||
|
|
||||||
|
export interface TagProps {
|
||||||
|
name: ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
icon?: IconProp;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tag = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
TagProps & ComponentPropsWithoutRef<'button'>
|
||||||
|
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
type='button'
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
>
|
||||||
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Tag.displayName = 'Tag';
|
||||||
|
|
||||||
|
export const EditableTag = forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
TagProps & {
|
||||||
|
onRemove: () => void;
|
||||||
|
removeIcon?: IconProp;
|
||||||
|
} & ComponentPropsWithoutRef<'span'>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
removeIcon = CloseIcon,
|
||||||
|
onRemove,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
>
|
||||||
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
|
{children}
|
||||||
|
<IconButton
|
||||||
|
className={classes.closeButton}
|
||||||
|
iconComponent={removeIcon}
|
||||||
|
onClick={onRemove}
|
||||||
|
icon='remove'
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'tag.remove',
|
||||||
|
defaultMessage: 'Remove',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
EditableTag.displayName = 'EditableTag';
|
||||||
29
app/javascript/mastodon/components/tags/tags.stories.tsx
Normal file
29
app/javascript/mastodon/components/tags/tags.stories.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
|
import { Tags } from './tags';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Tags,
|
||||||
|
title: 'Components/Tags/List',
|
||||||
|
args: {
|
||||||
|
tags: [{ name: 'tag-one' }, { name: 'tag-two' }],
|
||||||
|
active: 'tag-one',
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Tags>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render(args) {
|
||||||
|
return <Tags {...args} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Editable: Story = {
|
||||||
|
args: {
|
||||||
|
onRemove: action('Remove'),
|
||||||
|
},
|
||||||
|
};
|
||||||
54
app/javascript/mastodon/components/tags/tags.tsx
Normal file
54
app/javascript/mastodon/components/tags/tags.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
|
import classes from './style.module.css';
|
||||||
|
import { EditableTag, Tag } from './tag';
|
||||||
|
import type { TagProps } from './tag';
|
||||||
|
|
||||||
|
type Tag = TagProps & { name: string };
|
||||||
|
|
||||||
|
export type TagsProps = {
|
||||||
|
tags: Tag[];
|
||||||
|
active?: string;
|
||||||
|
} & (
|
||||||
|
| ({
|
||||||
|
onRemove?: never;
|
||||||
|
} & ComponentPropsWithoutRef<'button'>)
|
||||||
|
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
||||||
|
if (onRemove) {
|
||||||
|
return (
|
||||||
|
<div className={classes.tagsWrapper}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<MappedTag
|
||||||
|
key={tag.name}
|
||||||
|
active={tag.name === active}
|
||||||
|
onRemove={onRemove}
|
||||||
|
{...tag}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.tagsWrapper}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||||
|
onRemove,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove?.(props.name);
|
||||||
|
}, [onRemove, props.name]);
|
||||||
|
return <EditableTag {...props} onRemove={handleRemove} />;
|
||||||
|
};
|
||||||
@ -49,7 +49,7 @@
|
|||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
font-weight: unset;
|
font-weight: unset;
|
||||||
@ -145,7 +145,7 @@ svg.badgeIcon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dd {
|
dd {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,3 +154,26 @@ svg.badgeIcon {
|
|||||||
margin-left: 4px;
|
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 { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isRedesignEnabled } from '../common';
|
||||||
|
|
||||||
|
import classes from './redesign.module.scss';
|
||||||
|
|
||||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
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 (
|
return (
|
||||||
<div className='account__section-headline'>
|
<div className='account__section-headline'>
|
||||||
<NavLink exact to={`/@${acct}/featured`}>
|
<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 () {
|
export function EmojiPicker () {
|
||||||
return import('../../emoji/emoji_picker');
|
return import('../../emoji/emoji_picker');
|
||||||
}
|
}
|
||||||
@ -65,6 +67,9 @@ export function PinnedStatuses () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccountTimeline () {
|
export function AccountTimeline () {
|
||||||
|
if (isClientFeatureEnabled('profile_redesign')) {
|
||||||
|
return import('../../account_timeline/v2');
|
||||||
|
}
|
||||||
return import('../../account_timeline');
|
return import('../../account_timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "Decentralized social media powered by {mastodon}",
|
"about.powered_by": "Decentralized social media powered by {mastodon}",
|
||||||
"about.rules": "Server rules",
|
"about.rules": "Server rules",
|
||||||
"account.account_note_header": "Personal note",
|
"account.account_note_header": "Personal note",
|
||||||
|
"account.activity": "Activity",
|
||||||
"account.add_note": "Add a personal note",
|
"account.add_note": "Add a personal note",
|
||||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||||
"account.badges.bot": "Automated",
|
"account.badges.bot": "Automated",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Hashtags",
|
"account.featured.hashtags": "Hashtags",
|
||||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||||
"account.featured_tags.last_status_never": "No posts",
|
"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": "Follow",
|
||||||
"account.follow_back": "Follow back",
|
"account.follow_back": "Follow back",
|
||||||
"account.follow_back_short": "Follow back",
|
"account.follow_back_short": "Follow back",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Notifications",
|
"tabs_bar.notifications": "Notifications",
|
||||||
"tabs_bar.publish": "New Post",
|
"tabs_bar.publish": "New Post",
|
||||||
"tabs_bar.search": "Search",
|
"tabs_bar.search": "Search",
|
||||||
|
"tag.remove": "Remove",
|
||||||
"terms_of_service.effective_as_of": "Effective as of {date}",
|
"terms_of_service.effective_as_of": "Effective as of {date}",
|
||||||
"terms_of_service.title": "Terms of Service",
|
"terms_of_service.title": "Terms of Service",
|
||||||
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -71,6 +71,7 @@ class ConnectionPool::SharedTimedStack
|
|||||||
throw_away_connection = @queue.pop
|
throw_away_connection = @queue.pop
|
||||||
@tagged_queue[throw_away_connection.site].delete(throw_away_connection)
|
@tagged_queue[throw_away_connection.site].delete(throw_away_connection)
|
||||||
@create_block.call(preferred_tag)
|
@create_block.call(preferred_tag)
|
||||||
|
throw_away_connection.close
|
||||||
elsif @created != @max
|
elsif @created != @max
|
||||||
connection = @create_block.call(preferred_tag)
|
connection = @create_block.call(preferred_tag)
|
||||||
@created += 1
|
@created += 1
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
# also_known_as :string is an Array
|
# also_known_as :string is an Array
|
||||||
# attribution_domains :string default([]), is an Array
|
# attribution_domains :string default([]), is an Array
|
||||||
# avatar_content_type :string
|
# avatar_content_type :string
|
||||||
|
# avatar_description :string default(""), not null
|
||||||
# avatar_file_name :string
|
# avatar_file_name :string
|
||||||
# avatar_file_size :integer
|
# avatar_file_size :integer
|
||||||
# avatar_remote_url :string
|
# avatar_remote_url :string
|
||||||
@ -23,6 +24,7 @@
|
|||||||
# followers_url :string default(""), not null
|
# followers_url :string default(""), not null
|
||||||
# following_url :string default(""), not null
|
# following_url :string default(""), not null
|
||||||
# header_content_type :string
|
# header_content_type :string
|
||||||
|
# header_description :string default(""), not null
|
||||||
# header_file_name :string
|
# header_file_name :string
|
||||||
# header_file_size :integer
|
# header_file_size :integer
|
||||||
# header_remote_url :string default(""), not null
|
# header_remote_url :string default(""), not null
|
||||||
|
|||||||
@ -12,6 +12,7 @@ module Account::Associations
|
|||||||
has_many :account_notes
|
has_many :account_notes
|
||||||
has_many :account_pins
|
has_many :account_pins
|
||||||
has_many :account_warnings
|
has_many :account_warnings
|
||||||
|
has_many :action_logs, class_name: 'Admin::ActionLog'
|
||||||
has_many :aliases, class_name: 'AccountAlias'
|
has_many :aliases, class_name: 'AccountAlias'
|
||||||
has_many :bookmarks
|
has_many :bookmarks
|
||||||
has_many :collections
|
has_many :collections
|
||||||
|
|||||||
@ -24,6 +24,8 @@ module Account::Avatar
|
|||||||
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
|
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
|
||||||
validates_attachment_size :avatar, less_than: AVATAR_LIMIT
|
validates_attachment_size :avatar, less_than: AVATAR_LIMIT
|
||||||
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false
|
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false
|
||||||
|
|
||||||
|
validates :avatar_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_original_url
|
def avatar_original_url
|
||||||
|
|||||||
@ -25,6 +25,8 @@ module Account::Header
|
|||||||
validates_attachment_content_type :header, content_type: HEADER_IMAGE_MIME_TYPES
|
validates_attachment_content_type :header, content_type: HEADER_IMAGE_MIME_TYPES
|
||||||
validates_attachment_size :header, less_than: HEADER_LIMIT
|
validates_attachment_size :header, less_than: HEADER_LIMIT
|
||||||
remotable_attachment :header, HEADER_LIMIT, suppress_errors: false
|
remotable_attachment :header, HEADER_LIMIT, suppress_errors: false
|
||||||
|
|
||||||
|
validates :header_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }
|
||||||
end
|
end
|
||||||
|
|
||||||
def header_original_url
|
def header_original_url
|
||||||
|
|||||||
@ -7,7 +7,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||||||
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
|
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
|
||||||
|
|
||||||
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
|
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
|
||||||
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
|
:note, :url, :uri, :avatar, :avatar_static, :avatar_description, :header, :header_static, :header_description,
|
||||||
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections
|
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections
|
||||||
|
|
||||||
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
|
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
|
||||||
@ -82,6 +82,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||||||
full_asset_url(object.unavailable? ? object.avatar.default_url : object.avatar_static_url)
|
full_asset_url(object.unavailable? ? object.avatar.default_url : object.avatar_static_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def avatar_description
|
||||||
|
object.unavailable? ? '' : object.avatar_description
|
||||||
|
end
|
||||||
|
|
||||||
def header
|
def header
|
||||||
full_asset_url(object.unavailable? ? object.header.default_url : object.header_original_url)
|
full_asset_url(object.unavailable? ? object.header.default_url : object.header_original_url)
|
||||||
end
|
end
|
||||||
@ -90,6 +94,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||||||
full_asset_url(object.unavailable? ? object.header.default_url : object.header_static_url)
|
full_asset_url(object.unavailable? ? object.header.default_url : object.header_static_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def header_description
|
||||||
|
object.unavailable? ? '' : object.header_description
|
||||||
|
end
|
||||||
|
|
||||||
def created_at
|
def created_at
|
||||||
object.created_at.midnight.as_json
|
object.created_at.midnight.as_json
|
||||||
end
|
end
|
||||||
|
|||||||
@ -142,14 +142,18 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
|
|
||||||
def set_fetchable_attributes!
|
def set_fetchable_attributes!
|
||||||
begin
|
begin
|
||||||
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
|
avatar_url, avatar_description = image_url_and_description('icon')
|
||||||
|
@account.avatar_remote_url = avatar_url || '' unless skip_download?
|
||||||
@account.avatar = nil if @account.avatar_remote_url.blank?
|
@account.avatar = nil if @account.avatar_remote_url.blank?
|
||||||
|
@account.avatar_description = avatar_description || ''
|
||||||
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
||||||
RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id)
|
RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id)
|
||||||
end
|
end
|
||||||
begin
|
begin
|
||||||
@account.header_remote_url = image_url('image') || '' unless skip_download?
|
header_url, header_description = image_url_and_description('image')
|
||||||
|
@account.header_remote_url = header_url || '' unless skip_download?
|
||||||
@account.header = nil if @account.header_remote_url.blank?
|
@account.header = nil if @account.header_remote_url.blank?
|
||||||
|
@account.header_description = header_description || ''
|
||||||
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
|
||||||
RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id)
|
RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id)
|
||||||
end
|
end
|
||||||
@ -214,7 +218,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def image_url(key)
|
def image_url_and_description(key)
|
||||||
value = first_of_value(@json[key])
|
value = first_of_value(@json[key])
|
||||||
|
|
||||||
return if value.nil?
|
return if value.nil?
|
||||||
@ -224,9 +228,21 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
return if value.nil?
|
return if value.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
value = first_of_value(value['url']) if value.is_a?(Hash) && value['type'] == 'Image'
|
if value.is_a?(Hash) && value['type'] == 'Image'
|
||||||
value = value['href'] if value.is_a?(Hash)
|
url = first_of_value(value['url'])
|
||||||
value if value.is_a?(String)
|
url = url['href'] if url.is_a?(Hash)
|
||||||
|
description = value['summary'].presence || value['name'].presence
|
||||||
|
description = description.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if description.present?
|
||||||
|
else
|
||||||
|
url = value
|
||||||
|
end
|
||||||
|
|
||||||
|
url = url['href'] if url.is_a?(Hash)
|
||||||
|
|
||||||
|
url = nil unless url.is_a?(String)
|
||||||
|
description = nil unless description.is_a?(String)
|
||||||
|
|
||||||
|
[url, description]
|
||||||
end
|
end
|
||||||
|
|
||||||
def public_key
|
def public_key
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddAvatarDescriptionToAccounts < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :accounts, :avatar_description, :string, null: false, default: ''
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddHeaderDescriptionToAccounts < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :accounts, :header_description, :string, null: false, default: ''
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_01_19_153538) do
|
ActiveRecord::Schema[8.0].define(version: 2026_01_27_141820) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
@ -201,6 +201,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_19_153538) do
|
|||||||
t.string "following_url", default: "", null: false
|
t.string "following_url", default: "", null: false
|
||||||
t.integer "id_scheme", default: 1
|
t.integer "id_scheme", default: 1
|
||||||
t.integer "feature_approval_policy", default: 0, null: false
|
t.integer "feature_approval_policy", default: 0, null: false
|
||||||
|
t.string "avatar_description", default: "", null: false
|
||||||
|
t.string "header_description", default: "", null: false
|
||||||
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
||||||
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
||||||
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"
|
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ RSpec.describe Account do
|
|||||||
|
|
||||||
describe 'Associations' do
|
describe 'Associations' do
|
||||||
it { is_expected.to have_many(:account_notes).inverse_of(:account) }
|
it { is_expected.to have_many(:account_notes).inverse_of(:account) }
|
||||||
|
it { is_expected.to have_many(:action_logs).class_name('Admin::ActionLog') }
|
||||||
it { is_expected.to have_many(:targeted_account_notes).inverse_of(:target_account) }
|
it { is_expected.to have_many(:targeted_account_notes).inverse_of(:target_account) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user