Merge commit 'c6facd27ed191177afb003640b8ee7d0742c7f63' into glitch-soc/merge-upstream
This commit is contained in:
commit
44394878a4
227 files changed
+2723
-1926
No files matched your search
@@ -59,7 +59,7 @@ body:
|
||||
Any additional technical details you may have, like logs or error traces
|
||||
value: |
|
||||
If this is happening on your own Mastodon server, please fill out those:
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.3)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
@@ -60,7 +60,7 @@ body:
|
||||
value: |
|
||||
Please at least include those informations:
|
||||
- Operating system: (eg. Ubuntu 24.04.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.3)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
@@ -23,7 +23,7 @@ runs:
|
||||
shell: bash
|
||||
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }}
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
with:
|
||||
ruby-version: ${{ inputs.ruby-version }}
|
||||
bundler-cache: true
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@c984c1a20bb35a1cbda04477c816cea024418be9 # v1
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
onlyProduction: 'true'
|
||||
|
||||
- name: Cache assets from compilation
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: |
|
||||
public/assets
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
bin/flatware fan bin/rails db:test:prepare
|
||||
|
||||
- name: Cache RSpec persistence file
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: |
|
||||
tmp/rspec/examples.txt
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
with:
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright Chromium browser
|
||||
id: playwright-cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.2
|
||||
4.0.3
|
||||
+1
-1
@@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||
|
||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"]
|
||||
# renovate: datasource=docker depName=docker.io/ruby
|
||||
ARG RUBY_VERSION="4.0.2"
|
||||
ARG RUBY_VERSION="4.0.3"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="24"
|
||||
|
||||
@@ -4,7 +4,7 @@ source 'https://rubygems.org'
|
||||
ruby '>= 3.3.0', '< 4.1.0'
|
||||
|
||||
gem 'propshaft'
|
||||
gem 'puma', '~> 7.0'
|
||||
gem 'puma'
|
||||
gem 'rails', '~> 8.1.0'
|
||||
gem 'thor', '~> 1.2'
|
||||
|
||||
|
||||
+18
-18
@@ -99,8 +99,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1238.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-partitions (1.1242.0)
|
||||
aws-sdk-core (3.246.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -108,10 +108,10 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-kms (1.124.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.219.0)
|
||||
aws-sdk-s3 (1.220.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -132,7 +132,7 @@ GEM
|
||||
binding_of_caller (2.0.0)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.23.0)
|
||||
bootsnap (1.24.1)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
@@ -178,7 +178,7 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (2.0.0)
|
||||
css_parser (2.1.0)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
database_cleaner-active_record (2.2.2)
|
||||
@@ -226,7 +226,7 @@ GEM
|
||||
elasticsearch-dsl (0.1.10)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.3)
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -264,7 +264,7 @@ GEM
|
||||
excon (~> 1.0)
|
||||
formatador (>= 0.2, < 2.0)
|
||||
mime-types
|
||||
fog-json (1.2.0)
|
||||
fog-json (1.3.0)
|
||||
fog-core
|
||||
multi_json (~> 1.10)
|
||||
fog-openstack (1.1.5)
|
||||
@@ -314,7 +314,7 @@ GEM
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
http-cookie (1.1.4)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
@@ -343,7 +343,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.2)
|
||||
irb (1.17.0)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
@@ -458,7 +458,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.6.3)
|
||||
net-imap (0.6.4)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
@@ -471,7 +471,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.2)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
omniauth (2.1.4)
|
||||
@@ -625,7 +625,7 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.0)
|
||||
puma (8.0.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -691,7 +691,7 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.1)
|
||||
rake (13.4.2)
|
||||
rdf (3.3.4)
|
||||
bcp47_spec (~> 0.2)
|
||||
bigdecimal (~> 3.1, >= 3.1.5)
|
||||
@@ -848,7 +848,7 @@ GEM
|
||||
stackprof (0.2.28)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (5.8.0)
|
||||
stoplight (5.8.2)
|
||||
concurrent-ruby
|
||||
zeitwerk
|
||||
stringio (3.2.0)
|
||||
@@ -1045,7 +1045,7 @@ DEPENDENCIES
|
||||
prometheus_exporter (~> 2.2)
|
||||
propshaft
|
||||
public_suffix (~> 7.0)
|
||||
puma (~> 7.0)
|
||||
puma
|
||||
pundit (~> 2.3)
|
||||
rack-attack (~> 6.6)
|
||||
rack-cors
|
||||
@@ -1097,7 +1097,7 @@ DEPENDENCIES
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.2
|
||||
ruby 4.0.3
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.10
|
||||
4.0.11
|
||||
@@ -16,7 +16,7 @@ class Api::V1::NotificationsController < Api::BaseController
|
||||
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
||||
end
|
||||
|
||||
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships
|
||||
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships, supported_notification_types: params[:supported_types]
|
||||
end
|
||||
|
||||
def unread_count
|
||||
@@ -29,7 +29,7 @@ class Api::V1::NotificationsController < Api::BaseController
|
||||
|
||||
def show
|
||||
@notification = current_account.notifications.without_suspended.find(params[:id])
|
||||
render json: @notification, serializer: REST::NotificationSerializer
|
||||
render json: @notification, serializer: REST::NotificationSerializer, supported_notification_types: params[:supported_types]
|
||||
end
|
||||
|
||||
def clear
|
||||
@@ -98,6 +98,8 @@ class Api::V1::NotificationsController < Api::BaseController
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params)
|
||||
params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered, :supported_types)
|
||||
.permit(:limit, :account_id, :include_filtered, types: [], exclude_types: [], supported_types: [])
|
||||
.merge(core_params)
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ class Api::V2::NotificationsController < Api::BaseController
|
||||
'app.notification_grouping.expand_accounts_param' => expand_accounts_param
|
||||
)
|
||||
|
||||
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param
|
||||
render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param, supported_notification_types: params[:supported_types]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,7 +48,7 @@ class Api::V2::NotificationsController < Api::BaseController
|
||||
def show
|
||||
@notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take!
|
||||
presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification]))
|
||||
render json: presenter, serializer: REST::DedupNotificationGroupSerializer
|
||||
render json: presenter, serializer: REST::DedupNotificationGroupSerializer, supported_notification_types: params[:supported_types]
|
||||
end
|
||||
|
||||
def clear
|
||||
@@ -138,7 +138,9 @@ class Api::V2::NotificationsController < Api::BaseController
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params)
|
||||
params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types, :supported_types)
|
||||
.permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: [], supported_types: [])
|
||||
.merge(core_params)
|
||||
end
|
||||
|
||||
def expand_accounts_param
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="black">
|
||||
<path d="M13.2502 8.00029C13.2502 5.10089 10.8996 2.75044 8.00024 2.75029C5.10075 2.75029 2.75024 5.10079 2.75024 8.00029C2.7504 10.8997 5.10084 13.2503 8.00024 13.2503C10.8995 13.2501 13.2501 10.8996 13.2502 8.00029ZM7.41663 4.50029C7.41663 4.17812 7.67808 3.91667 8.00024 3.91667C8.32228 3.91683 8.58301 4.17822 8.58301 4.50029V7.63884L10.5945 8.64458C10.8825 8.78865 10.999 9.13922 10.8551 9.42729C10.711 9.71541 10.3605 9.83276 10.0724 9.68877L7.73877 8.52153C7.54143 8.4227 7.41673 8.221 7.41663 8.00029V4.50029ZM14.4166 8.00029C14.4165 11.5439 11.5438 14.4165 8.00024 14.4167C4.45651 14.4167 1.58316 11.544 1.58301 8.00029C1.58301 4.45646 4.45642 1.58305 8.00024 1.58305C11.5439 1.58321 14.4166 4.45656 14.4166 8.00029Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 799 B |
@@ -8,6 +8,7 @@ import { browserHistory } from 'mastodon/components/router';
|
||||
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from 'mastodon/settings';
|
||||
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
|
||||
|
||||
import { showAlert, showAlertForError } from './alerts';
|
||||
import { useEmoji } from './emojis';
|
||||
@@ -565,7 +566,7 @@ export function clearComposeSuggestions() {
|
||||
};
|
||||
}
|
||||
|
||||
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
|
||||
const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
|
||||
if (fetchComposeSuggestionsAccountsController) {
|
||||
fetchComposeSuggestionsAccountsController.abort();
|
||||
}
|
||||
@@ -592,12 +593,13 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
|
||||
});
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
|
||||
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
|
||||
const custom = await fetchCustomEmojiData();
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
|
||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||
};
|
||||
|
||||
const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
|
||||
const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
|
||||
if (fetchComposeSuggestionsTagsController) {
|
||||
fetchComposeSuggestionsTagsController.abort();
|
||||
}
|
||||
@@ -631,14 +633,14 @@ export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
switch (token[0]) {
|
||||
case ':':
|
||||
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||
void fetchComposeSuggestionsEmojis(dispatch, token);
|
||||
break;
|
||||
case '#':
|
||||
case '#':
|
||||
fetchComposeSuggestionsTags(dispatch, getState, token);
|
||||
fetchComposeSuggestionsTags(dispatch, token);
|
||||
break;
|
||||
default:
|
||||
fetchComposeSuggestionsAccounts(dispatch, getState, token);
|
||||
fetchComposeSuggestionsAccounts(dispatch, token);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import api from '../api';
|
||||
|
||||
export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST';
|
||||
export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS';
|
||||
export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL';
|
||||
|
||||
export function fetchCustomEmojis() {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchCustomEmojisRequest());
|
||||
|
||||
api().get('/api/v1/custom_emojis').then(response => {
|
||||
dispatch(fetchCustomEmojisSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchCustomEmojisFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCustomEmojisRequest() {
|
||||
return {
|
||||
type: CUSTOM_EMOJIS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCustomEmojisSuccess(custom_emojis) {
|
||||
return {
|
||||
type: CUSTOM_EMOJIS_FETCH_SUCCESS,
|
||||
custom_emojis,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCustomEmojisFail(error) {
|
||||
return {
|
||||
type: CUSTOM_EMOJIS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
@@ -34,11 +34,16 @@ export const apiGetCollection = (collectionId: string) =>
|
||||
`v1_alpha/collections/${collectionId}`,
|
||||
);
|
||||
|
||||
export const apiGetAccountCollections = (accountId: string) =>
|
||||
export const apiGetCollectionsCreatedByAccount = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/collections`,
|
||||
);
|
||||
|
||||
export const apiGetCollectionsFeaturingAccount = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/in_collections`,
|
||||
);
|
||||
|
||||
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
|
||||
apiRequestPost<WrappedCollectionAccountItem>(
|
||||
`v1_alpha/collections/${collectionId}/items`,
|
||||
|
||||
+5
-4
@@ -4,6 +4,10 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { AccountRole } from '@/mastodon/models/account';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import {
|
||||
AdminBadge,
|
||||
AutomatedBadge,
|
||||
@@ -11,10 +15,7 @@ import {
|
||||
BlockedBadge,
|
||||
GroupBadge,
|
||||
MutedBadge,
|
||||
} from '@/mastodon/components/badge';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { AccountRole } from '@/mastodon/models/account';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
} from '../badge';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
+4
-3
@@ -6,9 +6,6 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/mastodon/actions/accounts';
|
||||
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
|
||||
import { FollowButton } from '@/mastodon/components/follow_button';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
@@ -16,6 +13,10 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { CopyIconButton } from '../copy_icon_button';
|
||||
import { FollowButton } from '../follow_button';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
+16
-10
@@ -7,21 +7,21 @@ import classNames from 'classnames';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { MiniCard } from '@/mastodon/components/mini_card';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useFieldHtml } from '@/mastodon/features/account_timeline/hooks/useFieldHtml';
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
|
||||
import type { AccountFieldShape } from '@/mastodon/models/account';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { cleanExtraEmojis } from '../../emoji/normalize';
|
||||
import type { AccountField } from '../common';
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
import { CustomEmojiProvider } from '../emoji/context';
|
||||
import type { EmojiHTMLProps } from '../emoji/html';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
import { Icon } from '../icon';
|
||||
import { IconButton } from '../icon_button';
|
||||
import { MiniCard } from '../mini_card';
|
||||
import { useElementHandledLink } from '../status/handled_link';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
@@ -37,6 +37,12 @@ const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
export interface AccountField extends AccountFieldShape {
|
||||
nameHasEmojis: boolean;
|
||||
value_plain: string;
|
||||
valueHasEmojis: boolean;
|
||||
}
|
||||
|
||||
export const AccountHeaderFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
+6
-6
@@ -4,9 +4,6 @@ import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
import { useVisibility } from '@/mastodon/hooks/useVisibility';
|
||||
@@ -19,17 +16,20 @@ import type { Account } from '@/mastodon/models/account';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { FamiliarFollowers } from '../../../components/familiar_followers';
|
||||
import { AccountBio } from '../account_bio';
|
||||
import { Avatar } from '../avatar';
|
||||
import { AnimateEmojiProvider } from '../emoji/context';
|
||||
import { FamiliarFollowers } from '../familiar_followers';
|
||||
|
||||
import { AccountName } from './account_name';
|
||||
import { AccountSubscriptionForm } from './account_subscription_form';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { AccountHeaderFields } from './fields';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
import { AccountName } from './name';
|
||||
import { AccountNote } from './note';
|
||||
import { AccountNumberFields } from './number_fields';
|
||||
import classes from './styles.module.scss';
|
||||
import { AccountSubscriptionForm } from './subscription_form';
|
||||
import { AccountTabs } from './tabs';
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
File renamed without changes.
+2
-1
@@ -21,7 +21,6 @@ import {
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown_menu';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
@@ -40,6 +39,8 @@ import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react'
|
||||
import ReportIcon from '@/material-icons/400-24px/report.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { Dropdown } from '../dropdown_menu';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
+4
-3
@@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AvatarOverlay } from '../avatar_overlay';
|
||||
import { DisplayName } from '../display_name';
|
||||
|
||||
export const MovedNote: React.FC<{
|
||||
accountId: string;
|
||||
+7
-18
@@ -7,12 +7,7 @@ import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/esm/Overlay';
|
||||
|
||||
import FollowerIcon from '@/images/icons/icon_follower.svg?react';
|
||||
import { showAlert } from '@/mastodon/actions/alerts';
|
||||
import { Badge } from '@/mastodon/components/badge';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
@@ -21,6 +16,11 @@ import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
|
||||
import DomainIcon from '@/material-icons/400-24px/language.svg?react';
|
||||
|
||||
import { FollowsYouBadge } from '../badge';
|
||||
import { Button } from '../button';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import { AccountBadges } from './badges';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
@@ -60,17 +60,7 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
<h1>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</h1>
|
||||
{relationship?.followed_by && (
|
||||
<Badge
|
||||
icon={<FollowerIcon className={classes.followerBadgeIcon} />}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{relationship?.followed_by && <FollowsYouBadge />}
|
||||
</div>
|
||||
|
||||
<AccountNameHelp
|
||||
@@ -122,7 +112,6 @@ const AccountNameHelp: FC<{
|
||||
aria-controls={accessibilityId}
|
||||
>
|
||||
{handle}
|
||||
|
||||
<Icon
|
||||
id='help'
|
||||
icon={HelpIcon}
|
||||
@@ -189,7 +178,7 @@ const AccountNameHelp: FC<{
|
||||
</ol>
|
||||
<FormattedMessage
|
||||
id='account.name.help.footer'
|
||||
defaultMessage='Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Mastodon-compatible social apps.'
|
||||
defaultMessage='Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Fediverse apps.'
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
+3
-2
@@ -5,11 +5,12 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
|
||||
|
||||
import { Callout } from '../callout';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
+4
-6
@@ -4,15 +4,13 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import {
|
||||
NumberFields,
|
||||
NumberFieldsItem,
|
||||
} from '@/mastodon/components/number_fields';
|
||||
import { ShortNumber } from '@/mastodon/components/short_number';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { FormattedDateWrapper } from '../formatted_date';
|
||||
import { NumberFields, NumberFieldsItem } from '../number_fields';
|
||||
import { ShortNumber } from '../short_number';
|
||||
|
||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
+7
-48
@@ -37,10 +37,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.followerBadgeIcon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -50,13 +46,14 @@
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
align-items: center;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease-in-out;
|
||||
word-break: break-all;
|
||||
text-align: left;
|
||||
|
||||
> svg {
|
||||
width: 16px;
|
||||
@@ -203,6 +200,9 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
.noteContent {
|
||||
white-space-collapse: preserve-breaks;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.noteEditButton {
|
||||
@@ -275,10 +275,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
&.fieldItem {
|
||||
border-color: var(--color-border-success-soft);
|
||||
}
|
||||
|
||||
dt {
|
||||
padding-right: 24px;
|
||||
padding-right: 32px; // 8px padding + 16px for the icon + 8px gap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,48 +352,10 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs,
|
||||
.noTabs {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
|
||||
@container (width < 500px) {
|
||||
a {
|
||||
flex: 1 1 0px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 18px 0;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 0;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
&:not([aria-current='page']):is(:hover, :focus) {
|
||||
color: var(--color-text-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.active) {
|
||||
color: var(--color-text-brand);
|
||||
border-bottom: 4px solid var(--color-border-brand);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.noTabs {
|
||||
width: 100%;
|
||||
border-width: 0 0 1px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.bannerBase {
|
||||
+8
-7
@@ -8,16 +8,17 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { apiSubscribeByEmail } from 'mastodon/api/accounts';
|
||||
import { apiSubscribeByEmail } from '@/mastodon/api/accounts';
|
||||
import type {
|
||||
ValidationErrorResponse,
|
||||
ValidationError,
|
||||
} from 'mastodon/api_types/errors';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import type { FieldStatus } from 'mastodon/components/form_fields';
|
||||
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
} from '@/mastodon/api_types/errors';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { DisplayName } from '../display_name';
|
||||
import type { FieldStatus } from '../form_fields';
|
||||
import { TextInputField } from '../form_fields/text_input_field';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
+10
-9
@@ -3,11 +3,12 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import { TabLink, TabList } from '../tab_list';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
|
||||
@@ -28,20 +29,20 @@ export const AccountTabs: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||
<TabList>
|
||||
<TabLink isActive={isActive} to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
</TabLink>
|
||||
{show_media && (
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<TabLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</TabLink>
|
||||
)}
|
||||
{show_featured && (
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<TabLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
</TabLink>
|
||||
)}
|
||||
</div>
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { accountFactoryState } from '@/testing/factories';
|
||||
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
|
||||
|
||||
import { PendingBadge } from '../badge';
|
||||
|
||||
import { AccountListItem } from './index';
|
||||
|
||||
@@ -26,12 +28,30 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const FollowsYou: Story = {
|
||||
parameters: {
|
||||
state: {
|
||||
relationships: {
|
||||
'1': relationshipsFactory({
|
||||
followed_by: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStats: Story = {
|
||||
args: {
|
||||
stats: ['posts', 'last-active'],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomBadge: Story = {
|
||||
args: {
|
||||
badge: <PendingBadge />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBorder: Story = {
|
||||
args: {
|
||||
withBorder: true,
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { VerifiedBadge } from 'mastodon/components/badge';
|
||||
import { FollowsYouBadge, VerifiedBadge } from 'mastodon/components/badge';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useRelationship } from 'mastodon/hooks/useRelationship';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
|
||||
import { Avatar } from '../avatar';
|
||||
import { useAccountHandle } from '../display_name/default';
|
||||
import { DisplayNameSimple } from '../display_name/simple';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
import { FollowButton } from '../follow_button';
|
||||
import { FormattedDateWrapper } from '../formatted_date';
|
||||
import { ListItemLink, ListItemWrapper } from '../list_item';
|
||||
import { NumberFields, NumberFieldsItem } from '../number_fields';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
import { ShortNumber } from '../short_number';
|
||||
@@ -29,9 +34,10 @@ type Stat = 'followers' | 'following' | 'posts' | 'joined' | 'last-active';
|
||||
interface Props {
|
||||
accountId: string | undefined;
|
||||
stats?: Stat[];
|
||||
renderButton?: (options: RenderButtonOptions) => React.ReactNode;
|
||||
withBio?: boolean;
|
||||
withBorder?: boolean;
|
||||
badge?: ReactNode;
|
||||
renderButton?: (options: RenderButtonOptions) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_STATS: Stat[] = ['followers', 'posts', 'last-active'];
|
||||
@@ -48,10 +54,12 @@ export const AccountListItem: React.FC<Props> = ({
|
||||
stats = DEFAULT_STATS,
|
||||
withBio = true,
|
||||
withBorder = true,
|
||||
badge: badgeProp,
|
||||
renderButton = defaultRenderButton,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const handle = useAccountHandle(account, domain);
|
||||
const relationship = useRelationship(accountId);
|
||||
|
||||
const createdThisYear = useMemo(
|
||||
@@ -63,22 +71,34 @@ export const AccountListItem: React.FC<Props> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const badge =
|
||||
badgeProp ?? (relationship?.followed_by ? <FollowsYouBadge /> : null);
|
||||
|
||||
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} data-with-border={withBorder}>
|
||||
<div className={classes.header}>
|
||||
<Account
|
||||
id={accountId}
|
||||
minimal
|
||||
size={40}
|
||||
withMenu={false}
|
||||
withBorder={false}
|
||||
className={classes.account}
|
||||
/>
|
||||
|
||||
{renderButton({ accountId, relationship })}
|
||||
</div>
|
||||
<ListItemWrapper
|
||||
className={classes.main}
|
||||
icon={<Avatar account={account} size={40} />}
|
||||
sideContent={
|
||||
<span className={classes.button}>
|
||||
{renderButton({ accountId, relationship })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ListItemLink
|
||||
to={`/@${account.acct}`}
|
||||
data-hover-card-account={accountId}
|
||||
subtitle={handle}
|
||||
>
|
||||
<DisplayNameSimple
|
||||
account={account}
|
||||
className={classes.displayName}
|
||||
/>
|
||||
{badge && <span className={classes.badge}>{badge}</span>}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
|
||||
<NumberFields>
|
||||
{stats.includes('followers') && (
|
||||
|
||||
@@ -10,22 +10,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
.main {
|
||||
--list-item-padding: 0;
|
||||
}
|
||||
|
||||
.account {
|
||||
--account-name-size: 15px;
|
||||
--account-handle-color: var(--color-text-secondary);
|
||||
--account-handle-size: 13px;
|
||||
--account-bio-color: var(--color-text-primary);
|
||||
--account-bio-size: 13px;
|
||||
--account-outer-spacing: 0;
|
||||
.displayName {
|
||||
// Spacing for badge
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
flex-grow: 1;
|
||||
margin-inline-end: 16px;
|
||||
.badge {
|
||||
// Sort out vertical alignment next to name
|
||||
vertical-align: -4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.verifiedBadge {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AdminIcon from '@/images/icons/icon_admin.svg?react';
|
||||
import ClockIcon from '@/images/icons/icon_clock.svg?react';
|
||||
import FollowerIcon from '@/images/icons/icon_follower.svg?react';
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import type { OnAttributeHandler } from '@/mastodon/utils/html';
|
||||
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
|
||||
@@ -32,6 +34,11 @@ interface BadgeProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
| 'danger';
|
||||
}
|
||||
|
||||
type PresetBadgeProps = Omit<
|
||||
BadgeProps,
|
||||
'label' | 'icon' | 'domain' | 'roleId'
|
||||
>;
|
||||
|
||||
export const Badge: FC<BadgeProps> = ({
|
||||
icon = <PersonIcon />,
|
||||
variant = 'default',
|
||||
@@ -83,13 +90,32 @@ export const GroupBadge: FC<Partial<BadgeProps>> = ({ label, ...props }) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => (
|
||||
export const AutomatedBadge: FC<PresetBadgeProps> = (props) => (
|
||||
<Badge
|
||||
icon={<SmartToyIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />
|
||||
}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FollowsYouBadge: FC<PresetBadgeProps> = (props) => (
|
||||
<Badge
|
||||
icon={<FollowerIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const PendingBadge: FC<PresetBadgeProps> = (props) => (
|
||||
<Badge
|
||||
variant='warning'
|
||||
icon={<ClockIcon />}
|
||||
label={<FormattedMessage id='account.pending' defaultMessage='Pending' />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.badge {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
padding: 4px;
|
||||
@@ -61,7 +62,6 @@
|
||||
|
||||
.warning {
|
||||
background-color: var(--color-bg-warning-softest);
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
.variantSubtle {
|
||||
border: 1px solid var(--color-bg-brand-softest);
|
||||
border: 1px solid var(--color-border-brand-soft);
|
||||
background-color: var(--color-bg-primary);
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -276,9 +276,11 @@ export const ColumnHeader: React.FC<Props> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const HeadingElement = hasTitle ? 'h1' : 'div';
|
||||
|
||||
const component = (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
<HeadingElement className={buttonClassName}>
|
||||
{hasTitle && (
|
||||
<>
|
||||
{backButton}
|
||||
@@ -311,7 +313,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
||||
{extraButton}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</h1>
|
||||
</HeadingElement>
|
||||
|
||||
<div
|
||||
className={collapsibleClassName}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
padding: 24px;
|
||||
gap: 16px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ElephantImage from '@/images/elephant_ui.svg?react';
|
||||
|
||||
import classes from './empty_state.module.scss';
|
||||
@@ -19,6 +21,7 @@ export const EmptyState: React.FC<{
|
||||
title?: React.ReactNode;
|
||||
message?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({
|
||||
image = 'default',
|
||||
title = (
|
||||
@@ -26,11 +29,12 @@ export const EmptyState: React.FC<{
|
||||
),
|
||||
message,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const imageToRender = typeof image === 'string' ? images[image] : image;
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classNames(classes.wrapper, className)}>
|
||||
{(title || message || imageToRender) && (
|
||||
<div className={classes.content}>
|
||||
{imageToRender}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
@@ -50,6 +49,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
textarea.input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
+5
-4
@@ -2,10 +2,11 @@ import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { revealAccount } from 'mastodon/actions/accounts_typed';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
import { revealAccount } from '@/mastodon/actions/accounts_typed';
|
||||
import { domain } from '@/mastodon/initial_state';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
export const LimitedAccountHint: React.FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
@@ -1,16 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
interface ModalShellProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModalShell: React.FC<ModalShellProps> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
export const ModalShell = polymorphicForwardRef<'form', ModalShellProps>(
|
||||
({ as: Comp = 'form', children, className, ...restProps }, ref) => (
|
||||
<Comp
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
'safety-action-modal',
|
||||
@@ -18,9 +19,10 @@ export const ModalShell: React.FC<ModalShellProps> = ({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</Comp>
|
||||
),
|
||||
);
|
||||
ModalShell.displayName = 'ModalShell';
|
||||
|
||||
export const ModalShellBody: React.FC<ModalShellProps> = ({
|
||||
children,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Children, forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { hasReactChildren } from '@/mastodon/utils/has_react_children';
|
||||
|
||||
import { LoadingIndicator } from '../loading_indicator';
|
||||
|
||||
export const Scrollable = forwardRef<
|
||||
@@ -36,7 +38,7 @@ export const ItemList = forwardRef<
|
||||
emptyMessage?: React.ReactNode;
|
||||
}
|
||||
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
|
||||
if (!isLoading && Children.count(children) === 0 && emptyMessage) {
|
||||
if (!isLoading && !hasReactChildren(children) && emptyMessage) {
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<span>{emptyMessage}</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { debounce } from 'lodash';
|
||||
import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
||||
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
||||
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses';
|
||||
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/components/pinned_statuses';
|
||||
|
||||
import { StatusQuoteManager } from '../components/status_quoted';
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
interface TabListProps {
|
||||
/**
|
||||
* Setting this will remove the default border and
|
||||
* horizontal padding from the component
|
||||
*/
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a simple row of links as tabs.
|
||||
* The current page will be highlighted automatically based on the link destination.
|
||||
*/
|
||||
export const TabList: FC<TabListProps & ComponentPropsWithoutRef<'div'>> = ({
|
||||
plain,
|
||||
className,
|
||||
children,
|
||||
...otherProps
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
{...otherProps}
|
||||
className={classNames(
|
||||
className,
|
||||
classes.tabList,
|
||||
!plain && classes.withSpaceAndBorder,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabLink: FC<NavLinkProps> = ({
|
||||
className,
|
||||
children,
|
||||
...otherProps
|
||||
}) => {
|
||||
return (
|
||||
<NavLink className={classNames(classes.tab, className)} {...otherProps}>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
.tabList {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.withSpaceAndBorder {
|
||||
padding-inline: 16px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: block;
|
||||
padding: 18px 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border-radius: 0;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
@container (width < 500px) {
|
||||
flex: 1 1 0px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// Override silly global border radius on focused links
|
||||
border-radius: 0;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:not(:global(.active)):is(:hover, :focus) {
|
||||
color: var(--color-text-brand-soft);
|
||||
}
|
||||
|
||||
&:global(.active) {
|
||||
color: var(--color-text-brand);
|
||||
border-bottom: 4px solid var(--color-border-brand);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TabList, TabLink } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/TabList',
|
||||
component: TabList,
|
||||
subcomponents: { TabLink },
|
||||
} satisfies Meta<typeof TabList>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<TabList>
|
||||
<TabLink to='/'>Activity</TabLink>
|
||||
<TabLink to='/media'>Media</TabLink>
|
||||
<TabLink to='/featured'>Featured</TabLink>
|
||||
</TabList>
|
||||
),
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render: () => (
|
||||
<TabList plain>
|
||||
<TabLink to='/'>Activity</TabLink>
|
||||
<TabLink to='/media'>Media</TabLink>
|
||||
<TabLink to='/featured'>Featured</TabLink>
|
||||
</TabList>
|
||||
),
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { hydrateStore } from 'mastodon/actions/store';
|
||||
import { Router } from 'mastodon/components/router';
|
||||
@@ -13,7 +12,6 @@ if (initialState) {
|
||||
store.dispatch(hydrateStore(initialState));
|
||||
}
|
||||
|
||||
store.dispatch(fetchCustomEmojis());
|
||||
store.dispatch(fetchServer());
|
||||
|
||||
const ComposeContainer = () => (
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Route } from 'react-router-dom';
|
||||
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
|
||||
|
||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||
import { hydrateStore } from 'mastodon/actions/store';
|
||||
import { connectUserStream } from 'mastodon/actions/streaming';
|
||||
import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||
@@ -26,9 +24,6 @@ const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||
const hydrateAction = hydrateStore(initialState);
|
||||
|
||||
store.dispatch(hydrateAction);
|
||||
if (initialState.meta.me) {
|
||||
store.dispatch(fetchCustomEmojis());
|
||||
}
|
||||
|
||||
export default class Mastodon extends PureComponent {
|
||||
identity = createIdentityContext(initialState);
|
||||
|
||||
@@ -107,11 +107,11 @@ export const messages = defineMessages({
|
||||
},
|
||||
profileTabTitle: {
|
||||
id: 'account_edit.profile_tab.title',
|
||||
defaultMessage: 'Profile tab settings',
|
||||
defaultMessage: 'Profile display settings',
|
||||
},
|
||||
profileTabSubtitle: {
|
||||
id: 'account_edit.profile_tab.subtitle',
|
||||
defaultMessage: 'Customize the tabs on your profile and what they display.',
|
||||
defaultMessage: 'Customize how your profile is displayed.',
|
||||
},
|
||||
advancedSettingsTitle: {
|
||||
id: 'account_edit.advanced_settings.title',
|
||||
|
||||
@@ -27,7 +27,8 @@ export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
const handleToggleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { name, checked } = event.target;
|
||||
void dispatch(patchProfile({ [name]: checked }));
|
||||
const targetChecked = name === 'hide_collections' ? !checked : checked;
|
||||
void dispatch(patchProfile({ [name]: targetChecked }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
@@ -101,6 +102,25 @@ export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
checked={!profile.hideCollections}
|
||||
onChange={handleToggleChange}
|
||||
disabled={isPending}
|
||||
name='hide_collections'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_relations.title'
|
||||
defaultMessage='Show ‘Followers’ and ‘Following’'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_relations.description'
|
||||
defaultMessage='Shows accounts you follow and follows you to other users in your profile. People will still be able to see if you are following them.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
@@ -6,29 +6,29 @@ import { useHistory } from 'react-router';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
|
||||
import { AccountListItem } from 'mastodon/components/account_list_item';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import { fetchEndorsedAccounts } from '@/mastodon/actions/accounts';
|
||||
import { AccountHeader } from '@/mastodon/components/account_header';
|
||||
import { AccountListItem } from '@/mastodon/components/account_list_item';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
Scrollable,
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list';
|
||||
import { TruncatedListItems } from 'mastodon/components/truncated_list';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
} from '@/mastodon/components/scrollable_list/components';
|
||||
import type { TruncatedListItemInfo } from '@/mastodon/components/truncated_list';
|
||||
import { TruncatedListItems } from '@/mastodon/components/truncated_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from '@/mastodon/features/ui/components/column';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
|
||||
import { useAccountCollections } from '../collections';
|
||||
import { CollectionListItem } from '../collections/components/collection_list_item';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
@@ -67,7 +67,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
const { collections, status: collectionsLoadStatus } =
|
||||
useAccountCollections(accountId);
|
||||
useCollectionsCreatedBy(accountId);
|
||||
|
||||
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
|
||||
collections,
|
||||
|
||||
@@ -4,23 +4,23 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { List as ImmutableList, isList } from 'immutable';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { expandAccountMediaTimeline } from '@/mastodon/actions/timelines';
|
||||
import { AccountHeader } from '@/mastodon/components/account_header';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import ScrollableList from '@/mastodon/components/scrollable_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from '@/mastodon/features/ui/components/column';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import type { MediaAttachment } from '@/mastodon/models/media_attachment';
|
||||
import {
|
||||
useAppSelector,
|
||||
useAppDispatch,
|
||||
createAppSelector,
|
||||
} from 'mastodon/store';
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import { MediaItem } from './components/media_item';
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { AccountFieldShape } from '@/mastodon/models/account';
|
||||
|
||||
export interface AccountField extends AccountFieldShape {
|
||||
nameHasEmojis: boolean;
|
||||
value_plain: string;
|
||||
valueHasEmojis: boolean;
|
||||
}
|
||||
+2
-2
@@ -13,8 +13,8 @@ import { useOverflowButton } from '@/mastodon/hooks/useOverflow';
|
||||
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { useAccountContext } from './context';
|
||||
import classes from './styles.module.scss';
|
||||
import { useAccountContext } from '../hooks/useAccountContext';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
// Fetch tags.
|
||||
+3
-4
@@ -7,14 +7,13 @@ import { useParams } from 'react-router';
|
||||
|
||||
import Overlay from 'react-overlays/esm/Overlay';
|
||||
|
||||
import { AccountTabs } from '@/mastodon/components/account_header/tabs';
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
|
||||
import { AccountTabs } from '../components/tabs';
|
||||
|
||||
import { useAccountContext } from './context';
|
||||
import classes from './styles.module.scss';
|
||||
import { useAccountContext } from '../hooks/useAccountContext';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const AccountFilters: FC = () => {
|
||||
const { acct } = useParams<{ acct: string }>();
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
|
||||
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (!relationship) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='account__header__info'>
|
||||
{(relationship.followed_by || relationship.requested_by) && (
|
||||
<span className='relationship-tag'>
|
||||
<AccountInfoFollower relationship={relationship} />
|
||||
</span>
|
||||
)}
|
||||
{relationship.blocking && (
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.muting && (
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.domain_blocking && (
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
return (
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import IconPinned from '@/images/icons/icon_pinned.svg?react';
|
||||
import { Badge } from '@/mastodon/components/badge';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { StatusHeader } from '@/mastodon/components/status/header';
|
||||
import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header';
|
||||
|
||||
import { useAccountContext } from '../hooks/useAccountContext';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
|
||||
featured,
|
||||
...args
|
||||
}) => {
|
||||
if (!featured) {
|
||||
return <StatusHeader {...args} />;
|
||||
}
|
||||
return (
|
||||
<StatusHeader {...args} className={classes.pinnedStatusHeader}>
|
||||
<Badge
|
||||
className={classes.pinnedBadge}
|
||||
icon={<Icon id='pinned' icon={IconPinned} />}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned'
|
||||
defaultMessage='Pinned'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StatusHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const PinnedShowAllButton: FC = () => {
|
||||
const { onShowAllPinned } = useAccountContext();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onShowAllPinned}
|
||||
className={classNames(classes.pinnedViewAllButton, 'focusable')}
|
||||
>
|
||||
<Icon id='pinned' icon={IconPinned} />
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned.view_all'
|
||||
defaultMessage='View all pinned posts'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
createAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const MAX_SUGGESTED_TAGS = 3;
|
||||
|
||||
+15
-25
@@ -1,4 +1,3 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
import { useStorageState } from '@/mastodon/hooks/useStorage';
|
||||
|
||||
interface AccountTimelineContextValue {
|
||||
accountId: string;
|
||||
accountId: string | null;
|
||||
boosts: boolean;
|
||||
replies: boolean;
|
||||
showAllPinned: boolean;
|
||||
@@ -19,13 +18,20 @@ interface AccountTimelineContextValue {
|
||||
onShowAllPinned: () => void;
|
||||
}
|
||||
|
||||
const AccountTimelineContext =
|
||||
export const AccountTimelineContext =
|
||||
createContext<AccountTimelineContextValue | null>(null);
|
||||
|
||||
export const AccountTimelineProvider: FC<{
|
||||
accountId: string;
|
||||
children: ReactNode;
|
||||
}> = ({ accountId, children }) => {
|
||||
export function useAccountContext() {
|
||||
const values = useContext(AccountTimelineContext);
|
||||
if (!values) {
|
||||
throw new Error(
|
||||
'useAccountFilters must be used within an AccountTimelineProvider',
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export const useAccountContextValue = (accountId?: string | null) => {
|
||||
const storageOptions = {
|
||||
type: 'local',
|
||||
prefix: 'account-filters',
|
||||
@@ -62,9 +68,9 @@ export const AccountTimelineProvider: FC<{
|
||||
}, []);
|
||||
|
||||
// Memoize the context value to avoid unnecessary re-renders.
|
||||
const value = useMemo(
|
||||
return useMemo(
|
||||
() => ({
|
||||
accountId,
|
||||
accountId: accountId ?? null,
|
||||
boosts,
|
||||
replies,
|
||||
showAllPinned,
|
||||
@@ -82,20 +88,4 @@ export const AccountTimelineProvider: FC<{
|
||||
showAllPinned,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountTimelineContext.Provider value={value}>
|
||||
{children}
|
||||
</AccountTimelineContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAccountContext() {
|
||||
const values = useContext(AccountTimelineContext);
|
||||
if (!values) {
|
||||
throw new Error(
|
||||
'useAccountFilters must be used within an AccountTimelineProvider',
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines';
|
||||
import {
|
||||
expandTimelineByKey,
|
||||
timelineKey,
|
||||
} from '@/mastodon/actions/timelines_typed';
|
||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { useAccountContext } from './useAccountContext';
|
||||
|
||||
export function usePinnedStatusIds({
|
||||
accountId,
|
||||
tagged,
|
||||
forceEmptyState = false,
|
||||
}: {
|
||||
accountId: string;
|
||||
tagged?: string;
|
||||
forceEmptyState?: boolean;
|
||||
}) {
|
||||
const pinnedKey = timelineKey({
|
||||
type: 'account',
|
||||
userId: accountId,
|
||||
tagged,
|
||||
pinned: true,
|
||||
replies: true,
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(expandTimelineByKey({ key: pinnedKey }));
|
||||
}, [dispatch, pinnedKey]);
|
||||
|
||||
const pinnedTimeline = useAppSelector((state) =>
|
||||
selectTimelineByKey(state, pinnedKey),
|
||||
);
|
||||
|
||||
const { showAllPinned } = useAccountContext();
|
||||
|
||||
const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining.
|
||||
const pinnedStatusIds = useMemo(() => {
|
||||
if (!pinnedTimelineItems || forceEmptyState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (pinnedTimelineItems.size <= 1 || showAllPinned) {
|
||||
return pinnedTimelineItems;
|
||||
}
|
||||
return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL);
|
||||
}, [forceEmptyState, pinnedTimelineItems, showAllPinned]);
|
||||
|
||||
return {
|
||||
statusIds: pinnedStatusIds,
|
||||
isLoading: !!pinnedTimeline?.isLoading,
|
||||
showAllPinned,
|
||||
};
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
|
||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
|
||||
import { ColumnBackButton } from '../../components/column_back_button';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import StatusList from '../../components/status_list';
|
||||
import Column from '../ui/components/column';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
|
||||
import { AccountHeader } from './components/account_header';
|
||||
import { LimitedAccountHint } from './components/limited_account_hint';
|
||||
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
|
||||
|
||||
const emptyList = ImmutableList();
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
|
||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
||||
|
||||
if (accountId === null) {
|
||||
return {
|
||||
isLoading: false,
|
||||
isAccount: false,
|
||||
statusIds: emptyList,
|
||||
};
|
||||
} else if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
statusIds: emptyList,
|
||||
};
|
||||
}
|
||||
|
||||
const path = withReplies ? `${accountId}:with_replies` : `${accountId}${tagged ? `:${tagged}` : ''}`;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
||||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
|
||||
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||
hidden: getAccountHidden(state, accountId),
|
||||
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
|
||||
};
|
||||
};
|
||||
|
||||
class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
tagged: PropTypes.string,
|
||||
}).isRequired,
|
||||
accountId: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
withReplies: PropTypes.bool,
|
||||
blockedBy: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
suspended: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
_load () {
|
||||
const { accountId, withReplies, params: { tagged }, dispatch } = this.props;
|
||||
|
||||
dispatch(fetchAccount(accountId));
|
||||
|
||||
if (!withReplies) {
|
||||
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
|
||||
|
||||
if (accountId === me) {
|
||||
dispatch(connectTimeline(`account:${me}`));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { params: { acct }, accountId, dispatch } = this.props;
|
||||
|
||||
if (accountId) {
|
||||
this._load();
|
||||
} else {
|
||||
dispatch(lookupAccount(acct));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params: { acct, tagged }, accountId, withReplies, dispatch } = this.props;
|
||||
|
||||
if (prevProps.accountId !== accountId && accountId) {
|
||||
this._load();
|
||||
} else if (prevProps.params.acct !== acct) {
|
||||
dispatch(lookupAccount(acct));
|
||||
} else if (prevProps.params.tagged !== tagged) {
|
||||
if (!withReplies) {
|
||||
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
|
||||
}
|
||||
|
||||
if (prevProps.accountId === me && accountId !== me) {
|
||||
dispatch(disconnectTimeline({ timeline: `account:${me}` }));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { dispatch, accountId } = this.props;
|
||||
|
||||
if (accountId === me) {
|
||||
dispatch(disconnectTimeline({ timeline: `account:${me}` }));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies, tagged: this.props.params.tagged }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl, params: { tagged } } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
} else if (!isLoading && !isAccount) {
|
||||
return (
|
||||
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
|
||||
);
|
||||
}
|
||||
|
||||
let emptyMessage;
|
||||
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
|
||||
if (suspended) {
|
||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
||||
} else if (hidden) {
|
||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
||||
} else if (remote && statusIds.isEmpty()) {
|
||||
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
|
||||
} else {
|
||||
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<StatusList
|
||||
prepend={
|
||||
<>
|
||||
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={tagged} />
|
||||
{!forceEmptyState && <FeaturedCarousel accountId={this.props.accountId} tagged={tagged} />}
|
||||
</>
|
||||
}
|
||||
alwaysPrepend
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={forceEmptyState ? emptyList : statusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='account'
|
||||
withCounters
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AccountTimeline);
|
||||
+16
-14
@@ -12,8 +12,10 @@ import {
|
||||
expandTimelineByKey,
|
||||
timelineKey,
|
||||
} from '@/mastodon/actions/timelines_typed';
|
||||
import { AccountHeader } from '@/mastodon/components/account_header';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import StatusList from '@/mastodon/components/status_list';
|
||||
@@ -26,23 +28,23 @@ import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../components/account_header';
|
||||
import { LimitedAccountHint } from '../components/limited_account_hint';
|
||||
|
||||
import { AccountTimelineProvider, useAccountContext } from './context';
|
||||
import { FeaturedTags } from './featured_tags';
|
||||
import { AccountFilters } from './filters';
|
||||
import { FeaturedTags } from './components/featured_tags';
|
||||
import { AccountFilters } from './components/filters';
|
||||
import { renderPinnedStatusHeader } from './components/pinned_statuses';
|
||||
import { TagSuggestions } from './components/tags_suggestions';
|
||||
import {
|
||||
renderPinnedStatusHeader,
|
||||
usePinnedStatusIds,
|
||||
} from './pinned_statuses';
|
||||
AccountTimelineContext,
|
||||
useAccountContext,
|
||||
useAccountContextValue,
|
||||
} from './hooks/useAccountContext';
|
||||
import { usePinnedStatusIds } from './hooks/usePinned';
|
||||
import classes from './styles.module.scss';
|
||||
import { TagSuggestions } from './tags_suggestions';
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
|
||||
const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const AccountTimeline: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useAccountId();
|
||||
const accountContext = useAccountContextValue(accountId);
|
||||
|
||||
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
|
||||
if (accountId === null) {
|
||||
@@ -59,13 +61,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
|
||||
// Add this key to remount the timeline when accountId changes.
|
||||
return (
|
||||
<AccountTimelineProvider accountId={accountId}>
|
||||
<AccountTimelineContext.Provider value={accountContext}>
|
||||
<InnerTimeline
|
||||
accountId={accountId}
|
||||
key={accountId}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
</AccountTimelineProvider>
|
||||
</AccountTimelineContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -183,4 +185,4 @@ const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AccountTimelineV2;
|
||||
export default AccountTimeline;
|
||||
@@ -2,6 +2,7 @@ import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { AccountField } from '@/mastodon/components/account_header/fields';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import {
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
ModalShellBody,
|
||||
} from '@/mastodon/components/modal_shell';
|
||||
|
||||
import type { AccountField } from '../common';
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
@@ -97,7 +97,7 @@ export const AccountJoinModal: FC<{
|
||||
}, [anniversary, handle, dispatch, isMe]);
|
||||
|
||||
return (
|
||||
<ModalShell className={classes.joinShell}>
|
||||
<ModalShell as='div' className={classes.joinShell}>
|
||||
<ModalShellBody className={classes.joinWrapper}>
|
||||
<AccountAnniversaryImage anniversary={anniversary} />
|
||||
|
||||
|
||||
File renamed without changes.
@@ -1,110 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import IconPinned from '@/images/icons/icon_pinned.svg?react';
|
||||
import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines';
|
||||
import {
|
||||
expandTimelineByKey,
|
||||
timelineKey,
|
||||
} from '@/mastodon/actions/timelines_typed';
|
||||
import { Badge } from '@/mastodon/components/badge';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { StatusHeader } from '@/mastodon/components/status/header';
|
||||
import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header';
|
||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { useAccountContext } from './context';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export function usePinnedStatusIds({
|
||||
accountId,
|
||||
tagged,
|
||||
forceEmptyState = false,
|
||||
}: {
|
||||
accountId: string;
|
||||
tagged?: string;
|
||||
forceEmptyState?: boolean;
|
||||
}) {
|
||||
const pinnedKey = timelineKey({
|
||||
type: 'account',
|
||||
userId: accountId,
|
||||
tagged,
|
||||
pinned: true,
|
||||
replies: true,
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(expandTimelineByKey({ key: pinnedKey }));
|
||||
}, [dispatch, pinnedKey]);
|
||||
|
||||
const pinnedTimeline = useAppSelector((state) =>
|
||||
selectTimelineByKey(state, pinnedKey),
|
||||
);
|
||||
|
||||
const { showAllPinned } = useAccountContext();
|
||||
|
||||
const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining.
|
||||
const pinnedStatusIds = useMemo(() => {
|
||||
if (!pinnedTimelineItems || forceEmptyState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (pinnedTimelineItems.size <= 1 || showAllPinned) {
|
||||
return pinnedTimelineItems;
|
||||
}
|
||||
return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL);
|
||||
}, [forceEmptyState, pinnedTimelineItems, showAllPinned]);
|
||||
|
||||
return {
|
||||
statusIds: pinnedStatusIds,
|
||||
isLoading: !!pinnedTimeline?.isLoading,
|
||||
showAllPinned,
|
||||
};
|
||||
}
|
||||
|
||||
export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
|
||||
featured,
|
||||
...args
|
||||
}) => {
|
||||
if (!featured) {
|
||||
return <StatusHeader {...args} />;
|
||||
}
|
||||
return (
|
||||
<StatusHeader {...args} className={classes.pinnedStatusHeader}>
|
||||
<Badge
|
||||
className={classes.pinnedBadge}
|
||||
icon={<Icon id='pinned' icon={IconPinned} />}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned'
|
||||
defaultMessage='Pinned'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StatusHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const PinnedShowAllButton: FC = () => {
|
||||
const { onShowAllPinned } = useAccountContext();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onShowAllPinned}
|
||||
className={classNames(classes.pinnedViewAllButton, 'focusable')}
|
||||
>
|
||||
<Icon id='pinned' icon={IconPinned} />
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned.view_all'
|
||||
defaultMessage='View all pinned posts'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
|
||||
import type { StatusHeaderProps } from '@/mastodon/components/status/header';
|
||||
import {
|
||||
StatusDisplayName,
|
||||
StatusEditedAt,
|
||||
StatusVisibility,
|
||||
} from '@/mastodon/components/status/header';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
|
||||
export const AccountStatusHeader: FC<StatusHeaderProps> = ({
|
||||
status,
|
||||
account,
|
||||
children,
|
||||
avatarSize = 48,
|
||||
wrapperProps,
|
||||
onHeaderClick,
|
||||
}) => {
|
||||
const statusAccount = status.get('account') as Account | undefined;
|
||||
const editedAt = status.get('edited_at') as string;
|
||||
|
||||
return (
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
<div
|
||||
onClick={onHeaderClick}
|
||||
onAuxClick={onHeaderClick}
|
||||
{...wrapperProps}
|
||||
className='status__info'
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
>
|
||||
<Link
|
||||
to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
|
||||
className='status__relative-time'
|
||||
>
|
||||
<StatusVisibility visibility={status.get('visibility')} />
|
||||
<RelativeTimestamp timestamp={status.get('created_at') as string} />
|
||||
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||
</Link>
|
||||
|
||||
<StatusDisplayName
|
||||
statusAccount={statusAccount}
|
||||
friendAccount={account}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
.wrapper {
|
||||
--list-item-padding: 16px;
|
||||
--list-item-padding-block: 12px;
|
||||
--list-item-gap: 16px;
|
||||
|
||||
&:not(.wrapperWithoutBorder) {
|
||||
|
||||
@@ -111,7 +111,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h2'
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={collectionInfo}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,12 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { PendingBadge } from '@/mastodon/components/badge';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
CollectionAccountItem,
|
||||
} from 'mastodon/api_types/collections';
|
||||
import type { RenderButtonOptions } from 'mastodon/components/account_list_item';
|
||||
import {
|
||||
AccountListItem,
|
||||
@@ -75,47 +79,53 @@ const SensitiveScreen: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const getCollectionAccounts = createAppSelector(
|
||||
type CollectionItemWithAccount = CollectionAccountItem & {
|
||||
account?: Account | null;
|
||||
};
|
||||
|
||||
const getCollectionItems = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(state, collectionId?: string) =>
|
||||
state.collections.collections[collectionId ?? '']?.items,
|
||||
],
|
||||
(accounts, collectionAccountItems) =>
|
||||
(collectionAccountItems ?? []).map(({ account_id }) =>
|
||||
account_id ? accounts.get(account_id) : null,
|
||||
(collectionAccountItems ?? []).map(
|
||||
(item): CollectionItemWithAccount => ({
|
||||
...item,
|
||||
account: item.account_id ? accounts.get(item.account_id) : null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const CollectionAccountsList: React.FC<{
|
||||
collection?: ApiCollectionJSON;
|
||||
isLoading: boolean;
|
||||
}> = ({ collection, isLoading }) => {
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
const intl = useIntl();
|
||||
const confirmRevoke = useConfirmRevoke(collection);
|
||||
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
const isOwnCollection = collection?.account_id === me;
|
||||
const { account_id: collectionOwnerId, id } = collection ?? {};
|
||||
const isOwnCollection = collection.account_id === me;
|
||||
const { account_id: collectionOwnerId, id } = collection;
|
||||
|
||||
const relationships = useAppSelector((state) => state.relationships);
|
||||
const collectionAccounts = useAppSelector((state) =>
|
||||
getCollectionAccounts(state, id),
|
||||
getCollectionItems(state, id),
|
||||
);
|
||||
|
||||
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
||||
const visibleAccounts: Account[] = [];
|
||||
const hiddenAccounts: Account[] = [];
|
||||
const visibleAccounts: CollectionItemWithAccount[] = [];
|
||||
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
||||
|
||||
collectionAccounts.forEach((item) => {
|
||||
if (!item) {
|
||||
// We currently simply hide unavailable accounts, this includes
|
||||
// accounts that are pending inclusion; at least for the collection
|
||||
// owner we should display an indication of pending users
|
||||
const { account, account_id } = item;
|
||||
|
||||
if (!isOwnCollection && !account) {
|
||||
// Hide unavailable accounts unless you own this collection
|
||||
return;
|
||||
}
|
||||
|
||||
const relationship = relationships.get(item.id);
|
||||
const relationship = account_id ? relationships.get(account_id) : null;
|
||||
if (relationship?.blocking || relationship?.muting) {
|
||||
hiddenAccounts.push(item);
|
||||
} else {
|
||||
@@ -124,7 +134,7 @@ export const CollectionAccountsList: React.FC<{
|
||||
});
|
||||
|
||||
return { visibleAccounts, hiddenAccounts };
|
||||
}, [collectionAccounts, relationships]);
|
||||
}, [collectionAccounts, isOwnCollection, relationships]);
|
||||
|
||||
const renderAccountItemButton = useCallback(
|
||||
({ relationship, accountId }: RenderButtonOptions) => {
|
||||
@@ -159,15 +169,16 @@ export const CollectionAccountsList: React.FC<{
|
||||
index,
|
||||
totalListLength,
|
||||
isLastElement,
|
||||
}: TruncatedListItemInfo<Account>) => (
|
||||
}: TruncatedListItemInfo<CollectionItemWithAccount>) => (
|
||||
<Article
|
||||
key={item.id}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={totalListLength}
|
||||
>
|
||||
<AccountListItem
|
||||
accountId={item.id}
|
||||
accountId={item.account_id}
|
||||
withBorder={!isLastElement}
|
||||
badge={item.state === 'pending' ? <PendingBadge /> : null}
|
||||
renderButton={renderAccountItemButton}
|
||||
/>
|
||||
</Article>
|
||||
@@ -182,53 +193,41 @@ export const CollectionAccountsList: React.FC<{
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
{collection ? (
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='collections.detail.accounts_heading'
|
||||
defaultMessage='Accounts'
|
||||
/>
|
||||
)}
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</h3>
|
||||
{collection && (
|
||||
<SensitiveScreen
|
||||
sensitive={!isOwnCollection && collection.sensitive}
|
||||
focusTargetRef={listHeadingRef}
|
||||
>
|
||||
<ItemList
|
||||
isLoading={isLoading}
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
</SensitiveScreen>
|
||||
)}
|
||||
<SensitiveScreen
|
||||
sensitive={!isOwnCollection && collection.sensitive}
|
||||
focusTargetRef={listHeadingRef}
|
||||
>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
</SensitiveScreen>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -6,17 +6,24 @@ import { Helmet } from 'react-helmet';
|
||||
import { useHistory, useLocation, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { useAccountHandle } from '@/mastodon/components/display_name/default';
|
||||
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
CollectionAccountItem,
|
||||
} from 'mastodon/api_types/collections';
|
||||
import { Badge } from 'mastodon/components/badge';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { useAccountHandle } from 'mastodon/components/display_name/default';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { Scrollable } from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { domain, me } from 'mastodon/initial_state';
|
||||
@@ -68,13 +75,15 @@ export const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
|
||||
};
|
||||
|
||||
const RevokeControls: React.FC<{
|
||||
currentUserCollectionItem: CollectionAccountItem;
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
}> = ({ currentUserCollectionItem, collection }) => {
|
||||
const authorAccount = useAccount(collection.account_id);
|
||||
const confirmRevoke = useConfirmRevoke(collection);
|
||||
|
||||
return (
|
||||
<Callout
|
||||
icon={StarIcon}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.you_are_in_this_collection'
|
||||
@@ -94,13 +103,40 @@ const RevokeControls: React.FC<{
|
||||
defaultMessage='{author} added you on {date}'
|
||||
values={{
|
||||
author: <DisplayName account={authorAccount} variant='simple' />,
|
||||
date: '{date}', // TODO: Data not yet provided by API
|
||||
date: (
|
||||
<FormattedDateWrapper
|
||||
value={currentUserCollectionItem.created_at}
|
||||
day='2-digit'
|
||||
month='short'
|
||||
year='numeric'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
export const PendingNote: React.FC = () => {
|
||||
return (
|
||||
<Callout
|
||||
variant='subtle'
|
||||
icon={HelpIcon}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.pending_accounts.title'
|
||||
defaultMessage='Why am I seeing pending accounts?'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.pending_accounts.message'
|
||||
defaultMessage='Accounts may appear as pending when we’re awaiting a response from the user or their server. Only you can see pending accounts.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
collection,
|
||||
}) => {
|
||||
@@ -110,10 +146,11 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
const history = useHistory();
|
||||
|
||||
const isOwnCollection = account_id === me;
|
||||
const currentUserIndex = items.findIndex(
|
||||
const currentUserCollectionItem = items.find(
|
||||
(account) => account.account_id === me,
|
||||
);
|
||||
const isCurrentUserInCollection = !isOwnCollection && currentUserIndex > -1;
|
||||
const isCurrentUserInCollection =
|
||||
!isOwnCollection && !!currentUserCollectionItem;
|
||||
|
||||
const openShareModal = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -136,6 +173,8 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
}
|
||||
}, [history, openShareModal, isNewCollection, location.pathname]);
|
||||
|
||||
const hasPendingAccounts = items.some((item) => item.state === 'pending');
|
||||
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
<div className={classes.titleWithMenu}>
|
||||
@@ -160,7 +199,13 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
{isCurrentUserInCollection && <RevokeControls collection={collection} />}
|
||||
{hasPendingAccounts && <PendingNote />}
|
||||
{isCurrentUserInCollection && (
|
||||
<RevokeControls
|
||||
currentUserCollectionItem={currentUserCollectionItem}
|
||||
collection={collection}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -174,7 +219,6 @@ export const CollectionDetailPage: React.FC<{
|
||||
const collection = useAppSelector((state) =>
|
||||
id ? state.collections.collections[id] : undefined,
|
||||
);
|
||||
const isLoading = !!id && !collection;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -195,8 +239,14 @@ export const CollectionDetailPage: React.FC<{
|
||||
/>
|
||||
|
||||
<Scrollable>
|
||||
{collection && <CollectionHeader collection={collection} />}
|
||||
<CollectionAccountsList collection={collection} isLoading={isLoading} />
|
||||
{collection ? (
|
||||
<>
|
||||
<CollectionHeader collection={collection} />
|
||||
<CollectionAccountsList collection={collection} />
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</Scrollable>
|
||||
|
||||
<Helmet>
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
export function useConfirmRevoke(collection?: ApiCollectionJSON) {
|
||||
export function useConfirmRevoke(collection?: ApiCollectionJSON | null) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { id, items = [] } = collection ?? {};
|
||||
const ownCollectionItemId = items.find((item) => item.account_id === me)?.id;
|
||||
|
||||
@@ -6,16 +6,17 @@ import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { useComboboxItemProps } from '@/mastodon/components/form_fields/combobox_field';
|
||||
import type { ApiMutedAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import { AccountListItem } from 'mastodon/components/account_list_item';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { PendingBadge } from 'mastodon/components/badge';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { useAccountHandle } from 'mastodon/components/display_name/default';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
||||
import { useComboboxItemProps } from 'mastodon/components/form_fields/combobox_field';
|
||||
import {
|
||||
ListItemContent,
|
||||
ListItemWrapper,
|
||||
@@ -31,12 +32,14 @@ import { domain } from 'mastodon/initial_state';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import {
|
||||
addCollectionItem,
|
||||
getCollectionItemIds,
|
||||
getEditorCollectionItems,
|
||||
removeCollectionItem,
|
||||
updateCollectionEditorField,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { PendingNote } from '../detail';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepTitle } from './wizard_step_title';
|
||||
|
||||
@@ -44,8 +47,9 @@ const MAX_ACCOUNT_COUNT = 25;
|
||||
|
||||
const AddedAccountItem: React.FC<{
|
||||
accountId: string;
|
||||
pending?: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
}> = ({ accountId, onRemove }) => {
|
||||
}> = ({ accountId, pending, onRemove }) => {
|
||||
const handleRemoveAccount = useCallback(() => {
|
||||
onRemove(accountId);
|
||||
}, [accountId, onRemove]);
|
||||
@@ -62,7 +66,13 @@ const AddedAccountItem: React.FC<{
|
||||
[handleRemoveAccount],
|
||||
);
|
||||
|
||||
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
|
||||
return (
|
||||
<AccountListItem
|
||||
accountId={accountId}
|
||||
badge={pending && <PendingBadge />}
|
||||
renderButton={renderButton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
|
||||
@@ -183,22 +193,26 @@ export const CollectionAccounts: React.FC<{
|
||||
const { id, items: collectionItems } = collection ?? {};
|
||||
const isEditMode = !!id;
|
||||
|
||||
const addedAccountIds = useAppSelector(
|
||||
(state) => state.collections.editor.accountIds,
|
||||
const editorItemsFromState = useAppSelector(
|
||||
(state) => state.collections.editor.items,
|
||||
);
|
||||
|
||||
// In edit mode, we're bypassing state and just return collection items directly,
|
||||
// since they're edited "live", saving after each addition/deletion
|
||||
const accountIds = useMemo(
|
||||
// In edit mode, we're bypassing our Redux state and just work on the
|
||||
// collection items directly since they're edited "live", saving right
|
||||
// after each addition/deletion
|
||||
const editorItems = useMemo(
|
||||
() =>
|
||||
isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds,
|
||||
[isEditMode, collectionItems, addedAccountIds],
|
||||
isEditMode
|
||||
? getEditorCollectionItems(collectionItems)
|
||||
: editorItemsFromState,
|
||||
[isEditMode, collectionItems, editorItemsFromState],
|
||||
);
|
||||
const hasPendingItems = editorItems.some((item) => item.state === 'pending');
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const hasAccounts = accountIds.length > 0;
|
||||
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
||||
const hasItems = editorItems.length > 0;
|
||||
const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
|
||||
|
||||
const {
|
||||
accounts: suggestedAccounts,
|
||||
@@ -208,7 +222,8 @@ export const CollectionAccounts: React.FC<{
|
||||
} = useSearchAccounts({
|
||||
withRelationships: true,
|
||||
// Don't suggest accounts that were already added
|
||||
filterResults: (account) => !accountIds.includes(account.id),
|
||||
filterResults: (account) =>
|
||||
!editorItems.find((item) => item.account_id === account.id),
|
||||
});
|
||||
|
||||
const relationships = useAppSelector((state) => state.relationships);
|
||||
@@ -236,24 +251,33 @@ export const CollectionAccounts: React.FC<{
|
||||
(accountId: string) => {
|
||||
dispatch(
|
||||
updateCollectionEditorField({
|
||||
field: 'accountIds',
|
||||
value: accountIds.filter((id) => id !== accountId),
|
||||
field: 'items',
|
||||
value: editorItems.filter((item) => item.account_id !== accountId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[accountIds, dispatch],
|
||||
[editorItems, dispatch],
|
||||
);
|
||||
|
||||
const addAccountItem = useCallback(
|
||||
(item: ApiMutedAccountJSON) => {
|
||||
dispatch(
|
||||
updateCollectionEditorField({
|
||||
field: 'accountIds',
|
||||
value: [...accountIds, item.id],
|
||||
field: 'items',
|
||||
value: [
|
||||
...editorItems,
|
||||
{
|
||||
account_id: item.id,
|
||||
state:
|
||||
item.feature_approval.current_user === 'manual'
|
||||
? 'pending'
|
||||
: 'accepted',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
[accountIds, dispatch],
|
||||
[editorItems, dispatch],
|
||||
);
|
||||
|
||||
const instantRemoveAccountItem = useCallback(
|
||||
@@ -319,12 +343,10 @@ export const CollectionAccounts: React.FC<{
|
||||
e.preventDefault();
|
||||
|
||||
if (!id) {
|
||||
history.push(`/collections/new/details`, {
|
||||
account_ids: accountIds,
|
||||
});
|
||||
history.push('/collections/new/details');
|
||||
}
|
||||
},
|
||||
[id, history, accountIds],
|
||||
[id, history],
|
||||
);
|
||||
|
||||
const inputId = useId();
|
||||
@@ -345,16 +367,17 @@ export const CollectionAccounts: React.FC<{
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasPendingItems && <PendingNote />}
|
||||
<ComboboxField
|
||||
id={inputId}
|
||||
label={intl.formatMessage({
|
||||
id: 'collections.search_accounts_label',
|
||||
defaultMessage: 'Search for an account to add',
|
||||
})}
|
||||
value={hasMaxAccounts ? '' : searchValue}
|
||||
value={hasMaxItems ? '' : searchValue}
|
||||
onChange={handleSearchValueChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
disabled={hasMaxAccounts}
|
||||
disabled={hasMaxItems}
|
||||
isLoading={isLoadingSuggestions}
|
||||
items={groupedItems}
|
||||
getItemId={getItemId}
|
||||
@@ -363,7 +386,7 @@ export const CollectionAccounts: React.FC<{
|
||||
renderGroupTitle={renderGroupTitle}
|
||||
onSelectItem={handleSelectItem}
|
||||
status={
|
||||
hasMaxAccounts
|
||||
hasMaxItems
|
||||
? {
|
||||
variant: 'warning',
|
||||
message: intl.formatMessage({
|
||||
@@ -378,12 +401,12 @@ export const CollectionAccounts: React.FC<{
|
||||
</header>
|
||||
|
||||
<div>
|
||||
{hasAccounts && (
|
||||
{hasItems && (
|
||||
<AccountsHeadingElement className={classes.listHeading}>
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count}/{max} accounts'
|
||||
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
||||
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
|
||||
/>
|
||||
</AccountsHeadingElement>
|
||||
)}
|
||||
@@ -410,14 +433,15 @@ export const CollectionAccounts: React.FC<{
|
||||
/>
|
||||
}
|
||||
>
|
||||
{accountIds.map((accountId, index) => (
|
||||
{editorItems.map(({ account_id, state }, index) => (
|
||||
<Article
|
||||
key={accountId}
|
||||
key={account_id}
|
||||
aria-posinset={index}
|
||||
aria-setsize={accountIds.length}
|
||||
aria-setsize={editorItems.length}
|
||||
>
|
||||
<AddedAccountItem
|
||||
accountId={accountId}
|
||||
accountId={account_id}
|
||||
pending={state === 'pending'}
|
||||
onRemove={handleRemoveAccountItem}
|
||||
/>
|
||||
</Article>
|
||||
@@ -426,7 +450,7 @@ export const CollectionAccounts: React.FC<{
|
||||
</Scrollable>
|
||||
</div>
|
||||
</FormStack>
|
||||
{!isEditMode && hasAccounts && (
|
||||
{!isEditMode && hasItems && (
|
||||
<div className={classes.stickyFooter}>
|
||||
<Button type='submit'>
|
||||
{id ? (
|
||||
|
||||
@@ -46,7 +46,7 @@ import { WizardStepTitle } from './wizard_step_title';
|
||||
export const CollectionDetails: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
||||
const { id, name, description, topic, discoverable, sensitive, items } =
|
||||
useAppSelector((state) => state.collections.editor);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
@@ -123,7 +123,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
description,
|
||||
discoverable,
|
||||
sensitive,
|
||||
account_ids: accountIds,
|
||||
account_ids: items.map((item) => item.account_id),
|
||||
};
|
||||
if (topic) {
|
||||
payload.tag_name = topic;
|
||||
@@ -152,7 +152,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
sensitive,
|
||||
dispatch,
|
||||
history,
|
||||
accountIds,
|
||||
items,
|
||||
currentUserName,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { useAccountCollections } from '..';
|
||||
import { useCollectionsCreatedBy } from '../overview/created_by_you';
|
||||
|
||||
import { CollectionAccounts } from './accounts';
|
||||
import { CollectionDetails } from './details';
|
||||
@@ -86,7 +86,7 @@ export const CollectionEditorPage: React.FC<{
|
||||
// When creating a new collection, we load the current account's collections
|
||||
// to determine if they're allowed to create more.
|
||||
const { collections: collectionList, status: collectionListStatus } =
|
||||
useAccountCollections(isEditMode ? null : accountId);
|
||||
useCollectionsCreatedBy(isEditMode ? null : accountId);
|
||||
|
||||
const isLoading =
|
||||
(isEditMode && !collection) ||
|
||||
@@ -144,7 +144,7 @@ export const CollectionEditorPage: React.FC<{
|
||||
/>
|
||||
</Switch>
|
||||
) : (
|
||||
<MaxCollectionsCallout />
|
||||
<MaxCollectionsCallout className={classes.maxCollectionsError} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -156,9 +156,11 @@ export const CollectionEditorPage: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const MaxCollectionsCallout: React.FC = () => (
|
||||
export const MaxCollectionsCallout: React.FC<{ className?: string }> = ({
|
||||
className,
|
||||
}) => (
|
||||
<Callout
|
||||
className={classes.maxCollectionsError}
|
||||
className={className}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.maximum_collection_count_reached'
|
||||
|
||||
@@ -1,57 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import CollectionsFilledIcon from '@/material-icons/400-24px/category-fill.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { TabLink, TabList } from '@/mastodon/components/tab_list';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import {
|
||||
ItemList,
|
||||
Scrollable,
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import { Scrollable } from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import {
|
||||
fetchAccountCollections,
|
||||
selectAccountCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from './components/collection_list_item';
|
||||
import {
|
||||
messages as editorMessages,
|
||||
MaxCollectionsCallout,
|
||||
userCollectionLimit,
|
||||
} from './editor';
|
||||
import { areCollectionsEnabled } from './utils';
|
||||
import { CollectionsCreatedByYou } from './overview/created_by_you';
|
||||
import { CollectionsFeaturingYou } from './overview/featuring_you';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' },
|
||||
headingMe: {
|
||||
id: 'column.your_collections',
|
||||
defaultMessage: 'Your Collections',
|
||||
},
|
||||
headingOther: {
|
||||
id: 'column.other_collections',
|
||||
defaultMessage: 'Collections by {name}',
|
||||
defaultMessage: "{name}'s Collections",
|
||||
},
|
||||
createdByYou: {
|
||||
id: 'collections.list.created_by_you',
|
||||
defaultMessage: 'Created by you',
|
||||
},
|
||||
createdByAuthor: {
|
||||
id: 'collections.list.created_by_author',
|
||||
defaultMessage: 'Created by {name}',
|
||||
},
|
||||
featuringYou: {
|
||||
id: 'collections.list.featuring_you',
|
||||
defaultMessage: 'Featuring you',
|
||||
},
|
||||
});
|
||||
|
||||
export function useAccountCollections(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
void dispatch(fetchAccountCollections({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return useAppSelector((state) => selectAccountCollections(state, accountId));
|
||||
}
|
||||
|
||||
export const Collections: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
@@ -59,37 +45,11 @@ export const Collections: React.FC<{
|
||||
const me = useCurrentAccountId();
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
const { collections, status } = useAccountCollections(accountId);
|
||||
const isOwnCollectionsPage = accountId === me;
|
||||
|
||||
const emptyMessage =
|
||||
status === 'error' || !accountId ? (
|
||||
<FormattedMessage
|
||||
id='collections.error_loading_collections'
|
||||
defaultMessage='There was an error when trying to load your collections.'
|
||||
tagName='span'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='collections.no_collections_yet'
|
||||
defaultMessage='No collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='collections.create_a_collection_hint'
|
||||
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<SquigglyArrow className='empty-column-indicator__arrow' />
|
||||
</>
|
||||
);
|
||||
|
||||
const canCreateMoreCollections = collections.length < userCollectionLimit;
|
||||
const isOwnCollection = accountId === me;
|
||||
const titleMessage = isOwnCollection
|
||||
const titleMessage = isOwnCollectionsPage
|
||||
? messages.headingMe
|
||||
: messages.headingOther;
|
||||
|
||||
@@ -100,45 +60,41 @@ export const Collections: React.FC<{
|
||||
name: <DisplayNameSimple account={account} />,
|
||||
});
|
||||
|
||||
const createdByTabMessage = isOwnCollectionsPage
|
||||
? messages.createdByYou
|
||||
: messages.createdByAuthor;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={pageTitle}>
|
||||
<ColumnHeader
|
||||
title={pageTitleHtml}
|
||||
icon='collections'
|
||||
iconComponent={CollectionsFilledIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
isOwnCollection &&
|
||||
status === 'idle' &&
|
||||
canCreateMoreCollections && (
|
||||
<Link
|
||||
to='/collections/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(editorMessages.create)}
|
||||
aria-label={intl.formatMessage(editorMessages.create)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ColumnHeader showBackButton multiColumn={multiColumn} />
|
||||
|
||||
<Scrollable>
|
||||
{status === 'idle' && !canCreateMoreCollections && (
|
||||
<MaxCollectionsCallout />
|
||||
)}
|
||||
<ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
|
||||
{collections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
withTimestamp
|
||||
withAuthorHandle={false}
|
||||
key={item.id}
|
||||
collection={item}
|
||||
positionInList={index + 1}
|
||||
listSize={collections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
<header className={classes.header}>
|
||||
<h1 className={classes.heading}>{pageTitleHtml}</h1>
|
||||
<TabList plain>
|
||||
<TabLink exact to={`/@${account?.acct}/collections`}>
|
||||
{intl.formatMessage(createdByTabMessage, {
|
||||
name: <DisplayNameSimple account={account} />,
|
||||
})}
|
||||
</TabLink>
|
||||
{isOwnCollectionsPage && (
|
||||
<TabLink
|
||||
exact
|
||||
to={`/@${account?.acct}/collections/featuring-you`}
|
||||
>
|
||||
{intl.formatMessage(messages.featuringYou)}
|
||||
</TabLink>
|
||||
)}
|
||||
</TabList>
|
||||
</header>
|
||||
<Switch>
|
||||
<Route exact path={path} component={CollectionsCreatedByYou} />
|
||||
<Route
|
||||
exact
|
||||
path={`${path}/featuring-you`}
|
||||
component={CollectionsFeaturingYou}
|
||||
/>
|
||||
</Switch>
|
||||
</Scrollable>
|
||||
|
||||
<Helmet>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import {
|
||||
fetchCollectionsCreatedByAccount,
|
||||
selectAccountCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from '../components/collection_list_item';
|
||||
import {
|
||||
messages as editorMessages,
|
||||
MaxCollectionsCallout,
|
||||
userCollectionLimit,
|
||||
} from '../editor';
|
||||
import classes from '../styles.module.scss';
|
||||
import { areCollectionsEnabled } from '../utils';
|
||||
|
||||
const CreateButton: React.FC = () => (
|
||||
<Link to='/collections/new' className='button button--compact'>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
<FormattedMessage {...editorMessages.newCollection} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const CollectionListError: React.FC = () => (
|
||||
<EmptyState
|
||||
image={null}
|
||||
className={classes.error}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='collections.error_loading_collections'
|
||||
defaultMessage='There was an error when trying to load these collections.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export function useCollectionsCreatedBy(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
void dispatch(fetchCollectionsCreatedByAccount({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return useAppSelector((state) =>
|
||||
selectAccountCollections(state, accountId, 'createdBy'),
|
||||
);
|
||||
}
|
||||
|
||||
export const CollectionsCreatedByYou: React.FC = () => {
|
||||
const me = useCurrentAccountId();
|
||||
const accountId = useAccountId();
|
||||
|
||||
const { collections, status } = useCollectionsCreatedBy(accountId);
|
||||
|
||||
const canCreateMoreCollections = collections.length < userCollectionLimit;
|
||||
const isOwnCollectionPage = accountId === me;
|
||||
const showCreateButton =
|
||||
isOwnCollectionPage && status === 'idle' && canCreateMoreCollections;
|
||||
|
||||
if (status === 'error' || !accountId) {
|
||||
return <CollectionListError />;
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts'
|
||||
defaultMessage='Showcase your favorite accounts'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CreateButton />
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.listHeader}>
|
||||
<h2 className={classes.subHeading}>
|
||||
<FormattedMessage
|
||||
id='collections.list.collections_with_count'
|
||||
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
|
||||
values={{
|
||||
count: collections.length,
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
{showCreateButton && <CreateButton />}
|
||||
</div>
|
||||
<ItemList>
|
||||
{!canCreateMoreCollections && (
|
||||
<MaxCollectionsCallout className={classes.maxCollectionsError} />
|
||||
)}
|
||||
{collections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
withTimestamp
|
||||
withAuthorHandle={false}
|
||||
key={item.id}
|
||||
collection={item}
|
||||
positionInList={index + 1}
|
||||
listSize={collections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import {
|
||||
fetchCollectionsFeaturingAccount,
|
||||
selectAccountCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from '../components/collection_list_item';
|
||||
import classes from '../styles.module.scss';
|
||||
import { areCollectionsEnabled } from '../utils';
|
||||
|
||||
import { CollectionListError } from './created_by_you';
|
||||
|
||||
function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
void dispatch(fetchCollectionsFeaturingAccount({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return useAppSelector((state) =>
|
||||
selectAccountCollections(state, accountId, 'featuring'),
|
||||
);
|
||||
}
|
||||
|
||||
export const CollectionsFeaturingYou: React.FC = () => {
|
||||
const accountId = useAccountId();
|
||||
|
||||
const { collections, status } = useCollectionsFeaturing(accountId);
|
||||
|
||||
if (status === 'error' || !accountId) {
|
||||
return <CollectionListError />;
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.listHeader}>
|
||||
<h2 className={classes.subHeading}>
|
||||
<FormattedMessage
|
||||
id='collections.list.collections_with_count'
|
||||
defaultMessage='{count, plural, one {# Collection} other {# Collections}}'
|
||||
values={{
|
||||
count: collections.length,
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<ItemList>
|
||||
{collections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
withAuthorHandle
|
||||
key={item.id}
|
||||
collection={item}
|
||||
positionInList={index + 1}
|
||||
listSize={collections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 24px;
|
||||
padding-inline: 16px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding-top: 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.subHeading {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.maxCollectionsError {
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
@@ -14,7 +12,6 @@ import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { injectIntl } from '@/mastodon/components/intl';
|
||||
|
||||
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -156,7 +153,6 @@ class ModifierPicker extends PureComponent {
|
||||
class EmojiPickerMenuImpl extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
@@ -254,7 +250,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const { loading, style, intl, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
@@ -264,32 +260,16 @@ class EmojiPickerMenuImpl extends PureComponent {
|
||||
|
||||
const { modifierOpen } = this.state;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
custom={buildCustomEmojis(custom_emojis)}
|
||||
color=''
|
||||
emoji=''
|
||||
title={title}
|
||||
i18n={this.getI18n()}
|
||||
onClick={this.handleClick}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
@@ -315,9 +295,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
||||
const EmojiPickerMenu = injectIntl(EmojiPickerMenuImpl);
|
||||
|
||||
class EmojiPickerDropdown extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
intl: PropTypes.object.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
@@ -346,7 +324,9 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
this.setState({ loading: false });
|
||||
void EmojiMart.loadCustomEmojiData().then(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false, active: false });
|
||||
});
|
||||
@@ -408,7 +388,6 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
<div {...props} style={{ ...props.style }}>
|
||||
<div className={`dropdown-animation ${placement}`}>
|
||||
<EmojiPickerMenu
|
||||
custom_emojis={this.props.custom_emojis}
|
||||
loading={loading}
|
||||
onClose={this.onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
|
||||
-16
@@ -47,23 +47,7 @@ const getFrequentlyUsedEmojis = createSelector([
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
state => state.get('custom_emojis'),
|
||||
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode').toLowerCase();
|
||||
const bShort = b.get('shortcode').toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort ) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}));
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
custom_emojis: getCustomEmojis(state),
|
||||
skinTone: state.getIn(['settings', 'skinTone']),
|
||||
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
|
||||
});
|
||||
|
||||
@@ -168,29 +168,3 @@ const emojify = (str, customEmojis = {}) => {
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = (customEmojis) => {
|
||||
const emojis = [];
|
||||
|
||||
customEmojis.forEach(emoji => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
|
||||
const name = shortcode.replace(':', '');
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
short_names: [name],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: [name],
|
||||
imageUrl: url,
|
||||
custom: true,
|
||||
customCategory: emoji.get('category'),
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
||||
export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom']));
|
||||
@@ -1,49 +1,62 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import type { EmojiProps, PickerProps } from 'emoji-mart';
|
||||
import EmojiRaw from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
|
||||
import PickerRaw from 'emoji-mart/dist-es/components/picker/nimble-picker';
|
||||
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
|
||||
import { EMOJI_MODE_NATIVE } from './constants';
|
||||
import EmojiData from './emoji_data.json';
|
||||
import { useEmojiAppState } from './mode';
|
||||
import { usePickerEmojis } from './picker';
|
||||
|
||||
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_16_0.png`;
|
||||
|
||||
const Emoji = ({
|
||||
export { fetchCustomEmojiData as loadCustomEmojiData } from './picker';
|
||||
|
||||
export const Picker: FC<PickerProps> = ({
|
||||
set = 'twitter',
|
||||
sheetSize = 32,
|
||||
sheetColumns = 62,
|
||||
sheetRows = 62,
|
||||
backgroundImageFn = backgroundImageFnDefault,
|
||||
...props
|
||||
}: EmojiProps) => {
|
||||
}) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
const { customCategories, customEmojis } = usePickerEmojis();
|
||||
|
||||
if (!customEmojis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PickerRaw
|
||||
data={EmojiData}
|
||||
custom={customEmojis}
|
||||
include={customCategories}
|
||||
set={set}
|
||||
sheetSize={sheetSize}
|
||||
sheetColumns={sheetColumns}
|
||||
sheetRows={sheetRows}
|
||||
native={mode === EMOJI_MODE_NATIVE}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Emoji: FC<EmojiProps> = ({
|
||||
set = 'twitter',
|
||||
sheetSize = 32,
|
||||
sheetColumns = 62,
|
||||
sheetRows = 62,
|
||||
backgroundImageFn = backgroundImageFnDefault,
|
||||
...props
|
||||
}) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
return (
|
||||
<EmojiRaw
|
||||
data={EmojiData}
|
||||
set={set}
|
||||
sheetSize={sheetSize}
|
||||
sheetColumns={sheetColumns}
|
||||
sheetRows={sheetRows}
|
||||
native={mode === EMOJI_MODE_NATIVE}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Picker = ({
|
||||
set = 'twitter',
|
||||
sheetSize = 32,
|
||||
sheetColumns = 62,
|
||||
sheetRows = 62,
|
||||
backgroundImageFn = backgroundImageFnDefault,
|
||||
...props
|
||||
}: PickerProps) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
return (
|
||||
<PickerRaw
|
||||
data={EmojiData}
|
||||
set={set}
|
||||
sheetSize={sheetSize}
|
||||
@@ -55,5 +68,3 @@ const Picker = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Picker, Emoji };
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { CategoryName, CustomEmoji } from 'emoji-mart';
|
||||
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('picker');
|
||||
|
||||
let customEmojis: CustomEmoji[] | null = null;
|
||||
let customCategories = [
|
||||
'recent',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
] as CategoryName[];
|
||||
|
||||
export async function fetchCustomEmojiData() {
|
||||
if (customEmojis !== null) {
|
||||
return customEmojis;
|
||||
}
|
||||
|
||||
const { loadAllCustomEmoji } = await import('./database');
|
||||
const emojisRaw = await loadAllCustomEmoji();
|
||||
if (emojisRaw.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories = new Set(['custom']);
|
||||
const emojis = [];
|
||||
for (const emoji of emojisRaw) {
|
||||
const name = emoji.shortcode.replaceAll(':', '');
|
||||
emojis.push({
|
||||
name,
|
||||
id: name,
|
||||
custom: true,
|
||||
short_names: [name],
|
||||
imageUrl: autoPlayGif ? emoji.url : emoji.static_url,
|
||||
customCategory: emoji.category,
|
||||
});
|
||||
|
||||
if (emoji.category) {
|
||||
categories.add(`custom-${emoji.category}`);
|
||||
}
|
||||
}
|
||||
|
||||
customEmojis = emojis.toSorted((a, b) => {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
customCategories = customCategories.toSpliced(
|
||||
1,
|
||||
0,
|
||||
...(Array.from(categories).toSorted() as CategoryName[]),
|
||||
);
|
||||
log(
|
||||
'loaded %d custom emojis in %d categories',
|
||||
customEmojis.length,
|
||||
categories.size,
|
||||
);
|
||||
|
||||
return customEmojis;
|
||||
}
|
||||
|
||||
export function usePickerEmojis() {
|
||||
const [, setLoaded] = useState(customEmojis !== null);
|
||||
|
||||
useEffect(() => {
|
||||
if (customEmojis === null) {
|
||||
void fetchCustomEmojiData().then(() => {
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
customEmojis,
|
||||
customCategories,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { FC } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
@@ -15,6 +19,7 @@ export const AccountListHeader: FC<{
|
||||
}> = ({ accountId, total, titleText }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const currentId = useCurrentAccountId();
|
||||
return (
|
||||
<>
|
||||
<h1 className={classes.title}>
|
||||
@@ -31,6 +36,35 @@ export const AccountListHeader: FC<{
|
||||
/>
|
||||
</h2>
|
||||
)}
|
||||
{accountId === currentId && account?.hide_collections && (
|
||||
<Callout className={classes.callout}>
|
||||
<FormattedMessage
|
||||
id='account_list.hidden_notice'
|
||||
defaultMessage='This is only visible to you. To show this list to others, go to <link>{page} > {modal} > {field}</link>.'
|
||||
values={{
|
||||
link: (chunks) => <Link to='/profile/edit'>{chunks}</Link>,
|
||||
page: (
|
||||
<FormattedMessage
|
||||
id='account.edit_profile'
|
||||
defaultMessage='Edit profile'
|
||||
/>
|
||||
),
|
||||
modal: (
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.title'
|
||||
defaultMessage='Profile display settings'
|
||||
/>
|
||||
),
|
||||
field: (
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_relations.title'
|
||||
defaultMessage='Show ‘Followers’ and ‘Following’'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,12 @@
|
||||
margin: 20px 16px 10px;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.callout {
|
||||
margin: 10px 16px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 10px 16px;
|
||||
}
|
||||
+6
-1
@@ -24,9 +24,14 @@ export const NotificationCollection: React.FC<{
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const { collection, type } = notification;
|
||||
const collectionCreatorAccount = useAccount(collection.account_id);
|
||||
|
||||
const collectionCreatorAccount = useAccount(collection?.account_id);
|
||||
const confirmRevoke = useConfirmRevoke(collection);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
|
||||
+2
-1
@@ -65,7 +65,7 @@ export const ConfirmationModal: React.FC<
|
||||
}, [onClose, onSecondary]);
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShell onSubmit={handleClick}>
|
||||
<ModalShellBody className={className}>
|
||||
<h1 id={titleId}>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
@@ -99,6 +99,7 @@ export const ConfirmationModal: React.FC<
|
||||
|
||||
{/* eslint-disable jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
|
||||
<Button
|
||||
type='submit'
|
||||
onClick={handleClick}
|
||||
loading={updating}
|
||||
disabled={disabled}
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ export const ConfirmDeleteCollectionModal: React.FC<
|
||||
const history = useHistory();
|
||||
const { acct: currentUserName } = useAccount(me) ?? {};
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
void dispatch(deleteCollection({ collectionId: id }));
|
||||
const onConfirm = useCallback(async () => {
|
||||
await dispatch(deleteCollection({ collectionId: id }));
|
||||
history.push(`/@${currentUserName}/collections`);
|
||||
}, [dispatch, history, id, currentUserName]);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function PinnedStatuses () {
|
||||
}
|
||||
|
||||
export function AccountTimeline () {
|
||||
return import('../../account_timeline/v2');
|
||||
return import('../../account_timeline');
|
||||
}
|
||||
|
||||
export function AccountGallery () {
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
"account.block_domain": "حظر اسم النِّطاق {domain}",
|
||||
"account.block_short": "حظر",
|
||||
"account.blocked": "محظور",
|
||||
"account.blocking": "محظور",
|
||||
"account.cancel_follow_request": "إلغاء طلب المتابعة",
|
||||
"account.copy": "نسخ الرابط إلى الملف الشخصي",
|
||||
"account.direct": "إشارة خاصة لـ @{name}",
|
||||
"account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}",
|
||||
"account.domain_blocking": "نطاق محظور",
|
||||
"account.edit_note": "تعديل الملاحظة الشخصية",
|
||||
"account.edit_profile": "تعديل الملف الشخصي",
|
||||
"account.edit_profile_short": "تعديل",
|
||||
@@ -95,7 +93,6 @@
|
||||
"account.mute_notifications_short": "كتم الإشعارات",
|
||||
"account.mute_short": "اكتم",
|
||||
"account.muted": "مَكتوم",
|
||||
"account.muting": "مكتوم",
|
||||
"account.mutual": "أنتم تتابعون بعضكم البعض",
|
||||
"account.name.copy": "نسخ المعرف",
|
||||
"account.name_info": "ما معنى ذلك؟",
|
||||
@@ -112,7 +109,6 @@
|
||||
"account.remove_from_followers": "إزالة {name} من المتابعين",
|
||||
"account.report": "الإبلاغ عن @{name}",
|
||||
"account.requested_follow": "لقد طلب {name} متابعتك",
|
||||
"account.requests_to_follow_you": "طلبات المتابعة",
|
||||
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
||||
"account.show_reblogs": "اعرض إعادات نشر @{name}",
|
||||
"account.statuses_counter": "{count, plural, zero {}one {{counter} مشور} two {{counter} منشور} few {{counter} منشور} many {{counter} منشور} other {{counter} منشور}}",
|
||||
@@ -269,7 +265,6 @@
|
||||
"collections.copy_link_confirmation": "نسخ الرابط إلى الحافظة",
|
||||
"collections.create.basic_details_title": "المعلومات الأساسية",
|
||||
"collections.create.steps": "الخطوة {step}/{total}",
|
||||
"collections.detail.accounts_heading": "الحسابات",
|
||||
"collections.detail.revoke_inclusion": "أزلني",
|
||||
"collections.detail.sensitive_content": "محتوى حساس",
|
||||
"collections.edit_details": "تعديل التفاصيل",
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
"account.block_domain": "{domain} domenini blokla",
|
||||
"account.block_short": "Blok",
|
||||
"account.blocked": "Bloklanıb",
|
||||
"account.blocking": "Əngəlləmə",
|
||||
"account.cancel_follow_request": "İzləməni ləğv et",
|
||||
"account.copy": "Profil linkini kopyala",
|
||||
"account.direct": "@{name} istifadəçisini fərdi olaraq etiketlə",
|
||||
"account.disable_notifications": "@{name} paylaşım edəndə mənə bildiriş göndərməyi dayandır",
|
||||
"account.domain_blocking": "Domenin bloklanması",
|
||||
"account.edit_note": "Fərdi nota düzəliş",
|
||||
"account.edit_profile": "Profili redaktə et",
|
||||
"account.edit_profile_short": "Düzəliş",
|
||||
@@ -109,7 +107,6 @@
|
||||
"account.mute_notifications_short": "Bildirişləri səssizləşdir",
|
||||
"account.mute_short": "Səssizləşdir",
|
||||
"account.muted": "Səssizləşdirilib",
|
||||
"account.muting": "Səssizə alınır",
|
||||
"account.mutual": "Bir-birinizi izləyirsiniz",
|
||||
"account.no_bio": "Təsvir göstərilməyib.",
|
||||
"account.open_original_page": "Orijinal səhifəni aç",
|
||||
@@ -117,7 +114,6 @@
|
||||
"account.remove_from_followers": "{name} - izləyicilərdən çıxart",
|
||||
"account.report": "@{name} istifadəçisini şikayət et",
|
||||
"account.requested_follow": "{name} sizi izləmək sorğusu göndərib",
|
||||
"account.requests_to_follow_you": "Sizi izləmək istəyir",
|
||||
"account.share": "@{name} profilini paylaş",
|
||||
"account.show_reblogs": "@{name} - təkrar paylaşımlarını göstər",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} paylaşım} other {{counter} paylaşım}}",
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
"account.block_domain": "Заблакіраваць дамен {domain}",
|
||||
"account.block_short": "Заблакіраваць",
|
||||
"account.blocked": "Заблакіраваны",
|
||||
"account.blocking": "Блакіраванне",
|
||||
"account.cancel_follow_request": "Скасаваць запыт на падпіску",
|
||||
"account.copy": "Скапіраваць спасылку на профіль",
|
||||
"account.direct": "Згадаць прыватна @{name}",
|
||||
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
||||
"account.domain_blocking": "Блакіраванне дамена",
|
||||
"account.edit_note": "Рэдагаваць асабістую нататку",
|
||||
"account.edit_profile": "Рэдагаваць профіль",
|
||||
"account.edit_profile_short": "Рэдагаваць",
|
||||
@@ -111,7 +109,6 @@
|
||||
"account.mute_notifications_short": "Не апавяшчаць",
|
||||
"account.mute_short": "Ігнараваць",
|
||||
"account.muted": "Ігнаруецца",
|
||||
"account.muting": "Ігнараванне",
|
||||
"account.mutual": "Вы падпісаныя адно на аднаго",
|
||||
"account.name.copy": "Скапір. ідэнтыфікатар",
|
||||
"account.name.help.domain": "{domain} — сервер, які ўтрымлівае профіль і допісы гэтага карыстальніка.",
|
||||
@@ -135,7 +132,6 @@
|
||||
"account.remove_from_followers": "Выдаліць {name} з падпісчыкаў",
|
||||
"account.report": "Паскардзіцца на @{name}",
|
||||
"account.requested_follow": "{name} адправіў(-ла) запыт на падпіску",
|
||||
"account.requests_to_follow_you": "Хоча падпісацца на вас",
|
||||
"account.share": "Абагуліць профіль @{name}",
|
||||
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
||||
@@ -227,8 +223,6 @@
|
||||
"account_edit.profile_tab.show_media.title": "Паказваць укладку \"Медыя\"",
|
||||
"account_edit.profile_tab.show_media_replies.description": "Калі ўключыць, укладка \"Медыя\" будзе адлюстроўваць як Вашыя допісы, гэтак і Вашыя адказы на допісы іншых людзей.",
|
||||
"account_edit.profile_tab.show_media_replies.title": "Змяшчаць адказы ва ўкладцы \"Медыя\"",
|
||||
"account_edit.profile_tab.subtitle": "Змяняйце на свой густ укладкі свайго профілю і тое, што яны паказваюць.",
|
||||
"account_edit.profile_tab.title": "Налады ўкладкі профілю",
|
||||
"account_edit.save": "Захаваць",
|
||||
"account_edit.upload_modal.back": "Назад",
|
||||
"account_edit.upload_modal.done": "Гатова",
|
||||
@@ -387,11 +381,9 @@
|
||||
"collections.create.accounts_title": "Каго Вы ўключыце ў гэтую калекцыю?",
|
||||
"collections.create.basic_details_title": "Асноўныя звесткі",
|
||||
"collections.create.steps": "Крок {step}/{total}",
|
||||
"collections.create_a_collection_hint": "Стварыце калекцыю, каб параіць або падзяліцца сваімі любімымі ўліковымі запісамі з іншымі.",
|
||||
"collections.create_collection": "Стварыць калекцыю",
|
||||
"collections.delete_collection": "Выдаліць калекцыю",
|
||||
"collections.description_length_hint": "Максімум 100 сімвалаў",
|
||||
"collections.detail.accounts_heading": "Уліковыя запісы",
|
||||
"collections.detail.author_added_you_on_date": "{author} дадаў(-ла) Вас {date}",
|
||||
"collections.detail.loading": "Загружаецца калекцыя…",
|
||||
"collections.detail.revoke_inclusion": "Прыбраць сябе",
|
||||
@@ -400,11 +392,14 @@
|
||||
"collections.detail.share": "Падзяліцца гэтай калекцыяй",
|
||||
"collections.detail.you_are_in_this_collection": "Вас уключылі ў гэтую калекцыю",
|
||||
"collections.edit_details": "Рэдагаваць падрабязнасці",
|
||||
"collections.error_loading_collections": "Адбылася памылка падчас загрузкі Вашых калекцый.",
|
||||
"collections.hidden_accounts_description": "Вы заблакіравалі або ігнаруеце {count, plural, one {гэтага карыстальніка} other {гэтых карыстальнікаў}}",
|
||||
"collections.hidden_accounts_link": "{count, plural, one {# схаваны ўліковы запіс} few {# схаваныя ўліковыя запісы} other {# схаваных уліковых запісаў}}",
|
||||
"collections.hints.accounts_counter": "{count}/{max} уліковых запісаў",
|
||||
"collections.last_updated_at": "Апошняе абнаўленне: {date}",
|
||||
"collections.list.collections_with_count": "{count, plural, one {калекцыя} few {калекцыі} many {калекцыяў} other {калекцыі}}",
|
||||
"collections.list.created_by_author": "Створаныя {name}",
|
||||
"collections.list.created_by_you": "Створаныя Вамі",
|
||||
"collections.list.featuring_you": "У якіх ёсць Вы",
|
||||
"collections.manage_accounts": "Кіраванне ўліковымі запісамі",
|
||||
"collections.mark_as_sensitive": "Пазначыць як адчувальную",
|
||||
"collections.mark_as_sensitive_hint": "Схаваць апісанне калекцыі і ўліковыя запісы за банерам з папярэджаннем. Назва калекцыі застанецца бачнай.",
|
||||
@@ -412,7 +407,6 @@
|
||||
"collections.maximum_collection_count_reached": "Вы стварылі максімальную колькасць калекцый",
|
||||
"collections.name_length_hint": "Максімум 40 сімвалаў",
|
||||
"collections.new_collection": "Новая калекцыя",
|
||||
"collections.no_collections_yet": "Пакуль няма калекцый.",
|
||||
"collections.remove_account": "Выдаліць",
|
||||
"collections.report_collection": "Паскардзіцца на гэту калекцыю",
|
||||
"collections.revoke_collection_inclusion": "Прыбраць сябе з гэтай калекцыі",
|
||||
@@ -454,11 +448,11 @@
|
||||
"column.list_members": "Кіраванне ўдзельнікамі спіса",
|
||||
"column.lists": "Спісы",
|
||||
"column.mutes": "Ігнараваныя карыстальнікі",
|
||||
"column.my_collections": "Мае калекцыі",
|
||||
"column.notifications": "Апавяшчэнні",
|
||||
"column.other_collections": "Калекцыі {name}",
|
||||
"column.pins": "Замацаваныя допісы",
|
||||
"column.public": "Інтэграваная стужка",
|
||||
"column.your_collections": "Вашы калекцыі",
|
||||
"column_back_button.label": "Назад",
|
||||
"column_header.hide_settings": "Схаваць налады",
|
||||
"column_header.moveLeft_settings": "Пасунуць слупок улева",
|
||||
|
||||
@@ -21,12 +21,10 @@
|
||||
"account.block_domain": "Блокиране на домейн {domain}",
|
||||
"account.block_short": "Блокиране",
|
||||
"account.blocked": "Блокирани",
|
||||
"account.blocking": "Блокиране",
|
||||
"account.cancel_follow_request": "Оттегляне на заявката за последване",
|
||||
"account.copy": "Копиране на връзка към профила",
|
||||
"account.direct": "Частно споменаване на @{name}",
|
||||
"account.disable_notifications": "Спиране на известяване при публикуване от @{name}",
|
||||
"account.domain_blocking": "Блокиране на домейн",
|
||||
"account.edit_profile": "Редактиране на профила",
|
||||
"account.edit_profile_short": "Редактиране",
|
||||
"account.enable_notifications": "Известяване при публикуване от @{name}",
|
||||
@@ -63,7 +61,6 @@
|
||||
"account.mute_notifications_short": "Заглушаване на известията",
|
||||
"account.mute_short": "Заглушаване",
|
||||
"account.muted": "Заглушено",
|
||||
"account.muting": "Заглушаване",
|
||||
"account.mutual": "Взаимно се следвате",
|
||||
"account.no_bio": "Няма представен опис.",
|
||||
"account.open_original_page": "Отваряне на първообразната страница",
|
||||
@@ -71,7 +68,6 @@
|
||||
"account.remove_from_followers": "Премахване на {name} от последователи",
|
||||
"account.report": "Докладване на @{name}",
|
||||
"account.requested_follow": "{name} поиска да ви последва",
|
||||
"account.requests_to_follow_you": "Заявки да ви последват",
|
||||
"account.share": "Споделяне на профила на @{name}",
|
||||
"account.show_reblogs": "Показване на подсилвания от @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
|
||||
|
||||
@@ -21,12 +21,10 @@
|
||||
"account.block_domain": "Stankañ an domani {domain}",
|
||||
"account.block_short": "Stankañ",
|
||||
"account.blocked": "Stanket",
|
||||
"account.blocking": "Stanket",
|
||||
"account.cancel_follow_request": "Nullañ ar reked heuliañ",
|
||||
"account.copy": "Eilañ al liamm war-zu ho profil",
|
||||
"account.direct": "Menegiñ @{name} ent-prevez",
|
||||
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
||||
"account.domain_blocking": "Domani stanket",
|
||||
"account.edit_profile": "Kemmañ ar profil",
|
||||
"account.edit_profile_short": "Kemmañ",
|
||||
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
||||
@@ -62,7 +60,6 @@
|
||||
"account.mute_notifications_short": "Kuzhat ar c'hemennoù",
|
||||
"account.mute_short": "Kuzhat",
|
||||
"account.muted": "Kuzhet",
|
||||
"account.muting": "O kuzhat",
|
||||
"account.mutual": "Heuliañ a rit an eil egile",
|
||||
"account.no_bio": "Deskrivadur ebet da gaout.",
|
||||
"account.open_original_page": "Digeriñ ar bajenn orin",
|
||||
@@ -70,7 +67,6 @@
|
||||
"account.remove_from_followers": "Dilemel {name} eus an heulierien·ezed",
|
||||
"account.report": "Disklêriañ @{name}",
|
||||
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
|
||||
"account.requests_to_follow_you": "Rekedoù d'ho heuliañ",
|
||||
"account.share": "Skignañ profil @{name}",
|
||||
"account.show_reblogs": "Diskouez skignadennoù @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} embannadur} two {{counter} embannadur} few {{counter} embannadur} many {{counter} embannadur} other {{counter} embannadur}}",
|
||||
|
||||
@@ -27,12 +27,10 @@
|
||||
"account.block_domain": "Bloca el domini {domain}",
|
||||
"account.block_short": "Bloca",
|
||||
"account.blocked": "Blocat",
|
||||
"account.blocking": "Blocat",
|
||||
"account.cancel_follow_request": "Cancel·la el seguiment",
|
||||
"account.copy": "Copia l'enllaç al perfil",
|
||||
"account.direct": "Menciona privadament @{name}",
|
||||
"account.disable_notifications": "Deixa de notificar-me els tuts de @{name}",
|
||||
"account.domain_blocking": "Bloquem el domini",
|
||||
"account.edit_note": "Edita una nota personal",
|
||||
"account.edit_profile": "Edita el perfil",
|
||||
"account.edit_profile_short": "Edita",
|
||||
@@ -95,7 +93,6 @@
|
||||
"account.mute_notifications_short": "Silencia les notificacions",
|
||||
"account.mute_short": "Silencia",
|
||||
"account.muted": "Silenciat",
|
||||
"account.muting": "Silenciem",
|
||||
"account.mutual": "Us seguiu l'un a l'altre",
|
||||
"account.no_bio": "No s'ha proporcionat cap descripció.",
|
||||
"account.node_modal.callout": "Ningú més pot veure les vostres notes personals.",
|
||||
@@ -111,7 +108,6 @@
|
||||
"account.remove_from_followers": "Elimina {name} dels seguidors",
|
||||
"account.report": "Informa sobre @{name}",
|
||||
"account.requested_follow": "{name} ha demanat de seguir-te",
|
||||
"account.requests_to_follow_you": "Peticions de seguir-vos",
|
||||
"account.share": "Comparteix el perfil de @{name}",
|
||||
"account.show_reblogs": "Mostra els impulsos de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
|
||||
@@ -205,15 +201,12 @@
|
||||
"collections.collection_description": "Descripció",
|
||||
"collections.collection_name": "Nom",
|
||||
"collections.collection_topic": "Tema",
|
||||
"collections.create_a_collection_hint": "Creeu una coŀlecció per a recomanar o compartir amb d'altres els vostres comptes preferits.",
|
||||
"collections.create_collection": "Crea una coŀlecció",
|
||||
"collections.delete_collection": "Elimina la coŀlecció",
|
||||
"collections.description_length_hint": "Límit de 100 caràcters",
|
||||
"collections.error_loading_collections": "Hi ha hagut un error en carregar les vostres coŀleccions.",
|
||||
"collections.last_updated_at": "Última actualització: {date}",
|
||||
"collections.mark_as_sensitive": "Marcar com a sensible",
|
||||
"collections.mark_as_sensitive_hint": "Amaga la descripció i els comptes de la coŀlecció rere un avís de contingut. El nom de la coŀlecció serà encara visible.",
|
||||
"collections.no_collections_yet": "Encara no hi ha coŀleccions.",
|
||||
"collections.topic_hint": "Afegir una etiqueta que ajudi altres a entendre el tema principal de la coŀlecció.",
|
||||
"collections.view_collection": "Mostra la coŀlecció",
|
||||
"column.about": "Quant a",
|
||||
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user