Merge pull request #2760 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 5d4dbbcc67
main-rebase-security-fix
commit
a396985c14
|
@ -132,15 +132,17 @@ jobs:
|
||||||
additional-system-dependencies: ffmpeg libpam-dev
|
additional-system-dependencies: ffmpeg libpam-dev
|
||||||
|
|
||||||
- name: Load database schema
|
- name: Load database schema
|
||||||
run: './bin/rails db:create db:schema:load db:seed'
|
run: |
|
||||||
|
bin/rails db:setup
|
||||||
|
bin/flatware fan bin/rails db:test:prepare
|
||||||
|
|
||||||
- run: bin/rspec
|
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.ruby-version == '.ruby-version'
|
if: matrix.ruby-version == '.ruby-version'
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
files: coverage/lcov/mastodon.lcov
|
files: coverage/lcov/*.lcov
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
|
5
Gemfile
5
Gemfile
|
@ -100,8 +100,6 @@ gem 'json-ld'
|
||||||
gem 'json-ld-preloaded', '~> 3.2'
|
gem 'json-ld-preloaded', '~> 3.2'
|
||||||
gem 'rdf-normalize', '~> 0.5'
|
gem 'rdf-normalize', '~> 0.5'
|
||||||
|
|
||||||
gem 'private_address_check', '~> 0.5'
|
|
||||||
|
|
||||||
gem 'opentelemetry-api', '~> 1.2.5'
|
gem 'opentelemetry-api', '~> 1.2.5'
|
||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
|
@ -123,6 +121,9 @@ group :opentelemetry do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
# Enable usage of all available CPUs/cores during spec runs
|
||||||
|
gem 'flatware-rspec'
|
||||||
|
|
||||||
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
||||||
gem 'rspec-github', '~> 2.4', require: false
|
gem 'rspec-github', '~> 2.4', require: false
|
||||||
|
|
||||||
|
|
24
Gemfile.lock
24
Gemfile.lock
|
@ -101,16 +101,16 @@ GEM
|
||||||
awrence (1.2.1)
|
awrence (1.2.1)
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.947.0)
|
aws-partitions (1.947.0)
|
||||||
aws-sdk-core (3.198.0)
|
aws-sdk-core (3.199.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.8)
|
aws-sigv4 (~> 1.8)
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.86.0)
|
aws-sdk-kms (1.87.0)
|
||||||
aws-sdk-core (~> 3, >= 3.198.0)
|
aws-sdk-core (~> 3, >= 3.199.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.153.0)
|
aws-sdk-s3 (1.154.0)
|
||||||
aws-sdk-core (~> 3, >= 3.198.0)
|
aws-sdk-core (~> 3, >= 3.199.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.8)
|
aws-sigv4 (~> 1.8)
|
||||||
aws-sigv4 (1.8.0)
|
aws-sigv4 (1.8.0)
|
||||||
|
@ -159,7 +159,7 @@ GEM
|
||||||
case_transform (0.2)
|
case_transform (0.2)
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.9.8)
|
cbor (0.5.9.8)
|
||||||
charlock_holmes (0.7.7)
|
charlock_holmes (0.7.8)
|
||||||
chewy (7.6.0)
|
chewy (7.6.0)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
elasticsearch (>= 7.14.0, < 8)
|
elasticsearch (>= 7.14.0, < 8)
|
||||||
|
@ -264,6 +264,11 @@ GEM
|
||||||
ffi-compiler (1.3.2)
|
ffi-compiler (1.3.2)
|
||||||
ffi (>= 1.15.5)
|
ffi (>= 1.15.5)
|
||||||
rake
|
rake
|
||||||
|
flatware (2.3.2)
|
||||||
|
thor (< 2.0)
|
||||||
|
flatware-rspec (2.3.2)
|
||||||
|
flatware (= 2.3.2)
|
||||||
|
rspec (>= 3.6)
|
||||||
fog-core (2.4.0)
|
fog-core (2.4.0)
|
||||||
builder
|
builder
|
||||||
excon (~> 0.71)
|
excon (~> 0.71)
|
||||||
|
@ -595,7 +600,6 @@ GEM
|
||||||
actionmailer (>= 3)
|
actionmailer (>= 3)
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
private_address_check (0.5.0)
|
|
||||||
propshaft (0.9.0)
|
propshaft (0.9.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
|
@ -701,6 +705,10 @@ GEM
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 1.0)
|
||||||
rqrcode_core (1.2.0)
|
rqrcode_core (1.2.0)
|
||||||
|
rspec (3.13.0)
|
||||||
|
rspec-core (~> 3.13.0)
|
||||||
|
rspec-expectations (~> 3.13.0)
|
||||||
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.1)
|
rspec-expectations (3.13.1)
|
||||||
|
@ -933,6 +941,7 @@ DEPENDENCIES
|
||||||
faker (~> 3.2)
|
faker (~> 3.2)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
fastimage
|
fastimage
|
||||||
|
flatware-rspec
|
||||||
fog-core (<= 2.4.0)
|
fog-core (<= 2.4.0)
|
||||||
fog-openstack (~> 1.0)
|
fog-openstack (~> 1.0)
|
||||||
fuubar (~> 2.5)
|
fuubar (~> 2.5)
|
||||||
|
@ -994,7 +1003,6 @@ DEPENDENCIES
|
||||||
pg (~> 1.5)
|
pg (~> 1.5)
|
||||||
pghero
|
pghero
|
||||||
premailer-rails
|
premailer-rails
|
||||||
private_address_check (~> 0.5)
|
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 6.0)
|
||||||
puma (~> 6.3)
|
puma (~> 6.3)
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
|
||||||
import { importFetchedAccounts } from './importer';
|
|
||||||
|
|
||||||
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
|
|
||||||
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
|
|
||||||
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
|
|
||||||
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
|
|
||||||
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const fetchDirectory = params => (dispatch) => {
|
|
||||||
dispatch(fetchDirectoryRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
|
|
||||||
dispatch(importFetchedAccounts(data));
|
|
||||||
dispatch(fetchDirectorySuccess(data));
|
|
||||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
|
||||||
}).catch(error => dispatch(fetchDirectoryFail(error)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchDirectoryRequest = () => ({
|
|
||||||
type: DIRECTORY_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchDirectorySuccess = accounts => ({
|
|
||||||
type: DIRECTORY_FETCH_SUCCESS,
|
|
||||||
accounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchDirectoryFail = error => ({
|
|
||||||
type: DIRECTORY_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectory = params => (dispatch, getState) => {
|
|
||||||
dispatch(expandDirectoryRequest());
|
|
||||||
|
|
||||||
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
|
|
||||||
|
|
||||||
api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
|
|
||||||
dispatch(importFetchedAccounts(data));
|
|
||||||
dispatch(expandDirectorySuccess(data));
|
|
||||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
|
||||||
}).catch(error => dispatch(expandDirectoryFail(error)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandDirectoryRequest = () => ({
|
|
||||||
type: DIRECTORY_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectorySuccess = accounts => ({
|
|
||||||
type: DIRECTORY_EXPAND_SUCCESS,
|
|
||||||
accounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectoryFail = error => ({
|
|
||||||
type: DIRECTORY_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { apiGetDirectory } from 'flavours/glitch/api/directory';
|
||||||
|
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
import { fetchRelationships } from './accounts';
|
||||||
|
import { importFetchedAccounts } from './importer';
|
||||||
|
|
||||||
|
export const fetchDirectory = createDataLoadingThunk(
|
||||||
|
'directory/fetch',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0]) =>
|
||||||
|
apiGetDirectory(params),
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandDirectory = createDataLoadingThunk(
|
||||||
|
'directory/expand',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
|
||||||
|
const loadedItems = getState().user_lists.getIn([
|
||||||
|
'directory',
|
||||||
|
'items',
|
||||||
|
]) as ImmutableList<unknown>;
|
||||||
|
|
||||||
|
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
|
||||||
|
},
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
|
@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) {
|
||||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card?.author_account) {
|
if (status.card) {
|
||||||
pushUnique(accounts, status.card.author_account);
|
status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||||
normalStatus.poll = status.poll.id;
|
normalStatus.poll = status.poll.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card?.author_account) {
|
if (status.card) {
|
||||||
normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
|
normalStatus.card = {
|
||||||
|
...status.card,
|
||||||
|
authors: status.card.authors.map(author => ({
|
||||||
|
...author,
|
||||||
|
accountId: author.account?.id,
|
||||||
|
account: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.filtered) {
|
if (status.filtered) {
|
||||||
|
|
|
@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => {
|
||||||
api()
|
api()
|
||||||
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
|
dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
|
||||||
dispatch(fetchTrendingLinksSuccess(data));
|
dispatch(fetchTrendingLinksSuccess(data));
|
||||||
})
|
})
|
||||||
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { apiRequestGet } from 'flavours/glitch/api';
|
||||||
|
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||||
|
|
||||||
|
export const apiGetDirectory = (
|
||||||
|
params: {
|
||||||
|
order: string;
|
||||||
|
local: boolean;
|
||||||
|
offset?: number;
|
||||||
|
},
|
||||||
|
limit = 20,
|
||||||
|
) =>
|
||||||
|
apiRequestGet<ApiAccountJSON[]>('v1/directory', {
|
||||||
|
...params,
|
||||||
|
limit,
|
||||||
|
});
|
|
@ -30,6 +30,12 @@ export interface ApiMentionJSON {
|
||||||
acct: string;
|
acct: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiPreviewCardAuthorJSON {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
account?: ApiAccountJSON;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiPreviewCardJSON {
|
export interface ApiPreviewCardJSON {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -48,6 +54,7 @@ export interface ApiPreviewCardJSON {
|
||||||
embed_url: string;
|
embed_url: string;
|
||||||
blurhash: string;
|
blurhash: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
authors: ApiPreviewCardAuthorJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiStatusJSON {
|
export interface ApiStatusJSON {
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
|
|
||||||
|
export const AccountBio: React.FC<{
|
||||||
|
note: string;
|
||||||
|
className: string;
|
||||||
|
}> = ({ note, className }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (note.length === 0 || note === '<p></p>') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} translate`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: note }}
|
||||||
|
onClickCapture={handleClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,42 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
|
export const AccountFields: React.FC<{
|
||||||
|
fields: Account['fields'];
|
||||||
|
limit: number;
|
||||||
|
}> = ({ fields, limit = -1 }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (fields.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
|
{fields.take(limit).map((pair, i) => (
|
||||||
|
<dl
|
||||||
|
key={i}
|
||||||
|
className={classNames({ verified: pair.get('verified_at') })}
|
||||||
|
>
|
||||||
|
<dt
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||||
|
className='translate'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||||
|
{pair.get('verified_at') && (
|
||||||
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,233 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
|
||||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
|
||||||
|
|
||||||
|
|
||||||
import { useAppHistory } from './router';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
|
||||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
|
||||||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
|
|
||||||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
|
|
||||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const BackButton = ({ onlyIcon }) => {
|
|
||||||
const history = useAppHistory();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const handleBackClick = useCallback(() => {
|
|
||||||
if (history.location?.state?.fromMastodon) {
|
|
||||||
history.goBack();
|
|
||||||
} else {
|
|
||||||
history.push('/');
|
|
||||||
}
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
|
|
||||||
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
|
|
||||||
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
BackButton.propTypes = {
|
|
||||||
onlyIcon: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
class ColumnHeader extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
title: PropTypes.node,
|
|
||||||
icon: PropTypes.string,
|
|
||||||
iconComponent: PropTypes.func,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
extraButton: PropTypes.node,
|
|
||||||
showBackButton: PropTypes.bool,
|
|
||||||
children: PropTypes.node,
|
|
||||||
pinned: PropTypes.bool,
|
|
||||||
placeholder: PropTypes.bool,
|
|
||||||
onPin: PropTypes.func,
|
|
||||||
onMove: PropTypes.func,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
appendContent: PropTypes.node,
|
|
||||||
collapseIssues: PropTypes.bool,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
collapsed: true,
|
|
||||||
animating: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleToggleClick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.setState({ collapsed: !this.state.collapsed, animating: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTitleClick = () => {
|
|
||||||
this.props.onClick?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveLeft = () => {
|
|
||||||
this.props.onMove(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveRight = () => {
|
|
||||||
this.props.onMove(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTransitionEnd = () => {
|
|
||||||
this.setState({ animating: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
if (!this.props.pinned) {
|
|
||||||
this.props.history.replace('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onPin();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
|
|
||||||
const { collapsed, animating } = this.state;
|
|
||||||
|
|
||||||
const wrapperClassName = classNames('column-header__wrapper', {
|
|
||||||
'active': active,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonClassName = classNames('column-header', {
|
|
||||||
'active': active,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
|
||||||
'collapsed': collapsed,
|
|
||||||
'animating': animating,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
|
||||||
'active': !collapsed,
|
|
||||||
});
|
|
||||||
|
|
||||||
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
|
||||||
|
|
||||||
if (children) {
|
|
||||||
extraContent = (
|
|
||||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (multiColumn && pinned) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
|
||||||
|
|
||||||
moveButtons = (
|
|
||||||
<div className='column-header__setting-arrows'>
|
|
||||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
|
|
||||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (multiColumn && this.props.onPin) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
|
|
||||||
backButton = <BackButton onlyIcon={!!title} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapsedContent = [
|
|
||||||
extraContent,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (multiColumn) {
|
|
||||||
collapsedContent.push(
|
|
||||||
<div key='buttons' className='column-header__advanced-buttons'>
|
|
||||||
{pinButton}
|
|
||||||
{moveButtons}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
|
|
||||||
collapseButton = (
|
|
||||||
<button
|
|
||||||
className={collapsibleButtonClassName}
|
|
||||||
title={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
onClick={this.handleToggleClick}
|
|
||||||
>
|
|
||||||
<i className='icon-with-badge'>
|
|
||||||
<Icon id='sliders' icon={SettingsIcon} />
|
|
||||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
|
||||||
</i>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTitle = (icon || iconComponent) && title;
|
|
||||||
|
|
||||||
const component = (
|
|
||||||
<div className={wrapperClassName}>
|
|
||||||
<h1 className={buttonClassName}>
|
|
||||||
{hasTitle && (
|
|
||||||
<>
|
|
||||||
{backButton}
|
|
||||||
|
|
||||||
<button onClick={this.handleTitleClick} className='column-header__title'>
|
|
||||||
{!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasTitle && backButton}
|
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
|
||||||
{extraButton}
|
|
||||||
{collapseButton}
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
|
||||||
<div className='column-header__collapsible-inner'>
|
|
||||||
{(!collapsed || animating) && collapsedContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appendContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (placeholder) {
|
|
||||||
return component;
|
|
||||||
} else {
|
|
||||||
return (<ButtonInTabsBar>
|
|
||||||
{component}
|
|
||||||
</ButtonInTabsBar>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(withIdentity(withRouter(ColumnHeader)));
|
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
|
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||||
|
import type { IconProp } from 'flavours/glitch/components/icon';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
||||||
|
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
|
||||||
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||||
|
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||||
|
moveLeft: {
|
||||||
|
id: 'column_header.moveLeft_settings',
|
||||||
|
defaultMessage: 'Move column to the left',
|
||||||
|
},
|
||||||
|
moveRight: {
|
||||||
|
id: 'column_header.moveRight_settings',
|
||||||
|
defaultMessage: 'Move column to the right',
|
||||||
|
},
|
||||||
|
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const BackButton: React.FC<{
|
||||||
|
onlyIcon: boolean;
|
||||||
|
}> = ({ onlyIcon }) => {
|
||||||
|
const history = useAppHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleBackClick = useCallback(() => {
|
||||||
|
if (history.location.state?.fromMastodon) {
|
||||||
|
history.goBack();
|
||||||
|
} else {
|
||||||
|
history.push('/');
|
||||||
|
}
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className={classNames('column-header__back-button', {
|
||||||
|
compact: onlyIcon,
|
||||||
|
})}
|
||||||
|
aria-label={intl.formatMessage(messages.back)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id='chevron-left'
|
||||||
|
icon={ArrowBackIcon}
|
||||||
|
className='column-back-button__icon'
|
||||||
|
/>
|
||||||
|
{!onlyIcon && (
|
||||||
|
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconComponent?: IconProp;
|
||||||
|
active?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
pinned?: boolean;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
extraButton?: React.ReactNode;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
placeholder?: boolean;
|
||||||
|
appendContent?: React.ReactNode;
|
||||||
|
collapseIssues?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onMove?: (arg0: number) => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeader: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconComponent,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
pinned,
|
||||||
|
multiColumn,
|
||||||
|
extraButton,
|
||||||
|
showBackButton,
|
||||||
|
placeholder,
|
||||||
|
appendContent,
|
||||||
|
collapseIssues,
|
||||||
|
onClick,
|
||||||
|
onMove,
|
||||||
|
onPin,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const history = useAppHistory();
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed((value) => !value);
|
||||||
|
setAnimating(true);
|
||||||
|
},
|
||||||
|
[setCollapsed, setAnimating],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTitleClick = useCallback(() => {
|
||||||
|
onClick?.();
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
const handleMoveLeft = useCallback(() => {
|
||||||
|
onMove?.(-1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleMoveRight = useCallback(() => {
|
||||||
|
onMove?.(1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleTransitionEnd = useCallback(() => {
|
||||||
|
setAnimating(false);
|
||||||
|
}, [setAnimating]);
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (!pinned) {
|
||||||
|
history.replace('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPin?.();
|
||||||
|
}, [history, pinned, onPin]);
|
||||||
|
|
||||||
|
const wrapperClassName = classNames('column-header__wrapper', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonClassName = classNames('column-header', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||||
|
collapsed,
|
||||||
|
animating,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||||
|
active: !collapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
extraContent = (
|
||||||
|
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiColumn && pinned) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='times' icon={CloseIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
moveButtons = (
|
||||||
|
<div className='column-header__setting-arrows'>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveLeft)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveLeft)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveLeft}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveRight)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveRight)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveRight}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (multiColumn && onPin) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='plus' icon={AddIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.pin' defaultMessage='Pin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!pinned &&
|
||||||
|
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
|
||||||
|
) {
|
||||||
|
backButton = <BackButton onlyIcon={!!title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsedContent = [extraContent];
|
||||||
|
|
||||||
|
if (multiColumn) {
|
||||||
|
collapsedContent.push(
|
||||||
|
<div key='buttons' className='column-header__advanced-buttons'>
|
||||||
|
{pinButton}
|
||||||
|
{moveButtons}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn && (children || (multiColumn && onPin))) {
|
||||||
|
collapseButton = (
|
||||||
|
<button
|
||||||
|
className={collapsibleButtonClassName}
|
||||||
|
title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
collapsed ? messages.show : messages.hide,
|
||||||
|
)}
|
||||||
|
onClick={handleToggleClick}
|
||||||
|
>
|
||||||
|
<i className='icon-with-badge'>
|
||||||
|
<Icon id='sliders' icon={SettingsIcon} />
|
||||||
|
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIcon = icon && iconComponent;
|
||||||
|
const hasTitle = hasIcon && title;
|
||||||
|
|
||||||
|
const component = (
|
||||||
|
<div className={wrapperClassName}>
|
||||||
|
<h1 className={buttonClassName}>
|
||||||
|
{hasTitle && (
|
||||||
|
<>
|
||||||
|
{backButton}
|
||||||
|
|
||||||
|
<button onClick={handleTitleClick} className='column-header__title'>
|
||||||
|
{!backButton && (
|
||||||
|
<Icon
|
||||||
|
id={icon}
|
||||||
|
icon={iconComponent}
|
||||||
|
className='column-header__icon'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasTitle && backButton}
|
||||||
|
|
||||||
|
<div className='column-header__buttons'>
|
||||||
|
{extraButton}
|
||||||
|
{collapseButton}
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={collapsibleClassName}
|
||||||
|
tabIndex={collapsed ? -1 : undefined}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
>
|
||||||
|
<div className='column-header__collapsible-inner'>
|
||||||
|
{(!collapsed || animating) && collapsedContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appendContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default ColumnHeader;
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { useIdentity } from '@/flavours/glitch/identity_context';
|
||||||
|
import {
|
||||||
|
fetchRelationships,
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FollowButton: React.FC<{
|
||||||
|
accountId: string;
|
||||||
|
}> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
const relationship = useAppSelector((state) =>
|
||||||
|
state.relationships.get(accountId),
|
||||||
|
);
|
||||||
|
const following = relationship?.following || relationship?.requested;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && signedIn) {
|
||||||
|
dispatch(fetchRelationships([accountId]));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, signedIn]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!signedIn) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'follow',
|
||||||
|
accountId: accountId,
|
||||||
|
url: account?.url,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship) return;
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return;
|
||||||
|
} else if (relationship.following || relationship.requested) {
|
||||||
|
dispatch(unfollowAccount(accountId));
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
|
let label;
|
||||||
|
|
||||||
|
if (!signedIn) {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
} else if (accountId === me) {
|
||||||
|
label = intl.formatMessage(messages.edit_profile);
|
||||||
|
} else if (!relationship) {
|
||||||
|
label = <LoadingIndicator />;
|
||||||
|
} else if (relationship.requested) {
|
||||||
|
label = intl.formatMessage(messages.cancel_follow_request);
|
||||||
|
} else if (!relationship.following && relationship.followed_by) {
|
||||||
|
label = intl.formatMessage(messages.followBack);
|
||||||
|
} else if (relationship.following) {
|
||||||
|
label = intl.formatMessage(messages.unfollow);
|
||||||
|
} else {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href='/settings/profile'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
className='button button-secondary'
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||||
|
secondary={following}
|
||||||
|
className={following ? 'button--destructive' : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useEffect, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { fetchAccount } from 'flavours/glitch/actions/accounts';
|
||||||
|
import { AccountBio } from 'flavours/glitch/components/account_bio';
|
||||||
|
import { AccountFields } from 'flavours/glitch/components/account_fields';
|
||||||
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
export const HoverCardAccount = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ accountId: string }
|
||||||
|
>(({ accountId }, ref) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && !account) {
|
||||||
|
dispatch(fetchAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id='hover-card'
|
||||||
|
role='tooltip'
|
||||||
|
className={classNames('hover-card dropdown-animation', {
|
||||||
|
'hover-card--loading': !account,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{account ? (
|
||||||
|
<>
|
||||||
|
<Permalink
|
||||||
|
to={`/@${account.acct}`}
|
||||||
|
href={account.get('url')}
|
||||||
|
className='hover-card__name'
|
||||||
|
>
|
||||||
|
<Avatar account={account} size={46} />
|
||||||
|
<DisplayName account={account} localDomain={domain} />
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
|
<div className='hover-card__text-row'>
|
||||||
|
<AccountBio
|
||||||
|
note={account.note_emojified}
|
||||||
|
className='hover-card__bio'
|
||||||
|
/>
|
||||||
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='hover-card__number'>
|
||||||
|
<ShortNumber
|
||||||
|
value={account.followers_count}
|
||||||
|
renderer={FollowersCounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FollowButton accountId={accountId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<LoadingIndicator />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HoverCardAccount.displayName = 'HoverCardAccount';
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
import type {
|
||||||
|
OffsetValue,
|
||||||
|
UsePopperOptions,
|
||||||
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
|
import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account';
|
||||||
|
import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
|
||||||
|
|
||||||
|
const offset = [-12, 4] as OffsetValue;
|
||||||
|
const enterDelay = 650;
|
||||||
|
const leaveDelay = 250;
|
||||||
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
|
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||||
|
element.matches('[data-hover-card-account]');
|
||||||
|
|
||||||
|
export const HoverCardController: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||||
|
const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleAnchorMouseEnter = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
|
||||||
|
if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
|
||||||
|
setEnterTimeout(() => {
|
||||||
|
target.setAttribute('aria-describedby', 'hover-card');
|
||||||
|
setAnchor(target);
|
||||||
|
setOpen(true);
|
||||||
|
setAccountId(
|
||||||
|
target.getAttribute('data-hover-card-account') ?? undefined,
|
||||||
|
);
|
||||||
|
}, enterDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === cardRef.current?.parentNode) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnchorMouseLeave = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (e.target === anchor || e.target === cardRef.current?.parentNode) {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
|
||||||
|
setLeaveTimeout(() => {
|
||||||
|
anchor?.removeAttribute('aria-describedby');
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
}, leaveDelay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose, location]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
|
||||||
|
document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
|
||||||
|
};
|
||||||
|
}, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
|
||||||
|
|
||||||
|
if (!accountId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
rootClose
|
||||||
|
onHide={handleClose}
|
||||||
|
show={open}
|
||||||
|
target={anchor}
|
||||||
|
placement='bottom-start'
|
||||||
|
flip
|
||||||
|
offset={offset}
|
||||||
|
popperConfig={popperConfig}
|
||||||
|
>
|
||||||
|
{({ props }) => (
|
||||||
|
<div {...props} className='hover-card-controller'>
|
||||||
|
<HoverCardAccount accountId={accountId} ref={cardRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
};
|
|
@ -181,7 +181,8 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.removeAttribute('title');
|
||||||
|
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||||
if (rewriteMentions !== 'no') {
|
if (rewriteMentions !== 'no') {
|
||||||
while (link.firstChild) link.removeChild(link.firstChild);
|
while (link.firstChild) link.removeChild(link.firstChild);
|
||||||
link.appendChild(document.createTextNode('@'));
|
link.appendChild(document.createTextNode('@'));
|
||||||
|
|
|
@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent {
|
||||||
target='_blank'
|
target='_blank'
|
||||||
onClick={this.handleAccountClick}
|
onClick={this.handleAccountClick}
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
|
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||||
>
|
>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
|
|
|
@ -38,6 +38,7 @@ export default class StatusPrepend extends PureComponent {
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
href={account.get('url')}
|
href={account.get('url')}
|
||||||
className='status__display-name'
|
className='status__display-name'
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
>
|
>
|
||||||
<bdi>
|
<bdi>
|
||||||
<strong
|
<strong
|
||||||
|
|
|
@ -1,247 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unfollowAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'flavours/glitch/actions/accounts';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
|
||||||
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
|
||||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
|
||||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
|
||||||
account: getAccount(state, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
|
||||||
onFollow(account) {
|
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmations.unfollow.message'
|
|
||||||
defaultMessage='Are you sure you want to unfollow {name}?'
|
|
||||||
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
} }),
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onBlock(account) {
|
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
dispatch(unblockAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onMute(account) {
|
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
class AccountCard extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
onFollow: PropTypes.func.isRequired,
|
|
||||||
onBlock: PropTypes.func.isRequired,
|
|
||||||
onMute: PropTypes.func.isRequired,
|
|
||||||
onDismiss: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
|
||||||
if (autoPlayGif) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
|
||||||
|
|
||||||
for (var i = 0; i < emojis.length; i++) {
|
|
||||||
let emoji = emojis[i];
|
|
||||||
emoji.src = emoji.getAttribute('data-original');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseLeave = ({ currentTarget }) => {
|
|
||||||
if (autoPlayGif) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
|
||||||
|
|
||||||
for (var i = 0; i < emojis.length; i++) {
|
|
||||||
let emoji = emojis[i];
|
|
||||||
emoji.src = emoji.getAttribute('data-static');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFollow = () => {
|
|
||||||
this.props.onFollow(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlock = () => {
|
|
||||||
this.props.onBlock(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMute = () => {
|
|
||||||
this.props.onMute(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEditProfile = () => {
|
|
||||||
window.open('/settings/profile', '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDismiss = (e) => {
|
|
||||||
const { account, onDismiss } = this.props;
|
|
||||||
onDismiss(account.get('id'));
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, intl } = this.props;
|
|
||||||
|
|
||||||
let actionBtn;
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
|
||||||
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
|
||||||
actionBtn = '';
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
|
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account-card'>
|
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
|
||||||
<div className='account-card__header'>
|
|
||||||
{this.props.onDismiss && <IconButton className='media-modal__close' title={intl.formatMessage(messages.dismissSuggestion)} icon='times' onClick={this.handleDismiss} size={20} />}
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
autoPlayGif ? account.get('header') : account.get('header_static')
|
|
||||||
}
|
|
||||||
alt=''
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__title'>
|
|
||||||
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
|
|
||||||
<DisplayName account={account} />
|
|
||||||
</div>
|
|
||||||
</Permalink>
|
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
|
||||||
<div
|
|
||||||
className='account-card__bio translate'
|
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='account-card__actions'>
|
|
||||||
<div className='account-card__counters'>
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('statuses_count')} />
|
|
||||||
<small>
|
|
||||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
{account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.followers'
|
|
||||||
defaultMessage='Followers'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('following_count')} />{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.following'
|
|
||||||
defaultMessage='Following'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__actions__button'>
|
|
||||||
{actionBtn}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
|
|
|
@ -0,0 +1,273 @@
|
||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import {
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
|
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
||||||
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
cancelFollowRequestConfirm: {
|
||||||
|
id: 'confirmations.cancel_follow_request.confirm',
|
||||||
|
defaultMessage: 'Withdraw request',
|
||||||
|
},
|
||||||
|
requested: {
|
||||||
|
id: 'account.requested',
|
||||||
|
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
||||||
|
},
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
|
unfollowConfirm: {
|
||||||
|
id: 'confirmations.unfollow.confirm',
|
||||||
|
defaultMessage: 'Unfollow',
|
||||||
|
},
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const original = emoji.getAttribute('data-original');
|
||||||
|
if (original) emoji.src = original;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const staticUrl = emoji.getAttribute('data-static');
|
||||||
|
if (staticUrl) emoji.src = staticUrl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFollow = useCallback(() => {
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (account.getIn(['relationship', 'following'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.cancel_follow_request.message'
|
||||||
|
defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch, intl]);
|
||||||
|
|
||||||
|
const handleBlock = useCallback(() => {
|
||||||
|
if (account?.relationship?.blocking) {
|
||||||
|
dispatch(unblockAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
if (account?.relationship?.muting) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleEditProfile = useCallback(() => {
|
||||||
|
window.open('/settings/profile', '_blank');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
let actionBtn;
|
||||||
|
|
||||||
|
if (me !== account.get('id')) {
|
||||||
|
if (!account.get('relationship')) {
|
||||||
|
// Wait until the relationship is loaded
|
||||||
|
actionBtn = '';
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.cancel_follow_request)}
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unmute)}
|
||||||
|
onClick={handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
disabled={account.relationship?.blocked_by}
|
||||||
|
className={classNames({
|
||||||
|
'button--destructive': account.getIn(['relationship', 'following']),
|
||||||
|
})}
|
||||||
|
text={intl.formatMessage(
|
||||||
|
account.getIn(['relationship', 'following'])
|
||||||
|
? messages.unfollow
|
||||||
|
: messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unblock)}
|
||||||
|
onClick={handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.edit_profile)}
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-card'>
|
||||||
|
<Permalink
|
||||||
|
href={account.get('url')}
|
||||||
|
to={`/@${account.get('acct')}`}
|
||||||
|
className='account-card__permalink'
|
||||||
|
>
|
||||||
|
<div className='account-card__header'>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||||
|
}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__title'>
|
||||||
|
<div className='account-card__title__avatar'>
|
||||||
|
<Avatar account={account as Account} size={56} />
|
||||||
|
</div>
|
||||||
|
<DisplayName account={account as Account} />
|
||||||
|
</div>
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
|
{account.get('note').length > 0 && (
|
||||||
|
<div
|
||||||
|
className='account-card__bio translate'
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='account-card__actions'>
|
||||||
|
<div className='account-card__counters'>
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('statuses_count')} />
|
||||||
|
<small>
|
||||||
|
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers'
|
||||||
|
defaultMessage='Followers'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('following_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.following'
|
||||||
|
defaultMessage='Following'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__actions__button'>{actionBtn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,181 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
|
||||||
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
|
|
||||||
import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
|
|
||||||
import Column from 'flavours/glitch/components/column';
|
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
|
||||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
|
||||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
|
||||||
|
|
||||||
import AccountCard from './components/account_card';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
|
||||||
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
|
|
||||||
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
|
||||||
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
|
||||||
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
|
|
||||||
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class Directory extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
accountIds: ImmutablePropTypes.list.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
columnId: PropTypes.string,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
domain: PropTypes.string.isRequired,
|
|
||||||
params: PropTypes.shape({
|
|
||||||
order: PropTypes.string,
|
|
||||||
local: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
order: null,
|
|
||||||
local: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(removeColumn(columnId));
|
|
||||||
} else {
|
|
||||||
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getParams = (props, state) => ({
|
|
||||||
order: state.order === null ? (props.params.order || 'active') : state.order,
|
|
||||||
local: state.local === null ? (props.params.local || false) : state.local,
|
|
||||||
});
|
|
||||||
|
|
||||||
handleMove = dir => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
dispatch(moveColumn(columnId, dir));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHeaderClick = () => {
|
|
||||||
this.column.scrollTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const paramsOld = this.getParams(prevProps, prevState);
|
|
||||||
const paramsNew = this.getParams(this.props, this.state);
|
|
||||||
|
|
||||||
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
|
|
||||||
dispatch(fetchDirectory(paramsNew));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.column = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeOrder = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
|
||||||
} else {
|
|
||||||
this.setState({ order: e.target.value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeLocal = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
|
|
||||||
} else {
|
|
||||||
this.setState({ local: e.target.value === '1' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadMore = () => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(expandDirectory(this.getParams(this.props, this.state)));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
|
|
||||||
const { order, local } = this.getParams(this.props, this.state);
|
|
||||||
const pinned = !!columnId;
|
|
||||||
|
|
||||||
const scrollableArea = (
|
|
||||||
<div className='scrollable'>
|
|
||||||
<div className='filter-form'>
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
|
|
||||||
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
|
|
||||||
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='directory__list'>
|
|
||||||
{isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
|
|
||||||
<AccountCard id={accountId} key={accountId} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
|
||||||
<ColumnHeader
|
|
||||||
icon='address-book-o'
|
|
||||||
iconComponent={PeopleIcon}
|
|
||||||
title={intl.formatMessage(messages.title)}
|
|
||||||
onPin={this.handlePin}
|
|
||||||
onMove={this.handleMove}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
pinned={pinned}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Directory));
|
|
|
@ -0,0 +1,219 @@
|
||||||
|
import type { ChangeEventHandler } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
|
import {
|
||||||
|
addColumn,
|
||||||
|
removeColumn,
|
||||||
|
moveColumn,
|
||||||
|
changeColumnParams,
|
||||||
|
} from 'flavours/glitch/actions/columns';
|
||||||
|
import {
|
||||||
|
fetchDirectory,
|
||||||
|
expandDirectory,
|
||||||
|
} from 'flavours/glitch/actions/directory';
|
||||||
|
import Column from 'flavours/glitch/components/column';
|
||||||
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||||
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { AccountCard } from './components/account_card';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||||
|
recentlyActive: {
|
||||||
|
id: 'directory.recently_active',
|
||||||
|
defaultMessage: 'Recently active',
|
||||||
|
},
|
||||||
|
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
||||||
|
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
||||||
|
federated: {
|
||||||
|
id: 'directory.federated',
|
||||||
|
defaultMessage: 'From known fediverse',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Directory: React.FC<{
|
||||||
|
columnId?: string;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
params?: { order: string; local?: boolean };
|
||||||
|
}> = ({ columnId, multiColumn, params }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
order: string | null;
|
||||||
|
local: boolean | null;
|
||||||
|
}>({
|
||||||
|
order: null,
|
||||||
|
local: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const column = useRef<Column>(null);
|
||||||
|
|
||||||
|
const order = state.order ?? params?.order ?? 'active';
|
||||||
|
const local = state.local ?? params?.local ?? false;
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('DIRECTORY', { order, local }));
|
||||||
|
}
|
||||||
|
}, [dispatch, columnId, order, local]);
|
||||||
|
|
||||||
|
const domain = useAppSelector((s) => s.meta.get('domain') as string);
|
||||||
|
const accountIds = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(
|
||||||
|
['directory', 'items'],
|
||||||
|
ImmutableList(),
|
||||||
|
) as ImmutableList<string>,
|
||||||
|
);
|
||||||
|
const isLoading = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(dir: number) => {
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
column.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ order: e.target.value, local: s.local }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(
|
||||||
|
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
void dispatch(expandDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
const scrollableArea = (
|
||||||
|
<div className='scrollable'>
|
||||||
|
<div className='filter-form'>
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='active'
|
||||||
|
label={intl.formatMessage(messages.recentlyActive)}
|
||||||
|
checked={order === 'active'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='new'
|
||||||
|
label={intl.formatMessage(messages.newArrivals)}
|
||||||
|
checked={order === 'new'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='1'
|
||||||
|
label={intl.formatMessage(messages.local, { domain })}
|
||||||
|
checked={local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='0'
|
||||||
|
label={intl.formatMessage(messages.federated)}
|
||||||
|
checked={!local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='directory__list'>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
accountIds.map((accountId) => (
|
||||||
|
<AccountCard accountId={accountId} key={accountId} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
ref={column}
|
||||||
|
label={intl.formatMessage(messages.title)}
|
||||||
|
>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='address-book-o'
|
||||||
|
iconComponent={PeopleIcon}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{multiColumn && !pinned ? (
|
||||||
|
// @ts-expect-error ScrollContainer is not properly typed yet
|
||||||
|
<ScrollContainer scrollKey='directory'>
|
||||||
|
{scrollableArea}
|
||||||
|
</ScrollContainer>
|
||||||
|
) : (
|
||||||
|
scrollableArea
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
|
||||||
|
export default Directory;
|
|
@ -7,8 +7,12 @@ import { useAppSelector } from 'flavours/glitch/store';
|
||||||
export const AuthorLink = ({ accountId }) => {
|
export const AuthorLink = ({ accountId }) => {
|
||||||
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
|
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
|
||||||
<Avatar account={account} size={16} />
|
<Avatar account={account} size={16} />
|
||||||
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
|
@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
import { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Card = ({ id, source }) => {
|
export const Card = ({ id, source }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
|
||||||
<div className='explore__suggestions__card__body__main__name-button'>
|
<div className='explore__suggestions__card__body__main__name-button'>
|
||||||
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
||||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={account.get('id')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -75,7 +75,7 @@ class Links extends PureComponent {
|
||||||
publisher={link.get('provider_name')}
|
publisher={link.get('provider_name')}
|
||||||
publishedAt={link.get('published_at')}
|
publishedAt={link.get('published_at')}
|
||||||
author={link.get('author_name')}
|
author={link.get('author_name')}
|
||||||
authorAccount={link.getIn(['author_account', 'id'])}
|
authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
|
||||||
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||||
thumbnail={link.get('image')}
|
thumbnail={link.get('image')}
|
||||||
thumbnailDescription={link.get('image_description')}
|
thumbnailDescription={link.get('image_description')}
|
||||||
|
|
|
@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
|
@ -79,18 +78,8 @@ Source.propTypes = {
|
||||||
const Card = ({ id, sources }) => {
|
const Card = ({ id, sources }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
|
||||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -389,6 +389,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
title={targetAccount.get('acct')}
|
title={targetAccount.get('acct')}
|
||||||
to={`/@${targetAccount.get('acct')}`}
|
to={`/@${targetAccount.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
||||||
|
data-hover-card-account={targetAccount.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
@ -423,6 +424,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
title={account.get('acct')}
|
title={account.get('acct')}
|
||||||
to={`/@${account.get('acct')}`}
|
to={`/@${account.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={displayNameHtml}
|
dangerouslySetInnerHTML={displayNameHtml}
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
|
|
@ -127,7 +127,7 @@ export default class Card extends PureComponent {
|
||||||
const interactive = card.get('type') === 'video';
|
const interactive = card.get('type') === 'video';
|
||||||
const language = card.get('language') || '';
|
const language = card.get('language') || '';
|
||||||
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
||||||
const showAuthor = !!card.get('author_account');
|
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
|
||||||
|
|
||||||
const description = (
|
const description = (
|
||||||
<div className='status-card__content'>
|
<div className='status-card__content'>
|
||||||
|
@ -233,7 +233,7 @@ export default class Card extends PureComponent {
|
||||||
{description}
|
{description}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
|
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -285,7 +285,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
||||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
<a href={status.getIn(['account', 'url'])} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import Column from '../../../components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import ColumnHeader from '../../../components/column_header';
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import type { Props as ColumnHeaderProps } from 'flavours/glitch/components/column_header';
|
||||||
|
|
||||||
interface Props {
|
export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
|
||||||
multiColumn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ColumnLoading: React.FC<Props> = (otherProps) => (
|
|
||||||
<Column>
|
<Column>
|
||||||
<ColumnHeader {...otherProps} />
|
<ColumnHeader {...otherProps} />
|
||||||
<div className='scrollable' />
|
<div className='scrollable' />
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||||
import { changeLayout } from 'flavours/glitch/actions/app';
|
import { changeLayout } from 'flavours/glitch/actions/app';
|
||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
||||||
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
||||||
|
import { HoverCardController } from 'flavours/glitch/components/hover_card_controller';
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
@ -648,6 +649,7 @@ class UI extends PureComponent {
|
||||||
|
|
||||||
{layout !== 'mobile' && <PictureInPicture />}
|
{layout !== 'mobile' && <PictureInPicture />}
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
|
{/* Temporarily disabled while upstream improves the issue */ null && <HoverCardController />}
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<LoadingBarContainer className='loading-bar' />
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openURL } from 'flavours/glitch/actions/search';
|
||||||
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.classList.contains('mention');
|
||||||
|
|
||||||
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.textContent?.[0] === '#' ||
|
||||||
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
|
export const useLinks = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleHashtagClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
const { textContent } = element;
|
||||||
|
|
||||||
|
if (!textContent) return;
|
||||||
|
|
||||||
|
history.push(`/tags/${textContent.replace(/^#/, '')}`);
|
||||||
|
},
|
||||||
|
[history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
dispatch(
|
||||||
|
openURL(element.href, history, () => {
|
||||||
|
window.location.href = element.href;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const target = (e.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
|
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMentionClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMentionClick(target);
|
||||||
|
} else if (isHashtagClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleHashtagClick(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleMentionClick, handleHashtagClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return handleClick;
|
||||||
|
};
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useTimeout = () => {
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const set = useCallback((callback: () => void, delay: number) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(callback, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
[cancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [set, cancel] as const;
|
||||||
|
};
|
|
@ -155,6 +155,5 @@
|
||||||
"status.in_reply_to": "This toot is a reply",
|
"status.in_reply_to": "This toot is a reply",
|
||||||
"status.is_poll": "This toot is a poll",
|
"status.is_poll": "This toot is a poll",
|
||||||
"status.local_only": "Only visible from your instance",
|
"status.local_only": "Only visible from your instance",
|
||||||
"status.uncollapse": "Uncollapse",
|
"status.uncollapse": "Uncollapse"
|
||||||
"suggestions.dismiss": "Dismiss suggestion"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DIRECTORY_FETCH_REQUEST,
|
expandDirectory,
|
||||||
DIRECTORY_FETCH_SUCCESS,
|
fetchDirectory
|
||||||
DIRECTORY_FETCH_FAIL,
|
|
||||||
DIRECTORY_EXPAND_REQUEST,
|
|
||||||
DIRECTORY_EXPAND_SUCCESS,
|
|
||||||
DIRECTORY_EXPAND_FAIL,
|
|
||||||
} from 'flavours/glitch/actions/directory';
|
} from 'flavours/glitch/actions/directory';
|
||||||
import {
|
import {
|
||||||
FEATURED_TAGS_FETCH_REQUEST,
|
FEATURED_TAGS_FETCH_REQUEST,
|
||||||
|
@ -117,6 +113,7 @@ const normalizeFeaturedTags = (state, path, featuredTags, accountId) => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||||
export default function userLists(state = initialState, action) {
|
export default function userLists(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case FOLLOWERS_FETCH_SUCCESS:
|
case FOLLOWERS_FETCH_SUCCESS:
|
||||||
|
@ -194,16 +191,6 @@ export default function userLists(state = initialState, action) {
|
||||||
case MUTES_FETCH_FAIL:
|
case MUTES_FETCH_FAIL:
|
||||||
case MUTES_EXPAND_FAIL:
|
case MUTES_EXPAND_FAIL:
|
||||||
return state.setIn(['mutes', 'isLoading'], false);
|
return state.setIn(['mutes', 'isLoading'], false);
|
||||||
case DIRECTORY_FETCH_SUCCESS:
|
|
||||||
return normalizeList(state, ['directory'], action.accounts, action.next);
|
|
||||||
case DIRECTORY_EXPAND_SUCCESS:
|
|
||||||
return appendToList(state, ['directory'], action.accounts, action.next);
|
|
||||||
case DIRECTORY_FETCH_REQUEST:
|
|
||||||
case DIRECTORY_EXPAND_REQUEST:
|
|
||||||
return state.setIn(['directory', 'isLoading'], true);
|
|
||||||
case DIRECTORY_FETCH_FAIL:
|
|
||||||
case DIRECTORY_EXPAND_FAIL:
|
|
||||||
return state.setIn(['directory', 'isLoading'], false);
|
|
||||||
case FEATURED_TAGS_FETCH_SUCCESS:
|
case FEATURED_TAGS_FETCH_SUCCESS:
|
||||||
return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
|
return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
|
||||||
case FEATURED_TAGS_FETCH_REQUEST:
|
case FEATURED_TAGS_FETCH_REQUEST:
|
||||||
|
@ -211,6 +198,17 @@ export default function userLists(state = initialState, action) {
|
||||||
case FEATURED_TAGS_FETCH_FAIL:
|
case FEATURED_TAGS_FETCH_FAIL:
|
||||||
return state.setIn(['featured_tags', action.id, 'isLoading'], false);
|
return state.setIn(['featured_tags', action.id, 'isLoading'], false);
|
||||||
default:
|
default:
|
||||||
|
if(fetchDirectory.fulfilled.match(action))
|
||||||
|
return normalizeList(state, ['directory'], action.payload.accounts, undefined);
|
||||||
|
else if( expandDirectory.fulfilled.match(action))
|
||||||
|
return appendToList(state, ['directory'], action.payload.accounts, undefined);
|
||||||
|
else if(fetchDirectory.pending.match(action) ||
|
||||||
|
expandDirectory.pending.match(action))
|
||||||
|
return state.setIn(['directory', 'isLoading'], true);
|
||||||
|
else if(fetchDirectory.rejected.match(action) ||
|
||||||
|
expandDirectory.rejected.match(action))
|
||||||
|
return state.setIn(['directory', 'isLoading'], false);
|
||||||
|
else
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,8 +120,27 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&.button--destructive {
|
||||||
opacity: 0.5;
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: $ui-button-destructive-focus-background-color;
|
||||||
|
color: $ui-button-destructive-focus-background-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-color: $ui-primary-color;
|
||||||
|
color: $ui-primary-color;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: $ui-primary-color;
|
||||||
|
color: $ui-primary-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2629,7 +2648,7 @@ a.account__display-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-animation {
|
.dropdown-animation {
|
||||||
animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
|
animation: dropdown 250ms cubic-bezier(0.1, 0.7, 0.1, 1);
|
||||||
|
|
||||||
@keyframes dropdown {
|
@keyframes dropdown {
|
||||||
from {
|
from {
|
||||||
|
@ -10908,3 +10927,156 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover-card-controller[data-popper-reference-hidden='true'] {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card {
|
||||||
|
box-shadow: var(--dropdown-shadow);
|
||||||
|
background: var(--modal-background-color);
|
||||||
|
backdrop-filter: var(--background-filter);
|
||||||
|
border: 1px solid var(--modal-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
width: 270px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__number {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: $secondary-text-color;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
max-height: 2 * 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
bdi {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__account {
|
||||||
|
display: block;
|
||||||
|
color: $dark-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-fields {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $dark-text-color;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.verified {
|
||||||
|
dd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $valid-value-color;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -56,11 +56,13 @@ $account-background-color: $white !default;
|
||||||
|
|
||||||
$emojis-requiring-inversion: 'chains';
|
$emojis-requiring-inversion: 'chains';
|
||||||
|
|
||||||
.theme-mastodon-light {
|
body {
|
||||||
--dropdown-border-color: #d9e1e8;
|
--dropdown-border-color: #d9e1e8;
|
||||||
--dropdown-background-color: #fff;
|
--dropdown-background-color: #fff;
|
||||||
|
--modal-border-color: #d9e1e8;
|
||||||
|
--modal-background-color: var(--background-color-tint);
|
||||||
--background-border-color: #d9e1e8;
|
--background-border-color: #d9e1e8;
|
||||||
--background-color: #fff;
|
--background-color: #fff;
|
||||||
--background-color-tint: rgba(255, 255, 255, 90%);
|
--background-color-tint: rgba(255, 255, 255, 80%);
|
||||||
--background-filter: blur(10px);
|
--background-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openURL } from 'mastodon/actions/search';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.classList.contains('mention');
|
||||||
|
|
||||||
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.textContent?.[0] === '#' ||
|
||||||
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
|
export const useLinks = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleHashtagClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
const { textContent } = element;
|
||||||
|
|
||||||
|
if (!textContent) return;
|
||||||
|
|
||||||
|
history.push(`/tags/${textContent.replace(/^#/, '')}`);
|
||||||
|
},
|
||||||
|
[history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
dispatch(
|
||||||
|
openURL(element.href, history, () => {
|
||||||
|
window.location.href = element.href;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const target = (e.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
|
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMentionClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMentionClick(target);
|
||||||
|
} else if (isHashtagClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleHashtagClick(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleMentionClick, handleHashtagClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return handleClick;
|
||||||
|
};
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useTimeout = () => {
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const set = useCallback((callback: () => void, delay: number) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(callback, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
[cancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [set, cancel] as const;
|
||||||
|
};
|
|
@ -1,62 +0,0 @@
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
|
||||||
import { importFetchedAccounts } from './importer';
|
|
||||||
|
|
||||||
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
|
|
||||||
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
|
|
||||||
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
|
|
||||||
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
|
|
||||||
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const fetchDirectory = params => (dispatch) => {
|
|
||||||
dispatch(fetchDirectoryRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
|
|
||||||
dispatch(importFetchedAccounts(data));
|
|
||||||
dispatch(fetchDirectorySuccess(data));
|
|
||||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
|
||||||
}).catch(error => dispatch(fetchDirectoryFail(error)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchDirectoryRequest = () => ({
|
|
||||||
type: DIRECTORY_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchDirectorySuccess = accounts => ({
|
|
||||||
type: DIRECTORY_FETCH_SUCCESS,
|
|
||||||
accounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchDirectoryFail = error => ({
|
|
||||||
type: DIRECTORY_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectory = params => (dispatch, getState) => {
|
|
||||||
dispatch(expandDirectoryRequest());
|
|
||||||
|
|
||||||
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
|
|
||||||
|
|
||||||
api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
|
|
||||||
dispatch(importFetchedAccounts(data));
|
|
||||||
dispatch(expandDirectorySuccess(data));
|
|
||||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
|
||||||
}).catch(error => dispatch(expandDirectoryFail(error)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandDirectoryRequest = () => ({
|
|
||||||
type: DIRECTORY_EXPAND_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectorySuccess = accounts => ({
|
|
||||||
type: DIRECTORY_EXPAND_SUCCESS,
|
|
||||||
accounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandDirectoryFail = error => ({
|
|
||||||
type: DIRECTORY_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { apiGetDirectory } from 'mastodon/api/directory';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { fetchRelationships } from './accounts';
|
||||||
|
import { importFetchedAccounts } from './importer';
|
||||||
|
|
||||||
|
export const fetchDirectory = createDataLoadingThunk(
|
||||||
|
'directory/fetch',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0]) =>
|
||||||
|
apiGetDirectory(params),
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandDirectory = createDataLoadingThunk(
|
||||||
|
'directory/expand',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
|
||||||
|
const loadedItems = getState().user_lists.getIn([
|
||||||
|
'directory',
|
||||||
|
'items',
|
||||||
|
]) as ImmutableList<unknown>;
|
||||||
|
|
||||||
|
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
|
||||||
|
},
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
|
@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) {
|
||||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card?.author_account) {
|
if (status.card) {
|
||||||
pushUnique(accounts, status.card.author_account);
|
status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
normalStatus.poll = status.poll.id;
|
normalStatus.poll = status.poll.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card?.author_account) {
|
if (status.card) {
|
||||||
normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
|
normalStatus.card = {
|
||||||
|
...status.card,
|
||||||
|
authors: status.card.authors.map(author => ({
|
||||||
|
...author,
|
||||||
|
accountId: author.account?.id,
|
||||||
|
account: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.filtered) {
|
if (status.filtered) {
|
||||||
|
|
|
@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => {
|
||||||
api()
|
api()
|
||||||
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
|
dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
|
||||||
dispatch(fetchTrendingLinksSuccess(data));
|
dispatch(fetchTrendingLinksSuccess(data));
|
||||||
})
|
})
|
||||||
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
|
||||||
|
export const apiGetDirectory = (
|
||||||
|
params: {
|
||||||
|
order: string;
|
||||||
|
local: boolean;
|
||||||
|
offset?: number;
|
||||||
|
},
|
||||||
|
limit = 20,
|
||||||
|
) =>
|
||||||
|
apiRequestGet<ApiAccountJSON[]>('v1/directory', {
|
||||||
|
...params,
|
||||||
|
limit,
|
||||||
|
});
|
|
@ -30,6 +30,12 @@ export interface ApiMentionJSON {
|
||||||
acct: string;
|
acct: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiPreviewCardAuthorJSON {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
account?: ApiAccountJSON;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiPreviewCardJSON {
|
export interface ApiPreviewCardJSON {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -48,6 +54,7 @@ export interface ApiPreviewCardJSON {
|
||||||
embed_url: string;
|
embed_url: string;
|
||||||
blurhash: string;
|
blurhash: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
authors: ApiPreviewCardAuthorJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiStatusJSON {
|
export interface ApiStatusJSON {
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||||
|
|
||||||
|
export const AccountBio: React.FC<{
|
||||||
|
note: string;
|
||||||
|
className: string;
|
||||||
|
}> = ({ note, className }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (note.length === 0 || note === '<p></p>') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} translate`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: note }}
|
||||||
|
onClickCapture={handleClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,42 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
|
||||||
|
export const AccountFields: React.FC<{
|
||||||
|
fields: Account['fields'];
|
||||||
|
limit: number;
|
||||||
|
}> = ({ fields, limit = -1 }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (fields.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
|
{fields.take(limit).map((pair, i) => (
|
||||||
|
<dl
|
||||||
|
key={i}
|
||||||
|
className={classNames({ verified: pair.get('verified_at') })}
|
||||||
|
>
|
||||||
|
<dt
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||||
|
className='translate'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||||
|
{pair.get('verified_at') && (
|
||||||
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,233 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
|
||||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|
||||||
|
|
||||||
|
|
||||||
import { useAppHistory } from './router';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
|
||||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
|
||||||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
|
|
||||||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
|
|
||||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const BackButton = ({ onlyIcon }) => {
|
|
||||||
const history = useAppHistory();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const handleBackClick = useCallback(() => {
|
|
||||||
if (history.location?.state?.fromMastodon) {
|
|
||||||
history.goBack();
|
|
||||||
} else {
|
|
||||||
history.push('/');
|
|
||||||
}
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
|
|
||||||
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
|
|
||||||
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
BackButton.propTypes = {
|
|
||||||
onlyIcon: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
class ColumnHeader extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
title: PropTypes.node,
|
|
||||||
icon: PropTypes.string,
|
|
||||||
iconComponent: PropTypes.func,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
extraButton: PropTypes.node,
|
|
||||||
showBackButton: PropTypes.bool,
|
|
||||||
children: PropTypes.node,
|
|
||||||
pinned: PropTypes.bool,
|
|
||||||
placeholder: PropTypes.bool,
|
|
||||||
onPin: PropTypes.func,
|
|
||||||
onMove: PropTypes.func,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
appendContent: PropTypes.node,
|
|
||||||
collapseIssues: PropTypes.bool,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
collapsed: true,
|
|
||||||
animating: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleToggleClick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.setState({ collapsed: !this.state.collapsed, animating: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTitleClick = () => {
|
|
||||||
this.props.onClick?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveLeft = () => {
|
|
||||||
this.props.onMove(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveRight = () => {
|
|
||||||
this.props.onMove(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTransitionEnd = () => {
|
|
||||||
this.setState({ animating: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
if (!this.props.pinned) {
|
|
||||||
this.props.history.replace('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onPin();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
|
|
||||||
const { collapsed, animating } = this.state;
|
|
||||||
|
|
||||||
const wrapperClassName = classNames('column-header__wrapper', {
|
|
||||||
'active': active,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonClassName = classNames('column-header', {
|
|
||||||
'active': active,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
|
||||||
'collapsed': collapsed,
|
|
||||||
'animating': animating,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
|
||||||
'active': !collapsed,
|
|
||||||
});
|
|
||||||
|
|
||||||
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
|
||||||
|
|
||||||
if (children) {
|
|
||||||
extraContent = (
|
|
||||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (multiColumn && pinned) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
|
||||||
|
|
||||||
moveButtons = (
|
|
||||||
<div className='column-header__setting-arrows'>
|
|
||||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
|
|
||||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (multiColumn && this.props.onPin) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
|
|
||||||
backButton = <BackButton onlyIcon={!!title} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapsedContent = [
|
|
||||||
extraContent,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (multiColumn) {
|
|
||||||
collapsedContent.push(
|
|
||||||
<div key='buttons' className='column-header__advanced-buttons'>
|
|
||||||
{pinButton}
|
|
||||||
{moveButtons}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
|
|
||||||
collapseButton = (
|
|
||||||
<button
|
|
||||||
className={collapsibleButtonClassName}
|
|
||||||
title={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
onClick={this.handleToggleClick}
|
|
||||||
>
|
|
||||||
<i className='icon-with-badge'>
|
|
||||||
<Icon id='sliders' icon={SettingsIcon} />
|
|
||||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
|
||||||
</i>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTitle = (icon || iconComponent) && title;
|
|
||||||
|
|
||||||
const component = (
|
|
||||||
<div className={wrapperClassName}>
|
|
||||||
<h1 className={buttonClassName}>
|
|
||||||
{hasTitle && (
|
|
||||||
<>
|
|
||||||
{backButton}
|
|
||||||
|
|
||||||
<button onClick={this.handleTitleClick} className='column-header__title'>
|
|
||||||
{!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasTitle && backButton}
|
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
|
||||||
{extraButton}
|
|
||||||
{collapseButton}
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
|
||||||
<div className='column-header__collapsible-inner'>
|
|
||||||
{(!collapsed || animating) && collapsedContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appendContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (placeholder) {
|
|
||||||
return component;
|
|
||||||
} else {
|
|
||||||
return (<ButtonInTabsBar>
|
|
||||||
{component}
|
|
||||||
</ButtonInTabsBar>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(withIdentity(withRouter(ColumnHeader)));
|
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
|
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||||
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
|
||||||
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||||
|
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||||
|
moveLeft: {
|
||||||
|
id: 'column_header.moveLeft_settings',
|
||||||
|
defaultMessage: 'Move column to the left',
|
||||||
|
},
|
||||||
|
moveRight: {
|
||||||
|
id: 'column_header.moveRight_settings',
|
||||||
|
defaultMessage: 'Move column to the right',
|
||||||
|
},
|
||||||
|
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const BackButton: React.FC<{
|
||||||
|
onlyIcon: boolean;
|
||||||
|
}> = ({ onlyIcon }) => {
|
||||||
|
const history = useAppHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleBackClick = useCallback(() => {
|
||||||
|
if (history.location.state?.fromMastodon) {
|
||||||
|
history.goBack();
|
||||||
|
} else {
|
||||||
|
history.push('/');
|
||||||
|
}
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className={classNames('column-header__back-button', {
|
||||||
|
compact: onlyIcon,
|
||||||
|
})}
|
||||||
|
aria-label={intl.formatMessage(messages.back)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id='chevron-left'
|
||||||
|
icon={ArrowBackIcon}
|
||||||
|
className='column-back-button__icon'
|
||||||
|
/>
|
||||||
|
{!onlyIcon && (
|
||||||
|
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconComponent?: IconProp;
|
||||||
|
active?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
pinned?: boolean;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
extraButton?: React.ReactNode;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
placeholder?: boolean;
|
||||||
|
appendContent?: React.ReactNode;
|
||||||
|
collapseIssues?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onMove?: (arg0: number) => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeader: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconComponent,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
pinned,
|
||||||
|
multiColumn,
|
||||||
|
extraButton,
|
||||||
|
showBackButton,
|
||||||
|
placeholder,
|
||||||
|
appendContent,
|
||||||
|
collapseIssues,
|
||||||
|
onClick,
|
||||||
|
onMove,
|
||||||
|
onPin,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const history = useAppHistory();
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed((value) => !value);
|
||||||
|
setAnimating(true);
|
||||||
|
},
|
||||||
|
[setCollapsed, setAnimating],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTitleClick = useCallback(() => {
|
||||||
|
onClick?.();
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
const handleMoveLeft = useCallback(() => {
|
||||||
|
onMove?.(-1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleMoveRight = useCallback(() => {
|
||||||
|
onMove?.(1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleTransitionEnd = useCallback(() => {
|
||||||
|
setAnimating(false);
|
||||||
|
}, [setAnimating]);
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (!pinned) {
|
||||||
|
history.replace('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPin?.();
|
||||||
|
}, [history, pinned, onPin]);
|
||||||
|
|
||||||
|
const wrapperClassName = classNames('column-header__wrapper', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonClassName = classNames('column-header', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||||
|
collapsed,
|
||||||
|
animating,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||||
|
active: !collapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
extraContent = (
|
||||||
|
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiColumn && pinned) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='times' icon={CloseIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
moveButtons = (
|
||||||
|
<div className='column-header__setting-arrows'>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveLeft)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveLeft)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveLeft}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveRight)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveRight)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveRight}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (multiColumn && onPin) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='plus' icon={AddIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.pin' defaultMessage='Pin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!pinned &&
|
||||||
|
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
|
||||||
|
) {
|
||||||
|
backButton = <BackButton onlyIcon={!!title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsedContent = [extraContent];
|
||||||
|
|
||||||
|
if (multiColumn) {
|
||||||
|
collapsedContent.push(
|
||||||
|
<div key='buttons' className='column-header__advanced-buttons'>
|
||||||
|
{pinButton}
|
||||||
|
{moveButtons}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn && (children || (multiColumn && onPin))) {
|
||||||
|
collapseButton = (
|
||||||
|
<button
|
||||||
|
className={collapsibleButtonClassName}
|
||||||
|
title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
collapsed ? messages.show : messages.hide,
|
||||||
|
)}
|
||||||
|
onClick={handleToggleClick}
|
||||||
|
>
|
||||||
|
<i className='icon-with-badge'>
|
||||||
|
<Icon id='sliders' icon={SettingsIcon} />
|
||||||
|
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIcon = icon && iconComponent;
|
||||||
|
const hasTitle = hasIcon && title;
|
||||||
|
|
||||||
|
const component = (
|
||||||
|
<div className={wrapperClassName}>
|
||||||
|
<h1 className={buttonClassName}>
|
||||||
|
{hasTitle && (
|
||||||
|
<>
|
||||||
|
{backButton}
|
||||||
|
|
||||||
|
<button onClick={handleTitleClick} className='column-header__title'>
|
||||||
|
{!backButton && (
|
||||||
|
<Icon
|
||||||
|
id={icon}
|
||||||
|
icon={iconComponent}
|
||||||
|
className='column-header__icon'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasTitle && backButton}
|
||||||
|
|
||||||
|
<div className='column-header__buttons'>
|
||||||
|
{extraButton}
|
||||||
|
{collapseButton}
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={collapsibleClassName}
|
||||||
|
tabIndex={collapsed ? -1 : undefined}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
>
|
||||||
|
<div className='column-header__collapsible-inner'>
|
||||||
|
{(!collapsed || animating) && collapsedContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appendContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default ColumnHeader;
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { useIdentity } from '@/mastodon/identity_context';
|
||||||
|
import {
|
||||||
|
fetchRelationships,
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { Button } from 'mastodon/components/button';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
|
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FollowButton: React.FC<{
|
||||||
|
accountId: string;
|
||||||
|
}> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
const relationship = useAppSelector((state) =>
|
||||||
|
state.relationships.get(accountId),
|
||||||
|
);
|
||||||
|
const following = relationship?.following || relationship?.requested;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && signedIn) {
|
||||||
|
dispatch(fetchRelationships([accountId]));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, signedIn]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!signedIn) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'follow',
|
||||||
|
accountId: accountId,
|
||||||
|
url: account?.url,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship) return;
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return;
|
||||||
|
} else if (relationship.following || relationship.requested) {
|
||||||
|
dispatch(unfollowAccount(accountId));
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
|
let label;
|
||||||
|
|
||||||
|
if (!signedIn) {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
} else if (accountId === me) {
|
||||||
|
label = intl.formatMessage(messages.edit_profile);
|
||||||
|
} else if (!relationship) {
|
||||||
|
label = <LoadingIndicator />;
|
||||||
|
} else if (relationship.requested) {
|
||||||
|
label = intl.formatMessage(messages.cancel_follow_request);
|
||||||
|
} else if (relationship.following && relationship.followed_by) {
|
||||||
|
label = intl.formatMessage(messages.mutual);
|
||||||
|
} else if (!relationship.following && relationship.followed_by) {
|
||||||
|
label = intl.formatMessage(messages.followBack);
|
||||||
|
} else if (relationship.following) {
|
||||||
|
label = intl.formatMessage(messages.unfollow);
|
||||||
|
} else {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href='/settings/profile'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
className='button button-secondary'
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||||
|
secondary={following}
|
||||||
|
className={following ? 'button--destructive' : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useEffect, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { fetchAccount } from 'mastodon/actions/accounts';
|
||||||
|
import { AccountBio } from 'mastodon/components/account_bio';
|
||||||
|
import { AccountFields } from 'mastodon/components/account_fields';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
|
import { FollowersCounter } from 'mastodon/components/counters';
|
||||||
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
export const HoverCardAccount = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ accountId: string }
|
||||||
|
>(({ accountId }, ref) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && !account) {
|
||||||
|
dispatch(fetchAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id='hover-card'
|
||||||
|
role='tooltip'
|
||||||
|
className={classNames('hover-card dropdown-animation', {
|
||||||
|
'hover-card--loading': !account,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{account ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/@${account.acct}`} className='hover-card__name'>
|
||||||
|
<Avatar account={account} size={46} />
|
||||||
|
<DisplayName account={account} localDomain={domain} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className='hover-card__text-row'>
|
||||||
|
<AccountBio
|
||||||
|
note={account.note_emojified}
|
||||||
|
className='hover-card__bio'
|
||||||
|
/>
|
||||||
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='hover-card__number'>
|
||||||
|
<ShortNumber
|
||||||
|
value={account.followers_count}
|
||||||
|
renderer={FollowersCounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FollowButton accountId={accountId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<LoadingIndicator />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HoverCardAccount.displayName = 'HoverCardAccount';
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
import type {
|
||||||
|
OffsetValue,
|
||||||
|
UsePopperOptions,
|
||||||
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
|
import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||||
|
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
|
||||||
|
|
||||||
|
const offset = [-12, 4] as OffsetValue;
|
||||||
|
const enterDelay = 650;
|
||||||
|
const leaveDelay = 250;
|
||||||
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
|
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||||
|
element.matches('[data-hover-card-account]');
|
||||||
|
|
||||||
|
export const HoverCardController: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||||
|
const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleAnchorMouseEnter = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
|
||||||
|
if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
|
||||||
|
setEnterTimeout(() => {
|
||||||
|
target.setAttribute('aria-describedby', 'hover-card');
|
||||||
|
setAnchor(target);
|
||||||
|
setOpen(true);
|
||||||
|
setAccountId(
|
||||||
|
target.getAttribute('data-hover-card-account') ?? undefined,
|
||||||
|
);
|
||||||
|
}, enterDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === cardRef.current?.parentNode) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnchorMouseLeave = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (e.target === anchor || e.target === cardRef.current?.parentNode) {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
|
||||||
|
setLeaveTimeout(() => {
|
||||||
|
anchor?.removeAttribute('aria-describedby');
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
}, leaveDelay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose, location]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
|
||||||
|
document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
|
||||||
|
};
|
||||||
|
}, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
|
||||||
|
|
||||||
|
if (!accountId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
rootClose
|
||||||
|
onHide={handleClose}
|
||||||
|
show={open}
|
||||||
|
target={anchor}
|
||||||
|
placement='bottom-start'
|
||||||
|
flip
|
||||||
|
offset={offset}
|
||||||
|
popperConfig={popperConfig}
|
||||||
|
>
|
||||||
|
{({ props }) => (
|
||||||
|
<div {...props} className='hover-card-controller'>
|
||||||
|
<HoverCardAccount accountId={accountId} ref={cardRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
};
|
|
@ -425,7 +425,7 @@ class Status extends ImmutablePureComponent {
|
||||||
prepend = (
|
prepend = (
|
||||||
<div className='status__prepend'>
|
<div className='status__prepend'>
|
||||||
<div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
|
<div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
|
||||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
|
||||||
prepend = (
|
prepend = (
|
||||||
<div className='status__prepend'>
|
<div className='status__prepend'>
|
||||||
<div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
|
<div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
|
||||||
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -562,7 +562,7 @@ class Status extends ImmutablePureComponent {
|
||||||
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -116,8 +116,9 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.removeAttribute('title');
|
||||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
link.setAttribute('href', `/@${mention.get('acct')}`);
|
||||||
|
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||||
|
|
|
@ -1,234 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unfollowAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'mastodon/actions/accounts';
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
|
||||||
import { autoPlayGif, me } from 'mastodon/initial_state';
|
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
|
||||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
|
||||||
account: getAccount(state, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
|
||||||
onFollow(account) {
|
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmations.unfollow.message'
|
|
||||||
defaultMessage='Are you sure you want to unfollow {name}?'
|
|
||||||
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
} }),
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onBlock(account) {
|
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
dispatch(unblockAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onMute(account) {
|
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
class AccountCard extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
onFollow: PropTypes.func.isRequired,
|
|
||||||
onBlock: PropTypes.func.isRequired,
|
|
||||||
onMute: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
|
||||||
if (autoPlayGif) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
|
||||||
|
|
||||||
for (var i = 0; i < emojis.length; i++) {
|
|
||||||
let emoji = emojis[i];
|
|
||||||
emoji.src = emoji.getAttribute('data-original');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseLeave = ({ currentTarget }) => {
|
|
||||||
if (autoPlayGif) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
|
||||||
|
|
||||||
for (var i = 0; i < emojis.length; i++) {
|
|
||||||
let emoji = emojis[i];
|
|
||||||
emoji.src = emoji.getAttribute('data-static');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFollow = () => {
|
|
||||||
this.props.onFollow(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlock = () => {
|
|
||||||
this.props.onBlock(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMute = () => {
|
|
||||||
this.props.onMute(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEditProfile = () => {
|
|
||||||
window.open('/settings/profile', '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, intl } = this.props;
|
|
||||||
|
|
||||||
let actionBtn;
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
|
||||||
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
|
||||||
actionBtn = '';
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
|
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account-card'>
|
|
||||||
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
|
||||||
<div className='account-card__header'>
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
autoPlayGif ? account.get('header') : account.get('header_static')
|
|
||||||
}
|
|
||||||
alt=''
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__title'>
|
|
||||||
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
|
|
||||||
<DisplayName account={account} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
|
||||||
<div
|
|
||||||
className='account-card__bio translate'
|
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='account-card__actions'>
|
|
||||||
<div className='account-card__counters'>
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('statuses_count')} />
|
|
||||||
<small>
|
|
||||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('followers_count')} />{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.followers'
|
|
||||||
defaultMessage='Followers'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('following_count')} />{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.following'
|
|
||||||
defaultMessage='Following'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__actions__button'>
|
|
||||||
{actionBtn}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
|
|
|
@ -0,0 +1,269 @@
|
||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
|
import { Button } from 'mastodon/components/button';
|
||||||
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
|
import { autoPlayGif, me } from 'mastodon/initial_state';
|
||||||
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
import { makeGetAccount } from 'mastodon/selectors';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
cancelFollowRequestConfirm: {
|
||||||
|
id: 'confirmations.cancel_follow_request.confirm',
|
||||||
|
defaultMessage: 'Withdraw request',
|
||||||
|
},
|
||||||
|
requested: {
|
||||||
|
id: 'account.requested',
|
||||||
|
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
||||||
|
},
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
|
unfollowConfirm: {
|
||||||
|
id: 'confirmations.unfollow.confirm',
|
||||||
|
defaultMessage: 'Unfollow',
|
||||||
|
},
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const original = emoji.getAttribute('data-original');
|
||||||
|
if (original) emoji.src = original;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const staticUrl = emoji.getAttribute('data-static');
|
||||||
|
if (staticUrl) emoji.src = staticUrl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFollow = useCallback(() => {
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (account.getIn(['relationship', 'following'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.cancel_follow_request.message'
|
||||||
|
defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch, intl]);
|
||||||
|
|
||||||
|
const handleBlock = useCallback(() => {
|
||||||
|
if (account?.relationship?.blocking) {
|
||||||
|
dispatch(unblockAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
if (account?.relationship?.muting) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleEditProfile = useCallback(() => {
|
||||||
|
window.open('/settings/profile', '_blank');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
let actionBtn;
|
||||||
|
|
||||||
|
if (me !== account.get('id')) {
|
||||||
|
if (!account.get('relationship')) {
|
||||||
|
// Wait until the relationship is loaded
|
||||||
|
actionBtn = '';
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.cancel_follow_request)}
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unmute)}
|
||||||
|
onClick={handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
disabled={account.relationship?.blocked_by}
|
||||||
|
className={classNames({
|
||||||
|
'button--destructive': account.getIn(['relationship', 'following']),
|
||||||
|
})}
|
||||||
|
text={intl.formatMessage(
|
||||||
|
account.getIn(['relationship', 'following'])
|
||||||
|
? messages.unfollow
|
||||||
|
: messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unblock)}
|
||||||
|
onClick={handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.edit_profile)}
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-card'>
|
||||||
|
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
||||||
|
<div className='account-card__header'>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||||
|
}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__title'>
|
||||||
|
<div className='account-card__title__avatar'>
|
||||||
|
<Avatar account={account as Account} size={56} />
|
||||||
|
</div>
|
||||||
|
<DisplayName account={account as Account} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{account.get('note').length > 0 && (
|
||||||
|
<div
|
||||||
|
className='account-card__bio translate'
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='account-card__actions'>
|
||||||
|
<div className='account-card__counters'>
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('statuses_count')} />
|
||||||
|
<small>
|
||||||
|
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers'
|
||||||
|
defaultMessage='Followers'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('following_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.following'
|
||||||
|
defaultMessage='Following'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__actions__button'>{actionBtn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,181 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
|
||||||
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
|
|
||||||
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
|
|
||||||
import Column from 'mastodon/components/column';
|
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
|
||||||
import { LoadMore } from 'mastodon/components/load_more';
|
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
||||||
import { RadioButton } from 'mastodon/components/radio_button';
|
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
|
||||||
|
|
||||||
import AccountCard from './components/account_card';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
|
||||||
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
|
|
||||||
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
|
||||||
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
|
||||||
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
|
|
||||||
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class Directory extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
accountIds: ImmutablePropTypes.list.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
columnId: PropTypes.string,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
domain: PropTypes.string.isRequired,
|
|
||||||
params: PropTypes.shape({
|
|
||||||
order: PropTypes.string,
|
|
||||||
local: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
order: null,
|
|
||||||
local: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(removeColumn(columnId));
|
|
||||||
} else {
|
|
||||||
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getParams = (props, state) => ({
|
|
||||||
order: state.order === null ? (props.params.order || 'active') : state.order,
|
|
||||||
local: state.local === null ? (props.params.local || false) : state.local,
|
|
||||||
});
|
|
||||||
|
|
||||||
handleMove = dir => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
dispatch(moveColumn(columnId, dir));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHeaderClick = () => {
|
|
||||||
this.column.scrollTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const paramsOld = this.getParams(prevProps, prevState);
|
|
||||||
const paramsNew = this.getParams(this.props, this.state);
|
|
||||||
|
|
||||||
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
|
|
||||||
dispatch(fetchDirectory(paramsNew));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.column = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeOrder = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
|
||||||
} else {
|
|
||||||
this.setState({ order: e.target.value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeLocal = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
|
|
||||||
} else {
|
|
||||||
this.setState({ local: e.target.value === '1' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadMore = () => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(expandDirectory(this.getParams(this.props, this.state)));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
|
|
||||||
const { order, local } = this.getParams(this.props, this.state);
|
|
||||||
const pinned = !!columnId;
|
|
||||||
|
|
||||||
const scrollableArea = (
|
|
||||||
<div className='scrollable'>
|
|
||||||
<div className='filter-form'>
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
|
|
||||||
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
|
|
||||||
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='directory__list'>
|
|
||||||
{isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
|
|
||||||
<AccountCard id={accountId} key={accountId} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
|
||||||
<ColumnHeader
|
|
||||||
icon='address-book-o'
|
|
||||||
iconComponent={PeopleIcon}
|
|
||||||
title={intl.formatMessage(messages.title)}
|
|
||||||
onPin={this.handlePin}
|
|
||||||
onMove={this.handleMove}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
pinned={pinned}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Directory));
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
import type { ChangeEventHandler } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
|
import {
|
||||||
|
addColumn,
|
||||||
|
removeColumn,
|
||||||
|
moveColumn,
|
||||||
|
changeColumnParams,
|
||||||
|
} from 'mastodon/actions/columns';
|
||||||
|
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { LoadMore } from 'mastodon/components/load_more';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { RadioButton } from 'mastodon/components/radio_button';
|
||||||
|
import ScrollContainer from 'mastodon/containers/scroll_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { AccountCard } from './components/account_card';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||||
|
recentlyActive: {
|
||||||
|
id: 'directory.recently_active',
|
||||||
|
defaultMessage: 'Recently active',
|
||||||
|
},
|
||||||
|
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
||||||
|
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
||||||
|
federated: {
|
||||||
|
id: 'directory.federated',
|
||||||
|
defaultMessage: 'From known fediverse',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Directory: React.FC<{
|
||||||
|
columnId?: string;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
params?: { order: string; local?: boolean };
|
||||||
|
}> = ({ columnId, multiColumn, params }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
order: string | null;
|
||||||
|
local: boolean | null;
|
||||||
|
}>({
|
||||||
|
order: null,
|
||||||
|
local: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const column = useRef<Column>(null);
|
||||||
|
|
||||||
|
const order = state.order ?? params?.order ?? 'active';
|
||||||
|
const local = state.local ?? params?.local ?? false;
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('DIRECTORY', { order, local }));
|
||||||
|
}
|
||||||
|
}, [dispatch, columnId, order, local]);
|
||||||
|
|
||||||
|
const domain = useAppSelector((s) => s.meta.get('domain') as string);
|
||||||
|
const accountIds = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(
|
||||||
|
['directory', 'items'],
|
||||||
|
ImmutableList(),
|
||||||
|
) as ImmutableList<string>,
|
||||||
|
);
|
||||||
|
const isLoading = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(dir: number) => {
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
column.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ order: e.target.value, local: s.local }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(
|
||||||
|
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
void dispatch(expandDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
const scrollableArea = (
|
||||||
|
<div className='scrollable'>
|
||||||
|
<div className='filter-form'>
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='active'
|
||||||
|
label={intl.formatMessage(messages.recentlyActive)}
|
||||||
|
checked={order === 'active'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='new'
|
||||||
|
label={intl.formatMessage(messages.newArrivals)}
|
||||||
|
checked={order === 'new'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='1'
|
||||||
|
label={intl.formatMessage(messages.local, { domain })}
|
||||||
|
checked={local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='0'
|
||||||
|
label={intl.formatMessage(messages.federated)}
|
||||||
|
checked={!local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='directory__list'>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
accountIds.map((accountId) => (
|
||||||
|
<AccountCard accountId={accountId} key={accountId} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
ref={column}
|
||||||
|
label={intl.formatMessage(messages.title)}
|
||||||
|
>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='address-book-o'
|
||||||
|
iconComponent={PeopleIcon}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{multiColumn && !pinned ? (
|
||||||
|
// @ts-expect-error ScrollContainer is not properly typed yet
|
||||||
|
<ScrollContainer scrollKey='directory'>
|
||||||
|
{scrollableArea}
|
||||||
|
</ScrollContainer>
|
||||||
|
) : (
|
||||||
|
scrollableArea
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
|
||||||
|
export default Directory;
|
|
@ -8,8 +8,12 @@ import { useAppSelector } from 'mastodon/store';
|
||||||
export const AuthorLink = ({ accountId }) => {
|
export const AuthorLink = ({ accountId }) => {
|
||||||
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
|
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
|
||||||
<Avatar account={account} size={16} />
|
<Avatar account={account} size={16} />
|
||||||
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
|
|
||||||
import { dismissSuggestion } from 'mastodon/actions/suggestions';
|
import { dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { domain } from 'mastodon/initial_state';
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Card = ({ id, source }) => {
|
export const Card = ({ id, source }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
|
||||||
<div className='explore__suggestions__card__body__main__name-button'>
|
<div className='explore__suggestions__card__body__main__name-button'>
|
||||||
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
||||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={account.get('id')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -75,7 +75,7 @@ class Links extends PureComponent {
|
||||||
publisher={link.get('provider_name')}
|
publisher={link.get('provider_name')}
|
||||||
publishedAt={link.get('published_at')}
|
publishedAt={link.get('published_at')}
|
||||||
author={link.get('author_name')}
|
author={link.get('author_name')}
|
||||||
authorAccount={link.getIn(['author_account', 'id'])}
|
authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
|
||||||
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||||
thumbnail={link.get('image')}
|
thumbnail={link.get('image')}
|
||||||
thumbnailDescription={link.get('image_description')}
|
thumbnailDescription={link.get('image_description')}
|
||||||
|
|
|
@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
|
|
||||||
import { changeSetting } from 'mastodon/actions/settings';
|
import { changeSetting } from 'mastodon/actions/settings';
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
|
@ -79,18 +78,8 @@ Source.propTypes = {
|
||||||
const Card = ({ id, sources }) => {
|
const Card = ({ id, sources }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
|
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
|
||||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
const targetAccount = report.get('target_account');
|
const targetAccount = report.get('target_account');
|
||||||
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
||||||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
const targetLink = <bdi><Link className='notification__display-name' data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<HotKeys handlers={this.getHandlers()}>
|
||||||
|
@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
const account = notification.get('account');
|
const account = notification.get('account');
|
||||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||||
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
|
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
|
||||||
|
|
||||||
switch(notification.get('type')) {
|
switch(notification.get('type')) {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
|
|
|
@ -138,7 +138,7 @@ export default class Card extends PureComponent {
|
||||||
const interactive = card.get('type') === 'video';
|
const interactive = card.get('type') === 'video';
|
||||||
const language = card.get('language') || '';
|
const language = card.get('language') || '';
|
||||||
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
||||||
const showAuthor = !!card.get('author_account');
|
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
|
||||||
|
|
||||||
const description = (
|
const description = (
|
||||||
<div className='status-card__content'>
|
<div className='status-card__content'>
|
||||||
|
@ -244,7 +244,7 @@ export default class Card extends PureComponent {
|
||||||
{description}
|
{description}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
|
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
|
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
<a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
|
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import Column from '../../../components/column';
|
import Column from 'mastodon/components/column';
|
||||||
import ColumnHeader from '../../../components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import type { Props as ColumnHeaderProps } from 'mastodon/components/column_header';
|
||||||
|
|
||||||
interface Props {
|
export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
|
||||||
multiColumn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ColumnLoading: React.FC<Props> = (otherProps) => (
|
|
||||||
<Column>
|
<Column>
|
||||||
<ColumnHeader {...otherProps} />
|
<ColumnHeader {...otherProps} />
|
||||||
<div className='scrollable' />
|
<div className='scrollable' />
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||||
|
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
||||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||||
|
@ -585,6 +586,7 @@ class UI extends PureComponent {
|
||||||
|
|
||||||
{layout !== 'mobile' && <PictureInPicture />}
|
{layout !== 'mobile' && <PictureInPicture />}
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
|
<HoverCardController />
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<LoadingBarContainer className='loading-bar' />
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
|
|
|
@ -50,7 +50,6 @@
|
||||||
"account.requested_follow": "{name} het versoek om jou te volg",
|
"account.requested_follow": "{name} het versoek om jou te volg",
|
||||||
"account.share": "Deel @{name} se profiel",
|
"account.share": "Deel @{name} se profiel",
|
||||||
"account.show_reblogs": "Wys aangestuurde plasings van @{name}",
|
"account.show_reblogs": "Wys aangestuurde plasings van @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Plaas} other {{counter} Plasings}}",
|
|
||||||
"account.unblock": "Deblokkeer @{name}",
|
"account.unblock": "Deblokkeer @{name}",
|
||||||
"account.unblock_domain": "Deblokkeer domein {domain}",
|
"account.unblock_domain": "Deblokkeer domein {domain}",
|
||||||
"account.unblock_short": "Deblokkeer",
|
"account.unblock_short": "Deblokkeer",
|
||||||
|
|
|
@ -31,9 +31,7 @@
|
||||||
"account.follow": "Seguir",
|
"account.follow": "Seguir",
|
||||||
"account.followers": "Seguidores",
|
"account.followers": "Seguidores",
|
||||||
"account.followers.empty": "Encara no sigue dengún a este usuario.",
|
"account.followers.empty": "Encara no sigue dengún a este usuario.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
|
|
||||||
"account.following": "Seguindo",
|
"account.following": "Seguindo",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Seguindo}}",
|
|
||||||
"account.follows.empty": "Este usuario encara no sigue a dengún.",
|
"account.follows.empty": "Este usuario encara no sigue a dengún.",
|
||||||
"account.go_to_profile": "Ir ta lo perfil",
|
"account.go_to_profile": "Ir ta lo perfil",
|
||||||
"account.hide_reblogs": "Amagar retutz de @{name}",
|
"account.hide_reblogs": "Amagar retutz de @{name}",
|
||||||
|
@ -54,7 +52,6 @@
|
||||||
"account.requested_follow": "{name} ha demandau seguir-te",
|
"account.requested_follow": "{name} ha demandau seguir-te",
|
||||||
"account.share": "Compartir lo perfil de @{name}",
|
"account.share": "Compartir lo perfil de @{name}",
|
||||||
"account.show_reblogs": "Amostrar retutz de @{name}",
|
"account.show_reblogs": "Amostrar retutz de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
|
|
||||||
"account.unblock": "Desblocar a @{name}",
|
"account.unblock": "Desblocar a @{name}",
|
||||||
"account.unblock_domain": "Amostrar a {domain}",
|
"account.unblock_domain": "Amostrar a {domain}",
|
||||||
"account.unblock_short": "Desblocar",
|
"account.unblock_short": "Desblocar",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "رد المتابعة",
|
"account.follow_back": "رد المتابعة",
|
||||||
"account.followers": "مُتابِعون",
|
"account.followers": "مُتابِعون",
|
||||||
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
|
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
|
||||||
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
|
|
||||||
"account.following": "الاشتراكات",
|
"account.following": "الاشتراكات",
|
||||||
"account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
|
|
||||||
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
|
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
|
||||||
"account.go_to_profile": "اذهب إلى الملف الشخصي",
|
"account.go_to_profile": "اذهب إلى الملف الشخصي",
|
||||||
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
|
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "لقد طلب {name} متابعتك",
|
"account.requested_follow": "لقد طلب {name} متابعتك",
|
||||||
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
||||||
"account.show_reblogs": "اعرض إعادات نشر @{name}",
|
"account.show_reblogs": "اعرض إعادات نشر @{name}",
|
||||||
"account.statuses_counter": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
|
|
||||||
"account.unblock": "إلغاء الحَظر عن @{name}",
|
"account.unblock": "إلغاء الحَظر عن @{name}",
|
||||||
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
|
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
|
||||||
"account.unblock_short": "ألغ الحجب",
|
"account.unblock_short": "ألغ الحجب",
|
||||||
|
|
|
@ -32,7 +32,6 @@
|
||||||
"account.followers": "Siguidores",
|
"account.followers": "Siguidores",
|
||||||
"account.followers.empty": "Naide sigue a esti perfil.",
|
"account.followers.empty": "Naide sigue a esti perfil.",
|
||||||
"account.following": "Siguiendo",
|
"account.following": "Siguiendo",
|
||||||
"account.following_counter": "{count, plural,one {Sigue a {counter}} other {Sigue a {counter}}}",
|
|
||||||
"account.follows.empty": "Esti perfil nun sigue a naide.",
|
"account.follows.empty": "Esti perfil nun sigue a naide.",
|
||||||
"account.go_to_profile": "Dir al perfil",
|
"account.go_to_profile": "Dir al perfil",
|
||||||
"account.hide_reblogs": "Anubrir los artículos compartíos de @{name}",
|
"account.hide_reblogs": "Anubrir los artículos compartíos de @{name}",
|
||||||
|
@ -49,7 +48,6 @@
|
||||||
"account.report": "Informar de: @{name}",
|
"account.report": "Informar de: @{name}",
|
||||||
"account.requested_follow": "{name} solicitó siguite",
|
"account.requested_follow": "{name} solicitó siguite",
|
||||||
"account.show_reblogs": "Amosar los artículos compartíos de @{name}",
|
"account.show_reblogs": "Amosar los artículos compartíos de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} artículu} other {{counter} artículos}}",
|
|
||||||
"account.unblock": "Desbloquiar a @{name}",
|
"account.unblock": "Desbloquiar a @{name}",
|
||||||
"account.unblock_domain": "Desbloquiar el dominiu «{domain}»",
|
"account.unblock_domain": "Desbloquiar el dominiu «{domain}»",
|
||||||
"account.unblock_short": "Desbloquiar",
|
"account.unblock_short": "Desbloquiar",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Падпісацца ў адказ",
|
"account.follow_back": "Падпісацца ў адказ",
|
||||||
"account.followers": "Падпісчыкі",
|
"account.followers": "Падпісчыкі",
|
||||||
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
|
||||||
"account.following": "Падпіскі",
|
"account.following": "Падпіскі",
|
||||||
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
|
|
||||||
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
|
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
|
||||||
"account.go_to_profile": "Перайсці да профілю",
|
"account.go_to_profile": "Перайсці да профілю",
|
||||||
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} адправіў запыт на падпіску",
|
"account.requested_follow": "{name} адправіў запыт на падпіску",
|
||||||
"account.share": "Абагуліць профіль @{name}",
|
"account.share": "Абагуліць профіль @{name}",
|
||||||
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
|
||||||
"account.unblock": "Разблакіраваць @{name}",
|
"account.unblock": "Разблакіраваць @{name}",
|
||||||
"account.unblock_domain": "Разблакіраваць дамен {domain}",
|
"account.unblock_domain": "Разблакіраваць дамен {domain}",
|
||||||
"account.unblock_short": "Разблакіраваць",
|
"account.unblock_short": "Разблакіраваць",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Последване взаимно",
|
"account.follow_back": "Последване взаимно",
|
||||||
"account.followers": "Последователи",
|
"account.followers": "Последователи",
|
||||||
"account.followers.empty": "Още никой не следва потребителя.",
|
"account.followers.empty": "Още никой не следва потребителя.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} последовател} other {{counter} последователи}}",
|
|
||||||
"account.following": "Последвано",
|
"account.following": "Последвано",
|
||||||
"account.following_counter": "{count, plural, one {{counter} последван} other {{counter} последвани}}",
|
|
||||||
"account.follows.empty": "Потребителят още никого не следва.",
|
"account.follows.empty": "Потребителят още никого не следва.",
|
||||||
"account.go_to_profile": "Към профила",
|
"account.go_to_profile": "Към профила",
|
||||||
"account.hide_reblogs": "Скриване на подсилвания от @{name}",
|
"account.hide_reblogs": "Скриване на подсилвания от @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} поиска да ви последва",
|
"account.requested_follow": "{name} поиска да ви последва",
|
||||||
"account.share": "Споделяне на профила на @{name}",
|
"account.share": "Споделяне на профила на @{name}",
|
||||||
"account.show_reblogs": "Показване на подсилвания от @{name}",
|
"account.show_reblogs": "Показване на подсилвания от @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
|
|
||||||
"account.unblock": "Отблокиране на @{name}",
|
"account.unblock": "Отблокиране на @{name}",
|
||||||
"account.unblock_domain": "Отблокиране на домейн {domain}",
|
"account.unblock_domain": "Отблокиране на домейн {domain}",
|
||||||
"account.unblock_short": "Отблокиране",
|
"account.unblock_short": "Отблокиране",
|
||||||
|
|
|
@ -33,9 +33,7 @@
|
||||||
"account.follow": "অনুসরণ",
|
"account.follow": "অনুসরণ",
|
||||||
"account.followers": "অনুসরণকারী",
|
"account.followers": "অনুসরণকারী",
|
||||||
"account.followers.empty": "এই ব্যক্তিকে এখনো কেউ অনুসরণ করে না.",
|
"account.followers.empty": "এই ব্যক্তিকে এখনো কেউ অনুসরণ করে না.",
|
||||||
"account.followers_counter": "{count, plural,one {{counter} জন অনুসরণকারী } other {{counter} জন অনুসরণকারী}}",
|
|
||||||
"account.following": "অনুসরণ করা হচ্ছে",
|
"account.following": "অনুসরণ করা হচ্ছে",
|
||||||
"account.following_counter": "{count, plural,one {{counter} জনকে অনুসরণ} other {{counter} জনকে অনুসরণ}}",
|
|
||||||
"account.follows.empty": "এই সদস্য কাউকে এখনো ফলো করেন না.",
|
"account.follows.empty": "এই সদস্য কাউকে এখনো ফলো করেন না.",
|
||||||
"account.go_to_profile": "প্রোফাইলে যান",
|
"account.go_to_profile": "প্রোফাইলে যান",
|
||||||
"account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
|
"account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
|
||||||
|
@ -60,7 +58,6 @@
|
||||||
"account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
|
"account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
|
||||||
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
|
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
|
||||||
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
|
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
|
||||||
"account.statuses_counter": "{count, plural,one {{counter} টুট} other {{counter} টুট}}",
|
|
||||||
"account.unblock": "@{name} র কার্যকলাপ দেখুন",
|
"account.unblock": "@{name} র কার্যকলাপ দেখুন",
|
||||||
"account.unblock_domain": "{domain} কে আবার দেখুন",
|
"account.unblock_domain": "{domain} কে আবার দেখুন",
|
||||||
"account.unblock_short": "আনব্লক করুন",
|
"account.unblock_short": "আনব্লক করুন",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Heuliañ d'ho tro",
|
"account.follow_back": "Heuliañ d'ho tro",
|
||||||
"account.followers": "Tud koumanantet",
|
"account.followers": "Tud koumanantet",
|
||||||
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
|
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
|
||||||
"account.followers_counter": "{count, plural, other{{counter} Heulier·ez}}",
|
|
||||||
"account.following": "Koumanantoù",
|
"account.following": "Koumanantoù",
|
||||||
"account.following_counter": "{count, plural, one{{counter} C'houmanant} two{{counter} Goumanant} other {{counter} a Goumanant}}",
|
|
||||||
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
|
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
|
||||||
"account.go_to_profile": "Gwelet ar profil",
|
"account.go_to_profile": "Gwelet ar profil",
|
||||||
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
|
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
|
||||||
|
@ -62,7 +60,6 @@
|
||||||
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
|
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
|
||||||
"account.share": "Skignañ profil @{name}",
|
"account.share": "Skignañ profil @{name}",
|
||||||
"account.show_reblogs": "Diskouez skignadennoù @{name}",
|
"account.show_reblogs": "Diskouez skignadennoù @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} C'hannad} two {{counter} Gannad} other {{counter} a Gannadoù}}",
|
|
||||||
"account.unblock": "Diverzañ @{name}",
|
"account.unblock": "Diverzañ @{name}",
|
||||||
"account.unblock_domain": "Diverzañ an domani {domain}",
|
"account.unblock_domain": "Diverzañ an domani {domain}",
|
||||||
"account.unblock_short": "Distankañ",
|
"account.unblock_short": "Distankañ",
|
||||||
|
|
|
@ -35,9 +35,9 @@
|
||||||
"account.follow_back": "Segueix tu també",
|
"account.follow_back": "Segueix tu també",
|
||||||
"account.followers": "Seguidors",
|
"account.followers": "Seguidors",
|
||||||
"account.followers.empty": "A aquest usuari encara no el segueix ningú.",
|
"account.followers.empty": "A aquest usuari encara no el segueix ningú.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} Seguidors}}",
|
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidors}}",
|
||||||
"account.following": "Seguint",
|
"account.following": "Seguint",
|
||||||
"account.following_counter": "{count, plural, other {{counter} Seguint-ne}}",
|
"account.following_counter": "{count, plural, other {Seguint-ne {counter}}}",
|
||||||
"account.follows.empty": "Aquest usuari encara no segueix ningú.",
|
"account.follows.empty": "Aquest usuari encara no segueix ningú.",
|
||||||
"account.go_to_profile": "Vés al perfil",
|
"account.go_to_profile": "Vés al perfil",
|
||||||
"account.hide_reblogs": "Amaga els impulsos de @{name}",
|
"account.hide_reblogs": "Amaga els impulsos de @{name}",
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
"account.requested_follow": "{name} ha demanat de seguir-te",
|
"account.requested_follow": "{name} ha demanat de seguir-te",
|
||||||
"account.share": "Comparteix el perfil de @{name}",
|
"account.share": "Comparteix el perfil de @{name}",
|
||||||
"account.show_reblogs": "Mostra els impulsos de @{name}",
|
"account.show_reblogs": "Mostra els impulsos de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Tut} other {{counter} Tuts}}",
|
"account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
|
||||||
"account.unblock": "Desbloca @{name}",
|
"account.unblock": "Desbloca @{name}",
|
||||||
"account.unblock_domain": "Desbloca el domini {domain}",
|
"account.unblock_domain": "Desbloca el domini {domain}",
|
||||||
"account.unblock_short": "Desbloca",
|
"account.unblock_short": "Desbloca",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "فۆڵۆو بکەنەوە",
|
"account.follow_back": "فۆڵۆو بکەنەوە",
|
||||||
"account.followers": "شوێنکەوتووان",
|
"account.followers": "شوێنکەوتووان",
|
||||||
"account.followers.empty": "کەسێک شوێن ئەم بەکارهێنەرە نەکەوتووە",
|
"account.followers.empty": "کەسێک شوێن ئەم بەکارهێنەرە نەکەوتووە",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
|
|
||||||
"account.following": "بەدوادا",
|
"account.following": "بەدوادا",
|
||||||
"account.following_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
|
|
||||||
"account.follows.empty": "ئەم بەکارهێنەرە تا ئێستا شوێن کەس نەکەوتووە.",
|
"account.follows.empty": "ئەم بەکارهێنەرە تا ئێستا شوێن کەس نەکەوتووە.",
|
||||||
"account.go_to_profile": "بڕۆ بۆ پڕۆفایلی",
|
"account.go_to_profile": "بڕۆ بۆ پڕۆفایلی",
|
||||||
"account.hide_reblogs": "داشاردنی بووستەکان لە @{name}",
|
"account.hide_reblogs": "داشاردنی بووستەکان لە @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
|
"account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
|
||||||
"account.share": "پرۆفایلی @{name} هاوبەش بکە",
|
"account.share": "پرۆفایلی @{name} هاوبەش بکە",
|
||||||
"account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
|
"account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
|
||||||
"account.unblock": "@{name} لاببە",
|
"account.unblock": "@{name} لاببە",
|
||||||
"account.unblock_domain": "کردنەوەی دۆمەینی {domain}",
|
"account.unblock_domain": "کردنەوەی دۆمەینی {domain}",
|
||||||
"account.unblock_short": "لابردنی بەربەست",
|
"account.unblock_short": "لابردنی بەربەست",
|
||||||
|
|
|
@ -16,8 +16,6 @@
|
||||||
"account.follow": "Siguità",
|
"account.follow": "Siguità",
|
||||||
"account.followers": "Abbunati",
|
"account.followers": "Abbunati",
|
||||||
"account.followers.empty": "Nisunu hè abbunatu à st'utilizatore.",
|
"account.followers.empty": "Nisunu hè abbunatu à st'utilizatore.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Abbunatu} other {{counter} Abbunati}}",
|
|
||||||
"account.following_counter": "{count, plural, one {{counter} Abbunamentu} other {{counter} Abbunamenti}}",
|
|
||||||
"account.follows.empty": "St'utilizatore ùn seguita nisunu.",
|
"account.follows.empty": "St'utilizatore ùn seguita nisunu.",
|
||||||
"account.hide_reblogs": "Piattà spartere da @{name}",
|
"account.hide_reblogs": "Piattà spartere da @{name}",
|
||||||
"account.link_verified_on": "A prupietà di stu ligame hè stata verificata u {date}",
|
"account.link_verified_on": "A prupietà di stu ligame hè stata verificata u {date}",
|
||||||
|
@ -32,7 +30,6 @@
|
||||||
"account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
|
"account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
|
||||||
"account.share": "Sparte u prufile di @{name}",
|
"account.share": "Sparte u prufile di @{name}",
|
||||||
"account.show_reblogs": "Vede spartere da @{name}",
|
"account.show_reblogs": "Vede spartere da @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Statutu} other {{counter} Statuti}}",
|
|
||||||
"account.unblock": "Sbluccà @{name}",
|
"account.unblock": "Sbluccà @{name}",
|
||||||
"account.unblock_domain": "Ùn piattà più {domain}",
|
"account.unblock_domain": "Ùn piattà più {domain}",
|
||||||
"account.unendorse": "Ùn fà figurà nant'à u prufilu",
|
"account.unendorse": "Ùn fà figurà nant'à u prufilu",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Také sledovat",
|
"account.follow_back": "Také sledovat",
|
||||||
"account.followers": "Sledující",
|
"account.followers": "Sledující",
|
||||||
"account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
|
"account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Sledující} few {{counter} Sledující} many {{counter} Sledujících} other {{counter} Sledujících}}",
|
|
||||||
"account.following": "Sledujete",
|
"account.following": "Sledujete",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Sledovaný} few {{counter} Sledovaní} many {{counter} Sledovaných} other {{counter} Sledovaných}}",
|
|
||||||
"account.follows.empty": "Tento uživatel zatím nikoho nesleduje.",
|
"account.follows.empty": "Tento uživatel zatím nikoho nesleduje.",
|
||||||
"account.go_to_profile": "Přejít na profil",
|
"account.go_to_profile": "Přejít na profil",
|
||||||
"account.hide_reblogs": "Skrýt boosty od @{name}",
|
"account.hide_reblogs": "Skrýt boosty od @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} tě požádal o sledování",
|
"account.requested_follow": "{name} tě požádal o sledování",
|
||||||
"account.share": "Sdílet profil @{name}",
|
"account.share": "Sdílet profil @{name}",
|
||||||
"account.show_reblogs": "Zobrazit boosty od @{name}",
|
"account.show_reblogs": "Zobrazit boosty od @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Příspěvek} few {{counter} Příspěvky} many {{counter} Příspěvků} other {{counter} Příspěvků}}",
|
|
||||||
"account.unblock": "Odblokovat @{name}",
|
"account.unblock": "Odblokovat @{name}",
|
||||||
"account.unblock_domain": "Odblokovat doménu {domain}",
|
"account.unblock_domain": "Odblokovat doménu {domain}",
|
||||||
"account.unblock_short": "Odblokovat",
|
"account.unblock_short": "Odblokovat",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Dilyn yn ôl",
|
"account.follow_back": "Dilyn yn ôl",
|
||||||
"account.followers": "Dilynwyr",
|
"account.followers": "Dilynwyr",
|
||||||
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
|
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
|
||||||
"account.followers_counter": "{count, plural, one {Dilynwr: {counter}} other {Dilynwyr: {counter}}}",
|
|
||||||
"account.following": "Yn dilyn",
|
"account.following": "Yn dilyn",
|
||||||
"account.following_counter": "{count, plural, one {Yn dilyn: {counter}} other {Yn dilyn: {counter}}}",
|
|
||||||
"account.follows.empty": "Nid yw'r defnyddiwr hwn yn dilyn unrhyw un eto.",
|
"account.follows.empty": "Nid yw'r defnyddiwr hwn yn dilyn unrhyw un eto.",
|
||||||
"account.go_to_profile": "Mynd i'r proffil",
|
"account.go_to_profile": "Mynd i'r proffil",
|
||||||
"account.hide_reblogs": "Cuddio hybiau gan @{name}",
|
"account.hide_reblogs": "Cuddio hybiau gan @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
|
"account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
|
||||||
"account.share": "Rhannwch broffil @{name}",
|
"account.share": "Rhannwch broffil @{name}",
|
||||||
"account.show_reblogs": "Dangos hybiau gan @{name}",
|
"account.show_reblogs": "Dangos hybiau gan @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {Postiad: {counter}} other {Postiad: {counter}}}",
|
|
||||||
"account.unblock": "Dadflocio @{name}",
|
"account.unblock": "Dadflocio @{name}",
|
||||||
"account.unblock_domain": "Dadflocio parth {domain}",
|
"account.unblock_domain": "Dadflocio parth {domain}",
|
||||||
"account.unblock_short": "Dadflocio",
|
"account.unblock_short": "Dadflocio",
|
||||||
|
|
|
@ -35,9 +35,8 @@
|
||||||
"account.follow_back": "Følg tilbage",
|
"account.follow_back": "Følg tilbage",
|
||||||
"account.followers": "Følgere",
|
"account.followers": "Følgere",
|
||||||
"account.followers.empty": "Ingen følger denne bruger endnu.",
|
"account.followers.empty": "Ingen følger denne bruger endnu.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Følger} other {{counter} Følgere}}",
|
"account.followers_counter": "{count, plural, one {{counter} følger} other {{counter} følgere}}",
|
||||||
"account.following": "Følger",
|
"account.following": "Følger",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Følges} other {{counter} Følges}}",
|
|
||||||
"account.follows.empty": "Denne bruger følger ikke nogen endnu.",
|
"account.follows.empty": "Denne bruger følger ikke nogen endnu.",
|
||||||
"account.go_to_profile": "Gå til profil",
|
"account.go_to_profile": "Gå til profil",
|
||||||
"account.hide_reblogs": "Skjul boosts fra @{name}",
|
"account.hide_reblogs": "Skjul boosts fra @{name}",
|
||||||
|
@ -63,7 +62,6 @@
|
||||||
"account.requested_follow": "{name} har anmodet om at følge dig",
|
"account.requested_follow": "{name} har anmodet om at følge dig",
|
||||||
"account.share": "Del @{name}s profil",
|
"account.share": "Del @{name}s profil",
|
||||||
"account.show_reblogs": "Vis fremhævelser fra @{name}",
|
"account.show_reblogs": "Vis fremhævelser fra @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Indlæg} other {{counter} Indlæg}}",
|
|
||||||
"account.unblock": "Afblokér @{name}",
|
"account.unblock": "Afblokér @{name}",
|
||||||
"account.unblock_domain": "Afblokér domænet {domain}",
|
"account.unblock_domain": "Afblokér domænet {domain}",
|
||||||
"account.unblock_short": "Afblokér",
|
"account.unblock_short": "Afblokér",
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
"account.followers.empty": "Diesem Profil folgt noch niemand.",
|
"account.followers.empty": "Diesem Profil folgt noch niemand.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
|
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
|
||||||
"account.following": "Folge ich",
|
"account.following": "Folge ich",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
|
"account.following_counter": "{count, plural, one {{counter} folge ich} other {{counter} folge ich}}",
|
||||||
"account.follows.empty": "Dieses Profil folgt noch niemandem.",
|
"account.follows.empty": "Dieses Profil folgt noch niemandem.",
|
||||||
"account.go_to_profile": "Profil aufrufen",
|
"account.go_to_profile": "Profil aufrufen",
|
||||||
"account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden",
|
"account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Ακολούθησε και εσύ",
|
"account.follow_back": "Ακολούθησε και εσύ",
|
||||||
"account.followers": "Ακόλουθοι",
|
"account.followers": "Ακόλουθοι",
|
||||||
"account.followers.empty": "Κανείς δεν ακολουθεί αυτόν τον χρήστη ακόμα.",
|
"account.followers.empty": "Κανείς δεν ακολουθεί αυτόν τον χρήστη ακόμα.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Ακόλουθος} other {{counter} Ακόλουθοι}}",
|
|
||||||
"account.following": "Ακολουθείτε",
|
"account.following": "Ακολουθείτε",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Ακολουθεί} other {{counter} Ακολουθούν}}",
|
|
||||||
"account.follows.empty": "Αυτός ο χρήστης δεν ακολουθεί κανέναν ακόμα.",
|
"account.follows.empty": "Αυτός ο χρήστης δεν ακολουθεί κανέναν ακόμα.",
|
||||||
"account.go_to_profile": "Μετάβαση στο προφίλ",
|
"account.go_to_profile": "Μετάβαση στο προφίλ",
|
||||||
"account.hide_reblogs": "Απόκρυψη ενισχύσεων από @{name}",
|
"account.hide_reblogs": "Απόκρυψη ενισχύσεων από @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "Ο/Η {name} αιτήθηκε να σε ακολουθήσει",
|
"account.requested_follow": "Ο/Η {name} αιτήθηκε να σε ακολουθήσει",
|
||||||
"account.share": "Κοινοποίηση του προφίλ @{name}",
|
"account.share": "Κοινοποίηση του προφίλ @{name}",
|
||||||
"account.show_reblogs": "Εμφάνιση ενισχύσεων από @{name}",
|
"account.show_reblogs": "Εμφάνιση ενισχύσεων από @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Ανάρτηση} other {{counter} Αναρτήσεις}}",
|
|
||||||
"account.unblock": "Άρση αποκλεισμού @{name}",
|
"account.unblock": "Άρση αποκλεισμού @{name}",
|
||||||
"account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
|
"account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
|
||||||
"account.unblock_short": "Άρση αποκλεισμού",
|
"account.unblock_short": "Άρση αποκλεισμού",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Follow back",
|
"account.follow_back": "Follow back",
|
||||||
"account.followers": "Followers",
|
"account.followers": "Followers",
|
||||||
"account.followers.empty": "No one follows this user yet.",
|
"account.followers.empty": "No one follows this user yet.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
|
||||||
"account.following": "Following",
|
"account.following": "Following",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
|
||||||
"account.follows.empty": "This user doesn't follow anyone yet.",
|
"account.follows.empty": "This user doesn't follow anyone yet.",
|
||||||
"account.go_to_profile": "Go to profile",
|
"account.go_to_profile": "Go to profile",
|
||||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} has requested to follow you",
|
"account.requested_follow": "{name} has requested to follow you",
|
||||||
"account.share": "Share @{name}'s profile",
|
"account.share": "Share @{name}'s profile",
|
||||||
"account.show_reblogs": "Show boosts from @{name}",
|
"account.show_reblogs": "Show boosts from @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
|
|
||||||
"account.unblock": "Unblock @{name}",
|
"account.unblock": "Unblock @{name}",
|
||||||
"account.unblock_domain": "Unblock domain {domain}",
|
"account.unblock_domain": "Unblock domain {domain}",
|
||||||
"account.unblock_short": "Unblock",
|
"account.unblock_short": "Unblock",
|
||||||
|
|
|
@ -35,9 +35,9 @@
|
||||||
"account.follow_back": "Follow back",
|
"account.follow_back": "Follow back",
|
||||||
"account.followers": "Followers",
|
"account.followers": "Followers",
|
||||||
"account.followers.empty": "No one follows this user yet.",
|
"account.followers.empty": "No one follows this user yet.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
|
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
|
||||||
"account.following": "Following",
|
"account.following": "Following",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
|
"account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
|
||||||
"account.follows.empty": "This user doesn't follow anyone yet.",
|
"account.follows.empty": "This user doesn't follow anyone yet.",
|
||||||
"account.go_to_profile": "Go to profile",
|
"account.go_to_profile": "Go to profile",
|
||||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
"account.requested_follow": "{name} has requested to follow you",
|
"account.requested_follow": "{name} has requested to follow you",
|
||||||
"account.share": "Share @{name}'s profile",
|
"account.share": "Share @{name}'s profile",
|
||||||
"account.show_reblogs": "Show boosts from @{name}",
|
"account.show_reblogs": "Show boosts from @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
|
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
||||||
"account.unblock": "Unblock @{name}",
|
"account.unblock": "Unblock @{name}",
|
||||||
"account.unblock_domain": "Unblock domain {domain}",
|
"account.unblock_domain": "Unblock domain {domain}",
|
||||||
"account.unblock_short": "Unblock",
|
"account.unblock_short": "Unblock",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Sekvu reen",
|
"account.follow_back": "Sekvu reen",
|
||||||
"account.followers": "Sekvantoj",
|
"account.followers": "Sekvantoj",
|
||||||
"account.followers.empty": "Ankoraŭ neniu sekvas ĉi tiun uzanton.",
|
"account.followers.empty": "Ankoraŭ neniu sekvas ĉi tiun uzanton.",
|
||||||
"account.followers_counter": "{count, plural, one{{counter} Sekvanto} other {{counter} Sekvantoj}}",
|
|
||||||
"account.following": "Sekvatoj",
|
"account.following": "Sekvatoj",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}",
|
|
||||||
"account.follows.empty": "La uzanto ankoraŭ ne sekvas iun ajn.",
|
"account.follows.empty": "La uzanto ankoraŭ ne sekvas iun ajn.",
|
||||||
"account.go_to_profile": "Iri al profilo",
|
"account.go_to_profile": "Iri al profilo",
|
||||||
"account.hide_reblogs": "Kaŝi diskonigojn de @{name}",
|
"account.hide_reblogs": "Kaŝi diskonigojn de @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} petis sekvi vin",
|
"account.requested_follow": "{name} petis sekvi vin",
|
||||||
"account.share": "Diskonigi la profilon de @{name}",
|
"account.share": "Diskonigi la profilon de @{name}",
|
||||||
"account.show_reblogs": "Montri diskonigojn de @{name}",
|
"account.show_reblogs": "Montri diskonigojn de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Afiŝo} other {{counter} Afiŝoj}}",
|
|
||||||
"account.unblock": "Malbloki @{name}",
|
"account.unblock": "Malbloki @{name}",
|
||||||
"account.unblock_domain": "Malbloki la domajnon {domain}",
|
"account.unblock_domain": "Malbloki la domajnon {domain}",
|
||||||
"account.unblock_short": "Malbloki",
|
"account.unblock_short": "Malbloki",
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
|
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
|
||||||
"account.following": "Siguiendo",
|
"account.following": "Siguiendo",
|
||||||
"account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
|
"account.following_counter": "{count, plural, one {siguiendo a {counter}} other {siguiendo a {counter}}}",
|
||||||
"account.follows.empty": "Todavía este usuario no sigue a nadie.",
|
"account.follows.empty": "Todavía este usuario no sigue a nadie.",
|
||||||
"account.go_to_profile": "Ir al perfil",
|
"account.go_to_profile": "Ir al perfil",
|
||||||
"account.hide_reblogs": "Ocultar adhesiones de @{name}",
|
"account.hide_reblogs": "Ocultar adhesiones de @{name}",
|
||||||
|
|
|
@ -35,9 +35,9 @@
|
||||||
"account.follow_back": "Seguir también",
|
"account.follow_back": "Seguir también",
|
||||||
"account.followers": "Seguidores",
|
"account.followers": "Seguidores",
|
||||||
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
|
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
|
||||||
"account.following": "Siguiendo",
|
"account.following": "Siguiendo",
|
||||||
"account.following_counter": "{count, plural, other {{counter} Siguiendo}}",
|
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
|
||||||
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
|
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
|
||||||
"account.go_to_profile": "Ir al perfil",
|
"account.go_to_profile": "Ir al perfil",
|
||||||
"account.hide_reblogs": "Ocultar retoots de @{name}",
|
"account.hide_reblogs": "Ocultar retoots de @{name}",
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
"account.requested_follow": "{name} ha solicitado seguirte",
|
"account.requested_follow": "{name} ha solicitado seguirte",
|
||||||
"account.share": "Compartir el perfil de @{name}",
|
"account.share": "Compartir el perfil de @{name}",
|
||||||
"account.show_reblogs": "Mostrar retoots de @{name}",
|
"account.show_reblogs": "Mostrar retoots de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
|
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||||
"account.unblock": "Desbloquear a @{name}",
|
"account.unblock": "Desbloquear a @{name}",
|
||||||
"account.unblock_domain": "Mostrar a {domain}",
|
"account.unblock_domain": "Mostrar a {domain}",
|
||||||
"account.unblock_short": "Desbloquear",
|
"account.unblock_short": "Desbloquear",
|
||||||
|
|
|
@ -35,9 +35,9 @@
|
||||||
"account.follow_back": "Seguir también",
|
"account.follow_back": "Seguir también",
|
||||||
"account.followers": "Seguidores",
|
"account.followers": "Seguidores",
|
||||||
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
"account.followers.empty": "Todavía nadie sigue a este usuario.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
|
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
|
||||||
"account.following": "Siguiendo",
|
"account.following": "Siguiendo",
|
||||||
"account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
|
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
|
||||||
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
|
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
|
||||||
"account.go_to_profile": "Ir al perfil",
|
"account.go_to_profile": "Ir al perfil",
|
||||||
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
"account.hide_reblogs": "Ocultar impulsos de @{name}",
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
"account.requested_follow": "{name} ha solicitado seguirte",
|
"account.requested_follow": "{name} ha solicitado seguirte",
|
||||||
"account.share": "Compartir el perfil de @{name}",
|
"account.share": "Compartir el perfil de @{name}",
|
||||||
"account.show_reblogs": "Mostrar impulsos de @{name}",
|
"account.show_reblogs": "Mostrar impulsos de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
|
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||||
"account.unblock": "Desbloquear a @{name}",
|
"account.unblock": "Desbloquear a @{name}",
|
||||||
"account.unblock_domain": "Desbloquear dominio {domain}",
|
"account.unblock_domain": "Desbloquear dominio {domain}",
|
||||||
"account.unblock_short": "Desbloquear",
|
"account.unblock_short": "Desbloquear",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Jälgi vastu",
|
"account.follow_back": "Jälgi vastu",
|
||||||
"account.followers": "Jälgijad",
|
"account.followers": "Jälgijad",
|
||||||
"account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
|
"account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}",
|
|
||||||
"account.following": "Jälgib",
|
"account.following": "Jälgib",
|
||||||
"account.following_counter": "{count, plural, one {{counter} jälgitav} other {{counter} jälgitavat}}",
|
|
||||||
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
|
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
|
||||||
"account.go_to_profile": "Mine profiilile",
|
"account.go_to_profile": "Mine profiilile",
|
||||||
"account.hide_reblogs": "Peida @{name} jagamised",
|
"account.hide_reblogs": "Peida @{name} jagamised",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
|
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
|
||||||
"account.share": "Jaga @{name} profiili",
|
"account.share": "Jaga @{name} profiili",
|
||||||
"account.show_reblogs": "Näita @{name} jagamisi",
|
"account.show_reblogs": "Näita @{name} jagamisi",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
|
|
||||||
"account.unblock": "Eemalda blokeering @{name}",
|
"account.unblock": "Eemalda blokeering @{name}",
|
||||||
"account.unblock_domain": "Tee {domain} nähtavaks",
|
"account.unblock_domain": "Tee {domain} nähtavaks",
|
||||||
"account.unblock_short": "Eemalda blokeering",
|
"account.unblock_short": "Eemalda blokeering",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Jarraitu bueltan",
|
"account.follow_back": "Jarraitu bueltan",
|
||||||
"account.followers": "Jarraitzaileak",
|
"account.followers": "Jarraitzaileak",
|
||||||
"account.followers.empty": "Ez du inork erabiltzaile hau jarraitzen oraindik.",
|
"account.followers.empty": "Ez du inork erabiltzaile hau jarraitzen oraindik.",
|
||||||
"account.followers_counter": "{count, plural, one {Jarraitzaile {counter}} other {{counter} jarraitzaile}}",
|
|
||||||
"account.following": "Jarraitzen",
|
"account.following": "Jarraitzen",
|
||||||
"account.following_counter": "{count, plural, one {{counter} jarraitzen} other {{counter} jarraitzen}}",
|
|
||||||
"account.follows.empty": "Erabiltzaile honek ez du inor jarraitzen oraindik.",
|
"account.follows.empty": "Erabiltzaile honek ez du inor jarraitzen oraindik.",
|
||||||
"account.go_to_profile": "Joan profilera",
|
"account.go_to_profile": "Joan profilera",
|
||||||
"account.hide_reblogs": "Ezkutatu @{name} erabiltzailearen bultzadak",
|
"account.hide_reblogs": "Ezkutatu @{name} erabiltzailearen bultzadak",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name}-(e)k zu jarraitzeko eskaera egin du",
|
"account.requested_follow": "{name}-(e)k zu jarraitzeko eskaera egin du",
|
||||||
"account.share": "Partekatu @{name} erabiltzailearen profila",
|
"account.share": "Partekatu @{name} erabiltzailearen profila",
|
||||||
"account.show_reblogs": "Erakutsi @{name} erabiltzailearen bultzadak",
|
"account.show_reblogs": "Erakutsi @{name} erabiltzailearen bultzadak",
|
||||||
"account.statuses_counter": "{count, plural, one {Bidalketa {counter}} other {{counter} bidalketa}}",
|
|
||||||
"account.unblock": "Desblokeatu @{name}",
|
"account.unblock": "Desblokeatu @{name}",
|
||||||
"account.unblock_domain": "Berriz erakutsi {domain}",
|
"account.unblock_domain": "Berriz erakutsi {domain}",
|
||||||
"account.unblock_short": "Desblokeatu",
|
"account.unblock_short": "Desblokeatu",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "دنبال کردن متقابل",
|
"account.follow_back": "دنبال کردن متقابل",
|
||||||
"account.followers": "پیگیرندگان",
|
"account.followers": "پیگیرندگان",
|
||||||
"account.followers.empty": "هنوز کسی پیگیر این کاربر نیست.",
|
"account.followers.empty": "هنوز کسی پیگیر این کاربر نیست.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} پیگیرنده} other {{counter} پیگیرنده}}",
|
|
||||||
"account.following": "پی میگیرید",
|
"account.following": "پی میگیرید",
|
||||||
"account.following_counter": "{count, plural, one {{counter} پیگرفته} other {{counter} پیگرفته}}",
|
|
||||||
"account.follows.empty": "این کاربر هنوز پیگیر کسی نیست.",
|
"account.follows.empty": "این کاربر هنوز پیگیر کسی نیست.",
|
||||||
"account.go_to_profile": "رفتن به نمایه",
|
"account.go_to_profile": "رفتن به نمایه",
|
||||||
"account.hide_reblogs": "نهفتن تقویتهای @{name}",
|
"account.hide_reblogs": "نهفتن تقویتهای @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} درخواست پیگیریتان را داد",
|
"account.requested_follow": "{name} درخواست پیگیریتان را داد",
|
||||||
"account.share": "همرسانی نمایهٔ @{name}",
|
"account.share": "همرسانی نمایهٔ @{name}",
|
||||||
"account.show_reblogs": "نمایش تقویتهای @{name}",
|
"account.show_reblogs": "نمایش تقویتهای @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
|
|
||||||
"account.unblock": "رفع مسدودیت @{name}",
|
"account.unblock": "رفع مسدودیت @{name}",
|
||||||
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
|
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
|
||||||
"account.unblock_short": "رفع مسدودیت",
|
"account.unblock_short": "رفع مسدودیت",
|
||||||
|
|
|
@ -699,7 +699,7 @@
|
||||||
"server_banner.is_one_of_many": "{domain} on yksi monista itsenäisistä Mastodon-palvelimista, joiden välityksellä voit toimia fediversumissa.",
|
"server_banner.is_one_of_many": "{domain} on yksi monista itsenäisistä Mastodon-palvelimista, joiden välityksellä voit toimia fediversumissa.",
|
||||||
"server_banner.server_stats": "Palvelimen tilastot:",
|
"server_banner.server_stats": "Palvelimen tilastot:",
|
||||||
"sign_in_banner.create_account": "Luo tili",
|
"sign_in_banner.create_account": "Luo tili",
|
||||||
"sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmejä, mainoksia tai klikkikalastelua.",
|
"sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmeja, mainoksia tai klikkausten kalastelua.",
|
||||||
"sign_in_banner.mastodon_is": "Mastodon on paras tapa pysyä ajan tasalla siitä, mitä ympärillä tapahtuu.",
|
"sign_in_banner.mastodon_is": "Mastodon on paras tapa pysyä ajan tasalla siitä, mitä ympärillä tapahtuu.",
|
||||||
"sign_in_banner.sign_in": "Kirjaudu",
|
"sign_in_banner.sign_in": "Kirjaudu",
|
||||||
"sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy",
|
"sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Fylg aftur",
|
"account.follow_back": "Fylg aftur",
|
||||||
"account.followers": "Fylgjarar",
|
"account.followers": "Fylgjarar",
|
||||||
"account.followers.empty": "Ongar fylgjarar enn.",
|
"account.followers.empty": "Ongar fylgjarar enn.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Fylgjari} other {{counter} Fylgjarar}}",
|
|
||||||
"account.following": "Fylgir",
|
"account.following": "Fylgir",
|
||||||
"account.following_counter": "{count, plural, one {{counter} fylgir} other {{counter} fylgja}}",
|
|
||||||
"account.follows.empty": "Hesin brúkari fylgir ongum enn.",
|
"account.follows.empty": "Hesin brúkari fylgir ongum enn.",
|
||||||
"account.go_to_profile": "Far til vanga",
|
"account.go_to_profile": "Far til vanga",
|
||||||
"account.hide_reblogs": "Fjal lyft frá @{name}",
|
"account.hide_reblogs": "Fjal lyft frá @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} hevur biðið um at fylgja tær",
|
"account.requested_follow": "{name} hevur biðið um at fylgja tær",
|
||||||
"account.share": "Deil vanga @{name}'s",
|
"account.share": "Deil vanga @{name}'s",
|
||||||
"account.show_reblogs": "Vís lyft frá @{name}",
|
"account.show_reblogs": "Vís lyft frá @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} postur} other {{counter} postar}}",
|
|
||||||
"account.unblock": "Banna ikki @{name}",
|
"account.unblock": "Banna ikki @{name}",
|
||||||
"account.unblock_domain": "Banna ikki økisnavnið {domain}",
|
"account.unblock_domain": "Banna ikki økisnavnið {domain}",
|
||||||
"account.unblock_short": "Banna ikki",
|
"account.unblock_short": "Banna ikki",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "S'abonner en retour",
|
"account.follow_back": "S'abonner en retour",
|
||||||
"account.followers": "abonné·e·s",
|
"account.followers": "abonné·e·s",
|
||||||
"account.followers.empty": "Personne ne suit ce compte pour l'instant.",
|
"account.followers.empty": "Personne ne suit ce compte pour l'instant.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
|
|
||||||
"account.following": "Abonné·e",
|
"account.following": "Abonné·e",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
|
|
||||||
"account.follows.empty": "Ce compte ne suit personne présentement.",
|
"account.follows.empty": "Ce compte ne suit personne présentement.",
|
||||||
"account.go_to_profile": "Voir ce profil",
|
"account.go_to_profile": "Voir ce profil",
|
||||||
"account.hide_reblogs": "Masquer les boosts de @{name}",
|
"account.hide_reblogs": "Masquer les boosts de @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} a demandé à vous suivre",
|
"account.requested_follow": "{name} a demandé à vous suivre",
|
||||||
"account.share": "Partager le profil de @{name}",
|
"account.share": "Partager le profil de @{name}",
|
||||||
"account.show_reblogs": "Afficher les boosts de @{name}",
|
"account.show_reblogs": "Afficher les boosts de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Publication} other {{counter} Publications}}",
|
|
||||||
"account.unblock": "Débloquer @{name}",
|
"account.unblock": "Débloquer @{name}",
|
||||||
"account.unblock_domain": "Débloquer le domaine {domain}",
|
"account.unblock_domain": "Débloquer le domaine {domain}",
|
||||||
"account.unblock_short": "Débloquer",
|
"account.unblock_short": "Débloquer",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "S'abonner en retour",
|
"account.follow_back": "S'abonner en retour",
|
||||||
"account.followers": "Abonné·e·s",
|
"account.followers": "Abonné·e·s",
|
||||||
"account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
|
"account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
|
|
||||||
"account.following": "Abonnements",
|
"account.following": "Abonnements",
|
||||||
"account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
|
|
||||||
"account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
|
"account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
|
||||||
"account.go_to_profile": "Aller au profil",
|
"account.go_to_profile": "Aller au profil",
|
||||||
"account.hide_reblogs": "Masquer les partages de @{name}",
|
"account.hide_reblogs": "Masquer les partages de @{name}",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} a demandé à vous suivre",
|
"account.requested_follow": "{name} a demandé à vous suivre",
|
||||||
"account.share": "Partager le profil de @{name}",
|
"account.share": "Partager le profil de @{name}",
|
||||||
"account.show_reblogs": "Afficher les partages de @{name}",
|
"account.show_reblogs": "Afficher les partages de @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} Message} other {{counter} Messages}}",
|
|
||||||
"account.unblock": "Débloquer @{name}",
|
"account.unblock": "Débloquer @{name}",
|
||||||
"account.unblock_domain": "Débloquer le domaine {domain}",
|
"account.unblock_domain": "Débloquer le domaine {domain}",
|
||||||
"account.unblock_short": "Débloquer",
|
"account.unblock_short": "Débloquer",
|
||||||
|
|
|
@ -35,9 +35,7 @@
|
||||||
"account.follow_back": "Weromfolgje",
|
"account.follow_back": "Weromfolgje",
|
||||||
"account.followers": "Folgers",
|
"account.followers": "Folgers",
|
||||||
"account.followers.empty": "Noch net ien folget dizze brûker.",
|
"account.followers.empty": "Noch net ien folget dizze brûker.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} folger} other {{counter} folgers}}",
|
|
||||||
"account.following": "Folgjend",
|
"account.following": "Folgjend",
|
||||||
"account.following_counter": "{count, plural, one {{counter} folgjend} other {{counter} folgjend}}",
|
|
||||||
"account.follows.empty": "Dizze brûker folget noch net ien.",
|
"account.follows.empty": "Dizze brûker folget noch net ien.",
|
||||||
"account.go_to_profile": "Gean nei profyl",
|
"account.go_to_profile": "Gean nei profyl",
|
||||||
"account.hide_reblogs": "Boosts fan @{name} ferstopje",
|
"account.hide_reblogs": "Boosts fan @{name} ferstopje",
|
||||||
|
@ -63,7 +61,6 @@
|
||||||
"account.requested_follow": "{name} hat dy in folchfersyk stjoerd",
|
"account.requested_follow": "{name} hat dy in folchfersyk stjoerd",
|
||||||
"account.share": "Profyl fan @{name} diele",
|
"account.share": "Profyl fan @{name} diele",
|
||||||
"account.show_reblogs": "Boosts fan @{name} toane",
|
"account.show_reblogs": "Boosts fan @{name} toane",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} berjocht} other {{counter} berjochten}}",
|
|
||||||
"account.unblock": "@{name} deblokkearje",
|
"account.unblock": "@{name} deblokkearje",
|
||||||
"account.unblock_domain": "Domein {domain} deblokkearje",
|
"account.unblock_domain": "Domein {domain} deblokkearje",
|
||||||
"account.unblock_short": "Deblokkearje",
|
"account.unblock_short": "Deblokkearje",
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue